FRAMR.WALKTHROUGHENES

00 / CODE GUIDE

HOW
THIS
WORKS.

A technical read of FRAMR. Every decision, every file, every bit of math, explained.

This isn't a marketing README, it's the project's internal guide. To understand why something is built the way it is, it's here. Recommended reading order: top to bottom, though each section stands on its own.

01 / Bird's-eye view

FRAMR is a 100% client-side app. No backend, no database, no uploads to any server. All image processing happens in the browser using the Canvas API.

The flow is simple:

  1. The user uploads an image (drag-drop or file picker).
  2. The browser loads it into an HTMLImageElement and stores its dimensions.
  3. For each of the 23 platform presets, an interactive preview is rendered.
  4. The user repositions/zooms each frame with drag + scroll/slider.
  5. On download, the image is drawn onto a <canvas> at the exact preset size, converted to WebP via canvas.toBlob(), and a download is triggered.
  6. ZIPs are built with JSZip in the browser, also network-free.
IMAGEPNG · JPG · WEBPHTMLImageElementnaturalW · naturalHblob URLPreset catalog23 formats · lib/platformsTransforms statezoom · cx · cy per presetPresetCard ×23interactive previewdrag + zoom.WEBP+ZIPcomputeFrame(): the same functionfor the preview AND the export canvas
WHY THIS WAY?The most important rule: the source image never leaves the browser. It's a strong privacy argument (no explanation needed for creators) and it also saves the cost of a backend. Everything Canvas API needs has been in the browser for years.

02 / File map

The app is small, but everything has its place:

src/ ├── app/ │ ├── layout.tsx # fonts, skip-link, lang="en" │ ├── globals.css # tokens, focus-visible, reduced-motion │ ├── page.tsx # main app (Uploader + grid of PresetCards) │ ├── page.module.css │ ├── ui-kit/ │ │ ├── page.tsx # /ui-kit: design system showcase │ │ └── page.module.css │ └── walkthrough/ │ ├── page.tsx # /walkthrough: this very doc (EN) │ └── es/page.tsx # /walkthrough/es: ES version ├── components/ │ ├── Header.tsx # FRAMR. + WALKTHROUGH + UI KIT button │ ├── Header.module.css │ ├── Uploader.tsx # drag & drop + picker │ ├── Uploader.module.css │ ├── PresetCard.tsx # interactive preview + per-preset download │ └── PresetCard.module.css └── lib/ ├── platforms.ts # the 23 presets (Instagram, TikTok, etc.) ├── transform.ts # crop math (zoom·cx·cy → tx·ty·scale) └── export.ts # canvas → WebP → ZIP

Rules enforced in this tree:

  • Components in components/, pure logic in lib/, pages in app/. No catch-all utils.ts.
  • Every component has its .module.css next to it. No Tailwind. Every class is scoped to its file.
  • Nothing in lib/ imports from components/. One-way dependency only.

03 / Platform catalog

The dimensions for each social network live hardcoded in lib/platforms.ts. They're static data, officially valid for 2026, sourced from help centers and reference blogs (Buffer, Hootsuite, etc.).

export type Preset = {
  id: string;          // "ig_feed_portrait", unique
  label: string;       // "Feed Portrait"
  width: number;       // 1080
  height: number;      // 1350
  ratio: string;       // "4:5" (info, not used in math)
  minWidth: number;    // 320 (platform-recommended minimum)
};

export type Platform = {
  id: string;          // "instagram"
  name: string;        // "Instagram"
  color: string;       // "#E4405F" (legacy, overridden in CSS)
  presets: Preset[];
};

Six platforms with a total of 23 presets. Some examples:

PlatformPresetDimRatio
InstagramFeed Portrait1080 × 13504:5
InstagramStory / Reel1080 × 19209:16
TikTokVideo Cover1080 × 19209:16
YouTubeThumbnail1280 × 72016:9
YouTubeChannel Banner2560 × 144016:9
X (Twitter)Header1500 × 5003:1
LinkedInPersonal Banner1584 × 3964:1
FacebookCover Photo851 × 3152.7:1

The file also exports two small helpers:

export function getCoverScale(imgW, imgH, presetW, presetH) {
  return Math.max(presetW / imgW, presetH / imgH);
}

export function meetsRequirement(imgW, imgH, preset) {
  return imgW >= preset.width && imgH >= preset.height;
}

