The hard part of a no-backend collage tool wasn't the collage
I built a small photo combiner that runs entirely in the browser - no upload, no server, no account. You drop in a few photos, pick a grid, drag to reposition, hit export.
The collage logic took an afternoon. Keeping the exported PNG identical to what you saw on screen took the rest of the week. Here's the thing nobody warns you about: a preview and an export are two different rendering engines, and they do not agree with each other by default.
Two engines, one layout
The preview is DOM. I lay the cells out with CSS Grid because the browser already does everything I want - 1fr columns, gap, object-fit: cover, border-radius. A few lines and it just works:
el.style.gridTemplateColumns = `repeat(${cols}, 1fr)`
box.style.gridColumnEnd = 'span ' + (cell.c || 1)
im.style.objectPosition = `${item.panX}% ${item.panY}%`
The export is Canvas. And Canvas gives you none of that. There is no object-fit, no gap, no 1fr. You get drawImage and a blank rectangle. Every bit of layout the browser did for free in the preview, you now recompute by hand so the pixels land in the same place.
So the real work is writing the Canvas version of CSS.
object-fit: cover, by hand
In CSS, object-fit: cover crops an image to fill a box without distortion. In Canvas you do it yourself by picking the right source rectangle out of the original image:
function coverRect(img, dw, dh, panX = 50, panY = 50) {
const ir = img.naturalWidth / img.naturalHeight
const dr = dw / dh
let sw, sh
if (ir > dr) {
// source is wider than the box: full height, crop sides
sh = img.naturalHeight
sw = sh * dr
} else {
// full width, crop top/bottom
sw = img.naturalWidth
sh = sw / dr
}
const sx = (img.naturalWidth - sw) * (panX / 100)
const sy = (img.naturalHeight - sh) * (panY / 100)
return { sx, sy, sw, sh }
}
That panX / panY is the other half of it. In the preview, dragging an image inside its cell just changes object-position. In the export, the same percentage becomes an offset into the source rectangle. Same number, two completely different code paths - and if they drift, the exported photo is cropped differently from what the user framed. That bug is invisible until someone complains their cat's head got cut off.
The number that stops phones from crashing
Early on, exporting on a phone would sometimes produce a blank image or just kill the tab. Canvas has a maximum area - not width or height, total pixels - and mobile Safari's limit is lower than you'd guess. A 4x-scaled collage of big photos sails right past it and toBlob quietly hands you null.
So the export scale is capped against a pixel budget, not a dimension:
const MAX_AREA = 5e7 // 50 megapixels
function computeScale(w, h, format) {
const dpr = window.devicePixelRatio || 1
if (format === 'png') {
let r = dpr
if (w * h * dpr * dpr > MAX_AREA) r = Math.sqrt(MAX_AREA / (w * h))
return Math.min(r, 4)
}
return Math.min(dpr * 1.5, 3)
}
5e7 is a conservative ceiling that held up across the phones I tested. Render at device pixel ratio for sharpness, but the moment the canvas would exceed the budget, back off to whatever scale keeps it under. Better a slightly softer image than a blank one.
One listener set, not one per render
A smaller thing that bit me: dragging to reposition a photo needs mousemove / touchmove on window, not the element (your finger leaves the box). My first version attached those listeners inside the render function. Every re-render stacked another set. After a dozen edits the page had dozens of live listeners all firing.
The fix is boring and correct: register the move/up listeners once at module load, and keep a single active object describing whatever is currently being dragged. Render swaps the data, not the listeners.
Why no backend at all
I could have thrown this on a server and done the compositing with an image library. I didn't, and not only to save money. Photos are personal. "Combine your pictures" and "upload your pictures to my server" are very different promises, and the second one is a liability I don't want.
Everything here - decode, draw, toBlob, download - happens in the tab. Close it and nothing of yours was ever anywhere else. URL.createObjectURL on the blob, click a hidden <a download>, revoke the URL a second later. No request leaves the machine.
It also turns out to be the simpler architecture once you accept the constraint. No uploads to rate-limit, no temp files to garbage-collect, no abuse surface. The whole thing is static files on a CDN.
If you want to see the end result it's at Photo Combiner - grid, long-image and side-by-side layouts, all client-side. Happy to answer anything about the Canvas side in the comments.
Top comments (1)
I went backend-free here mostly because I didn't want people's photos touching a server just to stack them side by side. The surprise was how much of CSS you end up reimplementing by hand once you move layout into Canvas - object-fit, gap, all of it. Curious how others deal with the DOM-vs-Canvas gap. And has anyone found a reliable way to detect mobile Safari's canvas area limit before toBlob silently returns null, instead of just capping conservatively like I did?
Comments
No comments yet. Start the discussion.