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:
- The user uploads an image (drag-drop or file picker).
- The browser loads it into an
HTMLImageElementand stores its dimensions. - For each of the 23 platform presets, an interactive preview is rendered.
- The user repositions/zooms each frame with drag + scroll/slider.
- On download, the image is drawn onto a
<canvas>at the exact preset size, converted to WebP viacanvas.toBlob(), and a download is triggered. - ZIPs are built with JSZip in the browser, also network-free.
02 / File map
The app is small, but everything has its place:
Rules enforced in this tree:
- Components in
components/, pure logic inlib/, pages inapp/. No catch-all utils.ts. - Every component has its
.module.cssnext to it. No Tailwind. Every class is scoped to its file. - Nothing in
lib/imports fromcomponents/. 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:
| Platform | Preset | Dim | Ratio |
|---|---|---|---|
| Feed Portrait | 1080 × 1350 | 4:5 | |
| Story / Reel | 1080 × 1920 | 9:16 | |
| TikTok | Video Cover | 1080 × 1920 | 9:16 |
| YouTube | Thumbnail | 1280 × 720 | 16:9 |
| YouTube | Channel Banner | 2560 × 1440 | 16:9 |
| X (Twitter) | Header | 1500 × 500 | 3:1 |
| Personal Banner | 1584 × 396 | 4:1 | |
| Cover Photo | 851 × 315 | 2.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 = 1means "the image exactly covers the frame" (cover-fit).zoom = 2means "twice as big" → less image visible but with more detail.cx = 0.5, cy = 0.5places the image center at the frame center.cx = 0.2means "looking at 20% horizontally into the image", so the focus shifts to the left.
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:
base = max(1080/2400, 1920/1600) = max(0.45, 1.2) = 1.2: the image needs to grow 20% to cover vertically.scale = 1.2 * 1 = 1.2renderedW = 2400 * 1.2 = 2880,renderedH = 1600 * 1.2 = 1920(just right).tx = 1080/2 - 0.5 * 2880 = 540 - 1440 = -900(the image shifts left).ty = 1920/2 - 0.5 * 1920 = 960 - 960 = 0.- Clamp:
minTx = 1080 - 2880 = -1800.-900sits between-1800and0, 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 anHTMLImageElementplus 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 toDEFAULT_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.
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><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; };onPointerMove. If it were useState, every onPointerDown would trigger a pointless re-render. useRefis the idiomatic way to store "instance variables" in React.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 */]);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]);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
}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" });
}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:
- Sharp corners only. No
border-radius. - 3px black borders on anything with a boundary.
- Hover states thunk:
translate(-2px, -2px) + box-shadow: 4px 4px 0 color. No blur. - Copy is uppercase and confrontational. Period as punctuation, not whisper.
- Saturated palette (yellow, magenta, cyan, red). Never pastel.
- Official brand colors (with minimal darkening on YT and FB for WCAG).
- 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;
}16 / A11y: what was done and why
Lighthouse a11y score: 100/100 on both / and /ui-kit. Coverage:
| Category | What 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 hierarchy | One 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 keyboard | role="button" + tabIndex={0} + Enter/Space handler. |
| Slider labels | <label htmlFor> visually hidden (.srOnly) but linked. |
| ARIA labels | Buttons with terse text get descriptive aria-label. Frame image alt is "{name} preview for {label}". |
| :focus-visible | 3px 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 contrast | All 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. |
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.