getCoverScale tells how much the image needs to be enlarged to completely cover a preset. If it returns > 1, the image needs upscaling (and will be pixelated). It's the foundation for the ⚠ +N% warning.

04 / The transform model

This is the most important piece of the whole project. It's where the math that keeps the crop coherent between what's seen on screen and what's downloaded lives.

The problem

We need to represent "how this image is cropped for this preset" with a small object that is independent of the preview dimensions. Why? Because the preview is displayed at 240px, but the export happens at 1080×1920 or 2560×1440. The same "crop configuration" has to work for both.

The solution: 3 numbers

export type Transform = {
  zoom: number;  // >= 1, multiplier on top of "cover fit"
  cx: number;    // 0..1, horizontal center position in image coords
  cy: number;    // 0..1, vertical center position in image coords
};

The mental model:

  • zoom = 1 means "the image exactly covers the frame" (cover-fit).
  • zoom = 2 means "twice as big" → less image visible but with more detail.
  • cx = 0.5, cy = 0.5 places the image center at the frame center.
  • cx = 0.2 means "looking at 20% horizontally into the image", so the focus shifts to the left.
WHY NOT STORE tx, ty, scale?Because those values are tied to a specific frame size. If tx = 120px is stored, that means different things for a 240px frame versus a 1080px frame. By storing a normalized focal point(0..1) and a relative zoom, the same config works for any output size. It's resolution-independent.

05 / computeFrame(), step by step

This function takes an abstract transform and a frame size, and returns the concrete pixel values for rendering.

export function computeFrame(
  imgW, imgH, frameW, frameH, t
) {
  // 1. The minimum scale that guarantees cover (image fills the frame).
  const base = Math.max(frameW / imgW, frameH / imgH);

  // 2. Effective scale includes the user's zoom.
  const scale = base * t.zoom;
  const renderedW = imgW * scale;
  const renderedH = imgH * scale;

  // 3. Compute tx, ty so (cx, cy) lands at the frame center.
  let tx = frameW / 2 - t.cx * renderedW;
  let ty = frameH / 2 - t.cy * renderedH;

  // 4. Clamp: the image must never leave a blank edge.
  const minTx = frameW - renderedW;  // most negative allowed
  const minTy = frameH - renderedH;
  if (tx > 0) tx = 0;
  if (ty > 0) ty = 0;
  if (tx < minTx) tx = minTx;
  if (ty < minTy) ty = minTy;

  return { scale, renderedW, renderedH, tx, ty };
}

Worked example with real numbers

Image 2400×1600, preset 1080×1920 (vertical 9:16), zoom 1, cx 0.5, cy 0.5:

  1. base = max(1080/2400, 1920/1600) = max(0.45, 1.2) = 1.2: the image needs to grow 20% to cover vertically.
  2. scale = 1.2 * 1 = 1.2
  3. renderedW = 2400 * 1.2 = 2880, renderedH = 1600 * 1.2 = 1920 (just right).
  4. tx = 1080/2 - 0.5 * 2880 = 540 - 1440 = -900 (the image shifts left).
  5. ty = 1920/2 - 0.5 * 1920 = 960 - 960 = 0.
  6. Clamp: minTx = 1080 - 2880 = -1800. -900 sits between -1800 and 0, OK.

Result: the image is drawn at 2880×1920, shifted 900px to the left, showing the central 1080 horizontal pixels and all 1920 verticals. Perfect cover.

The sibling: clampedTransform()

When the user drags or changes zoom, the resulting cx/cy may fall outside the valid range (trying to view past the image edge). Instead of letting it through and clamping only at render time, the cx/cy themselves are adjusted before saving to state. That keeps the values consistent.

06 / Page state

The whole app is a single React component (app/page.tsx) with three pieces of state:

const [loaded, setLoaded] = useState<LoadedImage | null>(null);
const [transforms, setTransforms] = useState<Record<string, Transform>>({});
const [zipping, setZipping] = useState<string | null>(null);
  • loaded: the image already loaded as an HTMLImageElement plus its filename. Null when nothing has been uploaded yet.
  • transforms: an object keyed by preset id. Every preset the user has touched has its own transform; untouched ones fall back to DEFAULT_TRANSFORM ({ zoom: 1, cx: 0.5, cy: 0.5 }).
  • zipping: a string identifying what's being zipped ("all", "instagram", etc.) or null. Used to disable other buttons while it runs.
