ON project
Building Reshape: Turning Photos Into Art With Canvas, React, and a Shuffle Algorithm
How I built Reshape — a tool that slices photos into tiles, shuffles them with controlled chaos, and lets you drag them into art. Canvas performance tricks, SVG wobble filters, and a handmade aesthetic.
The Inspiration
It started with a painting. Johannes Wiener’s Reconstructed Landscape Painting, N. 2 takes a landscape photograph, slices it into a grid of squares, and swaps them around. From across the room, it still reads as a landscape. Up close, it falls apart into abstraction. I wanted to make something you could touch that does the same thing.
Reshape lets you upload any image, split it into a grid, shuffle the tiles, drag them around, and download the result. Simple concept, but the details turned out to be the fun part.
The Shuffle Algorithm
The core of Reshape isn’t random. Or rather, it’s carefully random.
The shuffle is controlled by a single “Chaos Level” slider (0-100%). Under the hood, this maps to two parameters:
- Move probability:
0.15 + intensity * 0.8. At zero chaos, only 15% of tiles move. At max, 95% do. - Distance bias: equals the intensity directly. This controls whether a tile swaps with a neighbor (1 cell away) or a distant neighbor (2 cells away, including diagonals).
The algorithm walks through every tile. For each one, it rolls the dice: should this tile move? If yes, should it swap nearby or far? Then it picks a random valid candidate from the appropriate offset list.
This creates something more interesting than pure randomization. At low chaos, you get gentle drifts — the image is recognizable but slightly off. At high chaos, clusters form and dissolve. The image becomes abstract but retains echoes of its structure. It mirrors what Wiener does by hand.
The Grid System
Four presets control how fine the slicing gets:
| Preset | Grid | Tiles |
|---|---|---|
| Tiny | 4x4 | 16 |
| Small | 6x6 | 36 |
| Normal | 12x12 | 144 |
| Large | 40x40 | 1,600 |
Tiny gives you big, bold blocks — almost like a Mondrian. Large turns a photograph into a texture. Normal is the sweet spot for most images: enough detail to recognize the source, enough disorder to make it strange.
Changing the grid size or chaos level triggers a fresh reset + shuffle, so you can rapidly explore different combinations.
Drag-and-Drop on Canvas: Making 1,600 Tiles Feel Smooth
The naive approach to tile dragging is:
- On every mouse move, find which tile the cursor is over.
- Swap positions in an array.
- Re-render the entire canvas.
This works fine for 16 tiles. For 1,600 tiles (the “Large” preset), it’s a disaster. Here’s what made it fast:
O(1) Tile Lookup With Int32Array
Instead of searching the entire positions array on every pointer event (findIndex over 1,600 elements), Reshape maintains an Int32Array that maps grid coordinates directly to tile indices:
const idx = new Int32Array(n * n)
for (let i = 0; i < positions.length; i++) {
const p = positions[i]
idx[p.y * n + p.x] = i
}Finding which tile is under the cursor becomes a single array access: idx[y * n + x]. On a swap, two entries update. No searching.
Dirty-Region Canvas Rendering
During a drag, only two tiles change position — the one being dragged and the one it swaps with. Instead of clearing and redrawing all 1,600 tiles, Reshape redraws just the two affected tiles imperatively:
function drawTileImperative(i: number) {
const pos = positionsRef.current[i]
const sx = (i % n) * tileW
const sy = Math.floor(i / n) * tileH
ctx.drawImage(image, sx, sy, tileW, tileH, pos.x * tileW, pos.y * tileH, tileW, tileH)
}Two drawImage calls instead of 1,600. The canvas looks identical.
Bypassing React During Drag
React’s state-driven rendering model is great for UI, but re-rendering the component tree on every pointer event during a drag creates unnecessary overhead. During a drag session, Reshape:
- Clones the positions array into a mutable ref (
positionsRef) - Mutates the ref and the Int32Array directly on each swap
- Draws imperatively to the canvas (no
setState, no re-render) - Commits the final state back to React only on pointer up
The React draw effect has a guard: if (draggingRef.current != null) return. This prevents stale state from overwriting the imperative draws mid-drag.
requestAnimationFrame Coalescing
High-refresh displays and trackpads can fire pointer events at 1000+ Hz. Even with O(1) lookups and 2-tile redraws, that’s wasteful. Reshape stores the latest pointer position in a ref and processes it once per animation frame:
function schedulePointer(clientX: number, clientY: number) {
pendingMoveRef.current = { clientX, clientY }
if (rafRef.current != null) return
rafRef.current = requestAnimationFrame(processPointer)
}One swap + two redraws per frame, regardless of input rate.
The Handmade Aesthetic
Reshape doesn’t look like a typical web app, and that’s intentional. The design borrows from zines, sketchbooks, and hand-drawn illustration.
SVG Wobble Filters
Every piece of text and every card passes through an SVG displacement map filter:
<filter id="wobble">
<feTurbulence type="fractalNoise" baseFrequency="0.018" numOctaves="2" seed="4" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="1.8" />
</filter>This takes crisp digital type and makes it look slightly unsteady — like it was printed on a letterpress with too much ink. Three filter variants (wobble, wobble-sm, wobble-card) control the intensity for different elements.
Paper Grain Texture
A full-viewport pseudo-element applies fractal noise as a texture overlay:
body::before {
background-image: url("data:image/svg+xml;utf8,<svg>
<filter id='n'>
<feTurbulence baseFrequency='0.9' numOctaves='3'/>
</filter>
<rect filter='url(%23n)'/>
</svg>");
mix-blend-mode: multiply;
opacity: 0.22;
}This gives the entire page a tactile, chalky quality — like drawing on textured paper.
Typography
Seven fonts, each with a role:
- Luckiest Guy for the title — loud, playful, unapologetic
- Bangers for buttons — comic-book energy
- Caveat for annotations and hints — handwritten cursive
- Kalam for friendly body copy
- Archivo / Inter for technical labels and info
The mix of hand-drawn and clean sans-serif creates tension between “art project” and “functional tool.”
Organic Decorations
SVG blobs in the corners use radial gradients and turbulence filters to look like watercolor marks:
<radialGradient id="teal-blob" cx="32%" cy="30%">
<stop offset="0%" stopColor="#6ee7b7" />
<stop offset="55%" stopColor="#4ed9a7" />
<stop offset="100%" stopColor="#34d399" />
</radialGradient>The react-rough-notation library adds a hand-drawn yellow highlight behind the tagline “Rearrange. Reimagine.” — animated on load with intentional wobble.
The Color Palette
Warm and approachable:
- Background:
#fdf7ec— cream, like aged paper - Ink:
#2d2a3f— deep purple-black - Primary:
#7c5ce8— vibrant purple for interactive elements - Teal:
#6fcfc3— soft accent - Coral:
#f89181— warm highlight - Yellow:
#f5c13a— golden annotations
Interaction Details
Small things that make it feel right:
- Hold spacebar to reveal the original image. The canvas crossfades instantly. Let go, and you’re back to the shuffled version. It’s the fastest way to compare.
- Hover highlight: a white border with soft shadow appears around the tile under your cursor. The line width scales with tile size (
Math.max(4, Math.floor(tileW * 0.025))) so it’s visible on tiny tiles and proportional on large ones. - Polaroid preview: the original image sits in the sidebar in a small “polaroid” card, tilted slightly. Visual anchor.
- Image info card: shows dimensions, grid size, and tile pixel size. Useful when you’re deciding on a preset.
Deployment
The stack is minimal:
- Vite for dev server and bundling
- TypeScript in strict mode
- Firebase Hosting for deployment (SPA rewrite, builds from
dist/) - PostHog for analytics (uploads, shuffles, downloads, drag sessions)
The entire app is a single component file (App.tsx), a stylesheet (App.css), and an 83-line shuffle algorithm (shuffle.ts). No routing, no state management library, no build complexity.
What I Learned
Canvas and React are oil and water during animations. React’s declarative model works beautifully for UI state, but the moment you need 60fps imperative updates (like dragging tiles), you have to step outside it. The pattern of “mutable refs during interaction, commit to state on completion” is awkward but effective.
SVG filters are underrated. The wobble effect is three lines of SVG and it transforms the entire feel of the app. No images, no external dependencies, and it runs on the GPU.
Constraints breed creativity. The entire app exists because Wiener constrained himself to a grid and a set of swaps. The chaos slider is the most interesting control precisely because it limits randomness rather than maximizing it. At 15% chaos, the results are often more compelling than at 95%.
Reshape is open source. Try it, break an image, and see what happens when order and chaos negotiate.
Final thoughts
This was a fun little side project — if you're building cooler things than most people, I'd love to hear about it.