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.

▶ Try it liveOpen ↗

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:

PresetGridTiles
Tiny4x416
Small6x636
Normal12x12144
Large40x401,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:

  1. On every mouse move, find which tile the cursor is over.
  2. Swap positions in an array.
  3. 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.

You're building cooler things
than most people think.

Let's connect
24 6