WHY HTMLImageElement AND NOT A BLOB URL?Because naturalWidth and naturalHeight are needed a thousand times for the math. Having the element already loaded avoids creating and awaiting new images on every render. On top of that, ctx.drawImage() on canvas accepts an HTMLImageElement directly.

07 / Uploader

The simplest component in the app, but with a couple of a11y details worth noting.

Structure

A <div> with role="button" and tabIndex={0} that acts as both the dropzone AND the file picker trigger:

<div
  role="button"
  tabIndex={0}
  onDragOver={onDragOver}
  onDragLeave={onDragLeave}
  onDrop={onDrop}
  onClick={openPicker}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") openPicker();
  }}
>
  <input type="file" ref={inputRef} hidden />
  <h1>DROP. AN. IMAGE.</h1>
  {/* ... */}
</div>
WHY NOT USE <button> DIRECTLY?HTML semantics: a <button> isn't supposed to contain an <h1> or an <input>inside it (the spec forbids interactive descendants). With a div + role="button" + tabIndex + Enter/Space handling the same accessibility is achieved without breaking semantics. Lighthouse accepts it.

Validation

When a file arrives, a minimal check runs:

if (!file.type.startsWith("image/")) {
  setError("THAT'S NOT AN IMAGE.");
  return;
}

There's no max-size validation or anything similar. More validations can always be added later as needed (a file.size check, specific formats, whatever fits).

08 / PresetCard: the juicy component

Almost all the interactive code lives here. It's responsible for showing the preview of a preset and enabling crop + download.

Anatomy

<article>                              <!-- whole card, a11y: aria-labelledby -->
  <header>                             <!-- preset label + target dimensions -->
    <h3>Feed Portrait</h3>
    <span>1080×1350</span>
  </header>
  <div>                                <!-- frame with overflow:hidden + drag handlers -->
    <img />                            <!-- real image, positioned via transform -->
    <div>skeleton shimmer</div>        <!-- initial 400ms -->
    <span>⚠ +28%</span>                <!-- badge if upscale is needed -->
    <span>↔ DRAG · ⇕ ZOOM</span>       <!-- hint on hover -->
  </div>
  <div>                                <!-- controls: zoom slider + reset -->
    <input type="range" />
    <button>RESET</button>
  </div>
  <button>↓ GIMME .WEBP</button>
</article>

The frame shows the preview at a small size (MAX_DISPLAY = 240px on the longer axis), but mathematically it represents the same crop as the export canvas.

09 / Pan: how dragging works

Dragging uses Pointer Events (not mouse events). Pointer events work uniformly for mouse + touch + pen, and they support setPointerCapture(), which is key.

The flow

// onPointerDown
(e) => {
  e.target.setPointerCapture(e.pointerId);  // target retains the events
  dragRef.current = {
    startX: e.clientX, startY: e.clientY,
    startCx: transform.cx, startCy: transform.cy,
    scale: math.scale,  // current effective scale
  };
};

// onPointerMove
(e) => {
  const d = dragRef.current;
  if (!d) return;
  const dx = e.clientX - d.startX;
  const dy = e.clientY - d.startY;
  // Convert pixel delta to delta in normalized image coords.
  const cx = d.startCx - dx / (imgW * d.scale);
  const cy = d.startCy - dy / (imgH * d.scale);
  onTransformChange(clampedTransform(/* ... */, { zoom, cx, cy }));
};

// onPointerUp
() => { dragRef.current = null; };
WHY useRef FOR DRAG STATE?Because there's no need to re-render when a drag starts. The only requirement is storing the starting positions and reading them in onPointerMove. If it were useState, every onPointerDown would trigger a pointless re-render. useRefis the idiomatic way to store "instance variables" in React.
THE setPointerCapture TRICKWithout pointer capture, if the user drags fast and the cursor leaves the frame, onPointerMove events stop arriving and the drag gets stuck. setPointerCapture makes the original target keep receiving all events until pointerup, even outside the element. Smooth dragging guaranteed.

10 / Zoom: wheel + slider

Two ways to zoom inside each frame.

HTML slider

An <input type="range" min={1} max={4} step={0.01}>. Trivial. It has a visually-hidden <label htmlFor> associated for screen readers.

