An interactive WebGL scene: an endless, staggered grid of banknotes lying on a dark table. Drag to pan, hover to shove the paper around, click a note to fly the camera onto it and reveal its headline.
Built with React Three Fiber on an orthographic camera. There are no real lights in the scene — every bit of depth (curl, sheen, shadow, edge blur) is faked in shaders and 2D canvas textures, sharing one geometry and one compiled program across every note.
pnpm install
pnpm dev # vite dev server
pnpm build # tsc --noEmit && vite build
pnpm preview # serve the production buildRequires pnpm (dependencies are pinned exactly via save-exact=true).
React 19 · TypeScript · Vite · Tailwind v4 · three.js · @react-three/fiber · @react-three/drei · @react-three/postprocessing · maath
src/lib/grid.ts doesn't store a grid — it derives one. visibleTiles()
returns the cells covering the current frustum plus a one-cell margin, and each
tile's design, tilt, jitter and z-order come from a hash of its col:row
coordinate. Panning re-windows the visible set; every note that scrolls back
into view is bit-for-bit identical to how it left.
Odd columns are offset half a cell so the notes stagger instead of forming visible rows. Cells are deliberately smaller than a tilted note's bounding box, so notes overlap most of the time.
| Input | Result |
|---|---|
| Drag (one pointer) | Pan with inertia (usePanControls.ts) |
| Pinch (two pointers) | Zoom, clamped to 0.6×–1.8× the responsive base |
| Hover a note | It lifts; neighbours are pushed aside and buckle |
| Click a note | Camera flies onto it, overlay headline appears |
| Esc / backdrop / ✕ | Close the focused note |
Hover and focus share one repel model (src/lib/hover.ts), differing only in
radius and strength. The falloff runs in a cell-aspect-normalized elliptical
metric — the grid is 3.8 × 1.7 per cell, so an isotropic falloff would shove
vertical neighbours roughly 4× harder than horizontal ones.
src/lib/paper.ts holds the deformation model; paperMaterial.ts turns it into
vertex displacement and shading. Every note shares one 32×16 subdivided unit
quad and one compiled shader program — only uniforms differ.
Notes lie flat at rest. Interaction bends them three ways:
- Buckle — a pushed note's cursor-facing edge lifts like a flicked sheet corner; the far edge stays pinned to the table.
- Flex — a velocity-driven bow against the direction of motion.
- Lean — additive in-plane rotation tracking lateral velocity, on a stiff critically-damped spring so it dies with the motion instead of trailing it.
The load-bearing trick is in the vertex shader: pure z-displacement is
invisible under an orthographic camera. So each vertex is also pulled back
along its bend axis by the first-order arc-length deficit (Δ(u) = ⅔c²u³ for a
parabola z = cu²), which shrinks and curves the projected silhouette. Real
paper is inextensible; without that foreshortening term a curled note reads as
a flat rectangle.
Focus flight (Scene.tsx) damps a single progress value: zoom interpolates in
log-space while the camera position is derived so the note's screen-space
offset decays linearly. Damping x/y/zoom independently instead reads as
zoom-then-slide, because zoom covers most of its distance immediately and
magnifies the remaining pan error.
Effects.tsx adds a custom radial edge blur (a postprocessing Effect whose
strength ramps with uv distance from center, so it tracks the vignette ellipse)
plus a vignette. Two DOM layers on top of the canvas apply a navy colour grade
and a corner falloff.
prefers-reduced-motion is respected: the camera snaps instead of flying and
the overlay transition is dropped.
src/
App.tsx canvas + DOM ambience layers + focus state
data/notes.ts note designs and their headlines
lib/
grid.ts hash-derived infinite tiling
hover.ts repel/push model
paper.ts spring integrator + per-note paper params
zoom.ts responsive base zoom for the ortho camera
scene/
Scene.tsx focus camera, responsive zoom, background
BanknoteGrid.tsx windowing + the single useFrame driving every note
Banknote.tsx one note: shadow mesh + paper mesh + animation handle
paperMaterial.ts bent-paper vertex/fragment shaders
usePanControls.ts drag-pan with inertia, pinch zoom
Effects.tsx radial edge blur + vignette
ui/FocusOverlay.tsx headline overlay for the focused note
public/notes/ banknote artwork
One useFrame in BanknoteGrid drives every channel for every note —
position, rotation, scale, shadow, curl, flex, buckle — via a shared handle map
that each Banknote registers into. Animation state lives on those handles, not
in React state, so re-windowing during a pan doesn't reset a note mid-motion.
- Tuning constants are grouped at the top of
lib/hover.tsandlib/paper.ts, each with a comment explaining the value's origin. Several encode a z-displacement budget that keeps bent notes under the hash z-spread (0.5) and the focus z (0.6) — worth reading before raising an amplitude. - Notes render
transparentwithdepthWrite: false, so overlaps resolve by painter order rather than depth. That's intentional and load-bearing. - Designs and layout are derived from a Figma file; comments reference its node
IDs (e.g.
159:472) where a value came straight from the design.