Native wheel listener (not React)

There's a trick here. React, by default, registers onWheel listeners as passive, which means e.preventDefault() doesn't work and the page keeps scrolling. To prevent that, the listener is registered via useEffect with { passive: false } directly on the DOM:

useEffect(() => {
  const el = frameRef.current;
  if (!el) return;
  const handler = (e) => {
    e.preventDefault();   // works thanks to passive: false
    const delta = -e.deltaY * 0.002;
    const zoom = transform.zoom * (1 + delta);
    onTransformChange(clampedTransform(/* ... */, { zoom, cx, cy }));
  };
  el.addEventListener("wheel", handler, { passive: false });
  return () => el.removeEventListener("wheel", handler);
}, [/* deps */]);
BONUS: TRACKPAD PINCHOn Mac, trackpad pinch generates wheel events with ctrlKey. The handler doesn't check ctrlKey and treats any deltaY as zoom, so trackpad pinch works for free. Multi-touch pinch on mobile would require additional logic with two simultaneous pointers.

11 / Skeleton + fade: deliberate UX

When a new image is loaded, each PresetCard shows 400ms of skeleton (animated diagonal stripes) and then the image appears with a 320ms fade.

const [skeleton, setSkeleton] = useState(true);

useEffect(() => {
  setSkeleton(true);                                // reset on every new image load
  const id = setTimeout(() => setSkeleton(false), 400);
  return () => clearTimeout(id);
}, [image.src]);
WHY FORCE AN ARTIFICIAL DELAY?The processing is so fast that without the skeleton it looks like nothing happened. Perceived performance matters: 400ms of skeleton + fade generates the feeling of "it's processing", which validates the work in the user's eyes. It's intentional UX, not a bug.

The fade uses a CSS transition on opacity. The skeleton is a background: repeating-linear-gradient with a @keyframes shimmer animation that moves the pattern at 0.8s/loop.

12 / The upscale check (warning)

When an image is smaller than a preset, it isn't blocked: it's left downloadable but with a visual warning.

const coverScale = getCoverScale(imgW, imgH, preset.width, preset.height);
const upscalePct = Math.round((coverScale - 1) * 100);
const fits = coverScale <= 1;

If coverScale > 1, the image needs to be enlarged to cover the preset. It will look pixelated. The UI shows:

  • A magenta badge in the frame corner: ⚠ +28%.
  • The download button switches to magenta and reads ↓ GIMME (UPSCALED +28%).
  • The download is still possible. The user decides.

13 / WebP export

The central function that converts a transform into a WebP Blob:

export async function renderPresetToBlob(image, preset, transform) {
  const canvas = document.createElement("canvas");
  canvas.width = preset.width;        // canvas at exact preset size
  canvas.height = preset.height;
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high"; // best available resampling

  const m = computeFrame(
    image.naturalWidth, image.naturalHeight,
    preset.width, preset.height,
    transform,
  );
  ctx.drawImage(image, m.tx, m.ty, m.renderedW, m.renderedH);

  return new Promise((resolve) =>
    canvas.toBlob((b) => resolve(b), "image/webp", 0.92),
  );
}

The key: computeFrame() is called with the canvas dimensions, not the preview's. The same function produces correct values for any scale, which is why what's shown small on screen is exactly what ends up in the WebP.

The download

export function triggerDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);   // free memory
}
WHY Q=0.92?It's the WebP sweet spot: 92% keeps the image visually indistinguishable from the original while saving ~30% size versus q=100. Below 80, subtle artifacts start appearing on fine edges in photographs.

14 / ZIPs in the browser

JSZip is used (the only dependency that doesn't come with Next.js). It generates ZIPs in memory without touching the disk.

export async function buildZip(image, imageName, presets, transforms) {
  const zip = new JSZip();
  for (const preset of presets) {
    const blob = await renderPresetToBlob(
      image, preset,
      transforms[preset.id] ?? DEFAULT_TRANSFORM,
    );
    if (blob) zip.file(fileNameFor(imageName, preset), blob);
  }
  return zip.generateAsync({ type: "blob" });
}
WHY SEQUENTIAL AND NOT Promise.all?Each toBlob()on a large canvas (2560×1440 for example) eats RAM. Doing them in parallel on mobile crashes the page. Sequential is ~1s for all 23 presets on a medium image, acceptable. The UI shows "ZIPPING…" so it's clear there's work in progress.

There are three entry points to buildZip:

  • ZIP ALL (23): cyan button next to SWAP, includes every preset.
  • DOWNLOAD SET: one button per platform on its band, includes only that platform's presets.
  • Single .webp: the ↓ GIMME button on each card, doesn't use ZIP, it's a direct download.

15 / Brutalism, applied

The entire aesthetic follows 7 rules, also listed at /ui-kit:

  1. Sharp corners only. No border-radius.
  2. 3px black borders on anything with a boundary.
  3. Hover states thunk: translate(-2px, -2px) + box-shadow: 4px 4px 0 color. No blur.
  4. Copy is uppercase and confrontational. Period as punctuation, not whisper.
  5. Saturated palette (yellow, magenta, cyan, red). Never pastel.
  6. Official brand colors (with minimal darkening on YT and FB for WCAG).
  7. Warnings are not blockers. Show the cost, let the user decide.

Tokens

Defined in globals.css via CSS custom properties:

:root {
  --paper: #ffffff;
  --ink: #000000;
  --yellow: #fff200;     /* primary action */
  --magenta: #ff0066;    /* warnings, upscale */
  --cyan: #00e0ff;       /* secondary action (ZIP ALL) */
  --red: #ff2200;
  --lime: #c4ff00;       /* reserve (unused) */

  --line-w: 3px;

  --font-display: "Space Grotesk", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}
WHY CSS MODULES AND NOT TAILWIND?Brutalism doesn't need a large design system: tokens + Space Grotesk + 4 colors + 3 rules cover it. CSS Modules give per-file scoping at zero runtime cost. Tailwind would add unnecessary complexity for this scope.

16 / A11y: what was done and why

Lighthouse a11y score: 100/100 on both / and /ui-kit. Coverage:

CategoryWhat was done
Skip link"Skip to content" link at the start of body, visible only when focused. Points to #content which is the <main>.
Heading hierarchyOne single <h1> per page. On home: "DROP. AN. IMAGE." (no-loaded) or the filename (loaded). On /ui-kit: "WEB BRUTALISM." Then h2 for platforms and h3 for presets.
Landmarks<header>, <nav aria-label="Primary">, <main id="content">, <section aria-labelledby>, <footer>.
Dropzone keyboardrole="button" + tabIndex={0} + Enter/Space handler.
Slider labels<label htmlFor> visually hidden (.srOnly) but linked.
ARIA labelsButtons with terse text get descriptive aria-label. Frame image alt is "{name} preview for {label}".
:focus-visible3px cyan outline on every focusable element. :focus without :focus-visible stays outline-less so mouse users aren't annoyed.
Reduced motion@media (prefers-reduced-motion: reduce) kills all animations and transitions.
Color contrastAll text passes WCAG AA (4.5:1 normal, 3:1 large). YT was nudged from #ff0000 to #db0000 and FB from #1877f2 to #166fe5 to clear the threshold.
TRADE-OFFA11y is often postponed in prototypes. Here it was done from the start: it pays off when the app already has a minimal stable structure, and adds much less overhead than retrofitting it later.

17 / Decisions and trade-offs

Client-side only

+ Real privacy (nothing uploads).
+ Zero operating cost.
+ Works offline after first load.
−No ML smart-crop possible (would require a server or a hefty WASM bundle).
−Very large images may saturate mobile RAM.

WebP q=92 fixed

+ Quality/size sweet spot.
−No user control over compression level.

Cover crop only (no contain, no stretch)

+ The output always fills the frame exactly. No awkward padding.
−If the image doesn't match the preset ratio, parts are lost. That's by design.

Per-preset transform (vs. one global)

+ Allows focusing on the face for Instagram and the horizon for YouTube banner.
−More state to manage, but it's confined to the transforms object.

Artificial skeleton + fade

+ Perceived processing.
−Adds 400ms of real waiting. In low-latency contexts a toggle could be exposed.

Sequential ZIP

+ Doesn't blow up mobile memory.
−Slower than parallel. Acceptable for 23 presets.

No backend

+ Deploys to static hosting (Vercel free, Netlify, GitHub Pages).
−No analytics or telemetry without adding something separate.

Brutalism

+ Memorable, anti-template, strong identity.
−Polarizes. Not for every audience, but that was the point.