{
  "version": 1,
  "origin": "https://archive.yorudesigns.ca",
  "routes": {
    "home": "/",
    "projects": "/projects/",
    "category": "/{id}/",
    "project": "/projects/{slug}/"
  },
  "studyCount": 69,
  "categories": [
    {
      "id": "creative",
      "titleEn": "Creative",
      "titleZh": "创造",
      "descriptionEn": "Free-flowing ideas with strong visual impact."
    },
    {
      "id": "exploration",
      "titleEn": "Exploration",
      "titleZh": "探索",
      "descriptionEn": "Experiments in code and research."
    },
    {
      "id": "growth",
      "titleEn": "Growth",
      "titleZh": "成长",
      "descriptionEn": "The sharpening of craft."
    },
    {
      "id": "expression",
      "titleEn": "Expression",
      "titleZh": "表现",
      "descriptionEn": "Self-expression and artistic intent."
    },
    {
      "id": "playfulness",
      "titleEn": "Playfulness",
      "titleZh": "玩心",
      "descriptionEn": "Humour, wit, and room to play."
    }
  ],
  "studies": [
    {
      "slug": "shard-trails",
      "title": "Shard Trails",
      "summary": "Ten thousand glowing filaments streak through a black void, braided into two hundred ropes that orbit and swirl past the camera. They read white at the edges and cyan through the core. Black fog dissolves everything just past the centre, so the swarm floats in nothing.",
      "stack": [
        "three.js",
        "InstancedMesh",
        "MeshPhongMaterial + THREE.Fog",
        "ACESFilmic tone mapping",
        "typed-array ring-buffer trails",
        "GlScene / GlFrame"
      ]
    },
    {
      "slug": "flow-field",
      "title": "Flow Field",
      "summary": "Ten thousand white-lilac specks drift through a churning flow field on near-black. The whole cloud tumbles slowly on all three axes as it goes.",
      "stack": [
        "three.js",
        "GLSL",
        "THREE.Points",
        "custom ShaderMaterial",
        "CPU flow field",
        "per-vertex colour and size attributes"
      ]
    },
    {
      "slug": "stable-fluids",
      "title": "Stable Fluids",
      "summary": "Moving the pointer pushes a rope of saturated red ink into a fluid on a black stage. The current curls it into vortices, shears it apart and wraps it around the screen edges before it fades. STABLE FLUIDS sits in the same red at dead centre, so the type reads through its own ink.",
      "stack": [
        "three.js",
        "GLSL",
        "ping-pong WebGLRenderTargets (7 × 512²)",
        "GlScene / GlFrame",
        "CSS Modules + container queries"
      ]
    },
    {
      "slug": "fixed-background-cards",
      "title": "Fixed Background Cards",
      "summary": "Nine tall photo cards zigzag down a long page. One darkened photograph sits behind them and drifts against the run as you scroll, and each card's image slides inside its own frame.",
      "stack": [
        "GSAP",
        "ScrollTrigger"
      ]
    },
    {
      "slug": "clipped-opening",
      "title": "Clipped Opening",
      "summary": "Six image panels wipe open from nothing while stacked at screen centre, then fly apart into a full-width filmstrip along the bottom edge.",
      "stack": [
        "GSAP"
      ]
    },
    {
      "slug": "dynamic-lines",
      "title": "Dynamic Lines",
      "summary": "Ten pale pink sine waves lie diagonally across a near-black frame. Each ripples faster than the one behind it while a soft edge of light sweeps along it and wipes it away.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP timelines",
        "ShaderMaterial"
      ]
    },
    {
      "slug": "crossed-curl",
      "title": "Crossed Curl",
      "summary": "Two point clouds shaped like a sword scissor across each other, breathing in and out of a curl-field storm. They glow teal on near-black, dissolving to a cloud and re-forming about every twenty seconds.",
      "stack": [
        "three.js",
        "GLSL"
      ]
    },
    {
      "slug": "raycast-noise",
      "title": "Raycast Noise",
      "summary": "A full-screen photograph inverts to its colour negative inside a soft blob that trails the cursor. Drifting noise eats at the blob's edge, so the boundary stays fibrous and never settles.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "ScrollTrigger",
        "CSS transforms"
      ]
    },
    {
      "slug": "falling-leaves",
      "title": "Falling Leaves",
      "summary": "Dark red type sits over a three by three outlined grid on black. A cloud of leaves tumbles slowly down through the frame in front of it, some solid, some drawn as wireframe.",
      "stack": [
        "three.js",
        "GSAP",
        "post-processing",
        "DOM"
      ]
    },
    {
      "slug": "hard-switch",
      "title": "Hard Switch",
      "summary": "A square panel dissolves back and forth between two black and red graphic marks on cream, floating on a near-black ground. A noise map chews up the diagonal edge of the wipe, so the boundary comes apart as it crosses.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "ScrollTrigger"
      ]
    },
    {
      "slug": "swelling-panel",
      "title": "Swelling Panel",
      "summary": "A 13 by 12 grid of small dark tiles stands in a large dark room under one hard overhead spot. Each tile pops open and pushes toward the viewer on a diagonal delay, so the panel reads as one slow wave.",
      "stack": [
        "three.js",
        "GSAP timelines",
        "spotlight shadow mapping",
        "MeshLambertMaterial"
      ]
    },
    {
      "slug": "scroll-distortion",
      "title": "Scroll Distortion",
      "summary": "Three full-height serif headings scroll past a background of one photograph melting into the next. Every pixel is dragged downward as it goes, so the outgoing picture drips off the bottom of the frame.",
      "stack": [
        "three.js",
        "GLSL",
        "lenis",
        "CSS container queries"
      ]
    },
    {
      "slug": "metallic-type",
      "title": "Metallic Type",
      "summary": "A warm grey sheet lies rotated and clipped shut on a black stage until ENTER unrolls it into a page. A narrow off-centre column carries a looping render of metallic type and three stills that wipe open as they scroll in, all under a moving grey video.",
      "stack": [
        "GSAP",
        "ScrollTrigger",
        "Lenis",
        "CSS clip-path",
        "CSS custom property tweening",
        "HTML video",
        "HTMLAudioElement"
      ]
    },
    {
      "slug": "clashing-pixels",
      "title": "Clashing Pixels",
      "summary": "A square photograph is cut into 14,400 tiles that flee the cursor like fish and spring back into place. A line of display serif is hidden over the picture and shows only when you drag across it.",
      "stack": [
        "three.js",
        "GPU instancing",
        "GLSL",
        "fixed-step spring integrator"
      ]
    },
    {
      "slug": "simplex-dust",
      "title": "Simplex Dust",
      "summary": "One hundred thousand white pinpoints hang on pure black, sampled off the surface of a cube and thrown apart by an amplified simplex noise. What survives on screen is a set of thin curved filaments, brightest where specks stack, turning over across minutes.",
      "stack": [
        "three.js",
        "GLSL"
      ]
    },
    {
      "slug": "curl-field",
      "title": "Curl Field",
      "summary": "400,000 dark teal points fold under curl noise into a veined, smoke-like knot on black. It tumbles on two axes while the field reshapes beneath it. A small panel sets the ink colour.",
      "stack": [
        "three.js",
        "GLSL",
        "Ashima 3D simplex noise",
        "curl noise",
        "THREE.Points",
        "additive blending",
        "OrbitControls"
      ]
    },
    {
      "slug": "baked-rock",
      "title": "Baked Rock",
      "summary": "A photogrammetry rock hangs in a black void, lit from the upper right. Nothing in the scene moves; the camera is yours to orbit, dolly and pan.",
      "stack": [
        "three.js",
        "GLTFLoader",
        "MeshStandardMaterial",
        "OrbitControls",
        "ACES filmic tone mapping",
        "anisotropic filtering"
      ]
    },
    {
      "slug": "card-carousel",
      "title": "Card Carousel",
      "summary": "An endless row of pale grey cards runs through a window cut into a near-black page, each card stamped with a concave eight-point star and a serial number. Drag or flick the row and it glides, then settles into a slot. Whichever card lands in the centre rises out of the line.",
      "stack": [
        "React client component (Next.js)",
        "CSS Modules",
        "GSAP (the 0.4s card lift only)",
        "requestAnimationFrame with clamped delta",
        "IntersectionObserver (50px rootMargin)",
        "ResizeObserver (10ms debounce)"
      ]
    },
    {
      "slug": "overlapping-images",
      "title": "Overlapping Images",
      "summary": "An outlined frame stands at the centre of a pale grey page. Scrolling wipes the next photograph into it from nothing while the picture grows from a quarter size, so each image swells out of the one before. A counter tracks which of the five is in front.",
      "stack": [
        "GSAP",
        "ScrollTrigger",
        "CSS transforms"
      ]
    },
    {
      "slug": "mirror-floor",
      "title": "Mirror Floor",
      "summary": "A small object stands on polished dark concrete in an empty grey room. The floor mirrors it back, blurred and broken up by its own roughness, and the reflection fills half the frame.",
      "stack": [
        "three.js",
        "GSAP",
        "ScrollTrigger"
      ]
    },
    {
      "slug": "instanced-mesh",
      "title": "Instanced Mesh",
      "summary": "White cubes stand in a 10x10x10 lattice against near-black. The whole frame pumps through a lens that pinches hard at the centre and flattens again on a seven-second swing. Nothing in the scene itself moves.",
      "stack": [
        "three.js",
        "GLSL",
        "InstancedMesh",
        "render-target post pass",
        "GSAP"
      ]
    },
    {
      "slug": "simplex-field",
      "title": "Simplex Field",
      "summary": "A single dark disc floats on a near-black page. Its face is packed with tight teal contour bands that ripple and swim as simplex noise bends them.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "post-processing"
      ]
    },
    {
      "slug": "video-frame",
      "title": "Video Frame",
      "summary": "A black scroll page whose full-bleed hero is a 150-frame still sequence rather than a video. Scroll position is the transport: down runs the footage forward, back runs it in reverse, exactly as far as the page moved. White editorial copy slides over the frames. A still sequence cannot stall on a keyframe the way a seek can.",
      "stack": [
        "React 19 client component",
        "Canvas 2D via the house useCanvasScene hook",
        "Lenis smooth scroll (wrapper/content, autoRaf)",
        "GSAP for the loading reveal",
        "CSS modules",
        "Self-hosted WebP still sequence"
      ]
    },
    {
      "slug": "sentence-split",
      "title": "Sentence Split",
      "summary": "A single line of white text on a dark stage breaks into nine words that fly in from above and below. They fold into a flush three-line block and slide to the lower left while a photograph is uncovered behind them by a shutter opening from the centre.",
      "stack": [
        "Next.js client component",
        "GSAP timeline",
        "GSAP SplitText (chars,words,lines)",
        "CSS Modules",
        "CSS container queries",
        "self-hosted <picture> art direction"
      ]
    },
    {
      "slug": "bottle-study",
      "title": "Bottle Study",
      "summary": "A bottle floats over a white product card, the shot leaning off the card's axis. Four colour swatches sit below it and a column of colour notes runs down the left. Picking a swatch turns the bottle a full circle as it takes the colour.",
      "stack": [
        "three.js",
        "GSAP",
        "self-hosted GLB (surfaceBody / surfaceCap)",
        "GlScene + loadModel",
        "CSS Modules",
        "CSS container queries"
      ]
    },
    {
      "slug": "glass-sphere",
      "title": "Glass Sphere",
      "summary": "A small glass bead tumbles at the centre of a near-black page, dragging a photograph through itself. Thirty stacked refraction taps smear red, green and blue apart into a heavy fringe.",
      "stack": [
        "three.js",
        "GLSL",
        "CSS transforms"
      ]
    },
    {
      "slug": "light-shafts",
      "title": "Light Shafts",
      "summary": "A dragon and a small figure stand in a black room, backlit through a narrow slot in a wall. The beam itself is visible as a white wedge fanning toward the camera. Nothing in the scene moves.",
      "stack": [
        "three.js",
        "GLSL",
        "post-processing"
      ]
    },
    {
      "slug": "tree-sway",
      "title": "Tree Sway",
      "summary": "A tree plays a baked wind clip at a sixth speed, seen from inside and below its canopy on flat light grey. It reads as a dark silhouette with a thin lit rim along the upward faces.",
      "stack": [
        "three.js",
        "GLSL",
        "post-processing"
      ]
    },
    {
      "slug": "particle-morph",
      "title": "Particle Morph",
      "summary": "Ten thousand black points travel back and forth between a sphere and a torus knot on a light grey ground. Every crossing passes through a taffy-like intermediate while the whole cloud slowly tumbles.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP"
      ]
    },
    {
      "slug": "liquid-video",
      "title": "Liquid Video",
      "summary": "A looping film fills a letterboxed band of a black page. The pointer smears a fluid through it, dragging the footage and fringing it red and green wherever the wake passes.",
      "stack": [
        "three.js",
        "GLSL",
        "stable fluids over seven render targets",
        "gsap"
      ]
    },
    {
      "slug": "colour-drain",
      "title": "Colour Drain",
      "summary": "A colour photograph of a street scene opens grey and blooms into colour. The pointer smears a fluid trail across it, draining the colour again wherever it passes and healing as the ink decays.",
      "stack": [
        "three.js",
        "GLSL",
        "stable-fluids solver",
        "half-float render targets"
      ]
    },
    {
      "slug": "dot-heart",
      "title": "Dot Heart",
      "summary": "A pink heart drawn as a grid of circular dots beats on a near-black stage, with a half-opacity ghost copy trailing a beat behind it. Three swatches recolour both layers.",
      "stack": [
        "GSAP",
        "ScrollTrigger",
        "CSS transforms",
        "DOM"
      ]
    },
    {
      "slug": "deformed-cube",
      "title": "Deformed Cube",
      "summary": "A cube with a sphere bitten out of every face tumbles in front of a backdrop plane. One shader paints both: gold and rose bands split by hard black seams, curled by Perlin noise. Through the holes you see the cavity inside.",
      "stack": [
        "three.js",
        "ShaderMaterial + GLSL",
        "Gustavson classic Perlin cnoise",
        "Analytically built pierced-box geometry",
        "GlScene / GlFrame",
        "CSS Modules"
      ]
    },
    {
      "slug": "block-offset",
      "title": "Block Offset",
      "summary": "A wide photograph floats at the centre of a pale grey field, with the title set huge in cyan across the lower left. Moving the pointer drags a soft circular pocket across the picture, shoving it opposite to the direction of travel.",
      "stack": [
        "three.js",
        "GLSL",
        "WebGLRenderTarget",
        "React",
        "CSS Modules"
      ]
    },
    {
      "slug": "queen-reflection",
      "title": "Queen Reflection",
      "summary": "A clear glass chess piece stands on a near-black stage. Three ribbons of the repeated word Queen crawl behind it, arriving inverted through the glass. A liquid ripple follows the pointer across the frame.",
      "stack": [
        "three.js",
        "GLSL",
        "GLTFLoader",
        "MeshPhysicalMaterial transmission",
        "render-to-target post pass",
        "2D simplex noise",
        "CanvasTexture"
      ]
    },
    {
      "slug": "glass-atrium",
      "title": "Glass Atrium",
      "summary": "A glass sphere hangs in front of a photograph of an atrium and bends it into a circular lens. Orbiting the camera swings the inverted picture across its face.",
      "stack": [
        "three.js",
        "GLSL",
        "post-processing"
      ]
    },
    {
      "slug": "noise-melt",
      "title": "Noise Melt",
      "summary": "A tall panel on black melts back and forth between two paintings. A noise map drags the pixels vertically, so both pictures show as stretched smears before one resolves.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP"
      ]
    },
    {
      "slug": "fire-fox",
      "title": "Fire Fox",
      "summary": "Twenty thousand burnt-orange sprites trace the surface of an origami form on black. A noise field grows and pulls them off the surface, then eases back and the shape reassembles.",
      "stack": [
        "three.js",
        "GLSL",
        "RawShaderMaterial",
        "THREE.Points",
        "classic Perlin 3D noise",
        "additive blending",
        "precomputed surface sampling"
      ]
    },
    {
      "slug": "pointer-gallery",
      "title": "Pointer Gallery",
      "summary": "A wall of seventeen monochrome photographs slides under the cursor like a map. Each tile drifts a little further than the one before it, so the grid shears as it travels. Only the centre is lit.",
      "stack": [
        "CSS transforms",
        "DOM"
      ]
    },
    {
      "slug": "tilted-slider",
      "title": "Tilted Slider",
      "summary": "Six steeply tilted photo slats lean across a pale grey ground. Scrolling glides the ribbon sideways while each photograph counter-drifts inside its own frame.",
      "stack": [
        "CSS transforms"
      ]
    },
    {
      "slug": "box-lattice",
      "title": "Box Lattice",
      "summary": "A lattice of boxes stands on a dark disc: most short, a handful tall, a scatter drawn as wireframe, one accent colour among the greys. The field turns on its own and leans toward the pointer.",
      "stack": [
        "three.js",
        "GLSL",
        "CSS transforms"
      ]
    },
    {
      "slug": "box-drift",
      "title": "Box Drift",
      "summary": "A lattice of 1,728 white cel-shaded boxes tumbles as one group in black fog. Two thirds of them swing far out of formation and back while the rest hold the core.",
      "stack": [
        "three.js",
        "GLSL",
        "instancing"
      ]
    },
    {
      "slug": "linked-frames",
      "title": "Linked Frames",
      "summary": "Five full-bleed photographs stacked one frame apart. Each arrives as a rotated sliver, unwinds flat as you scroll toward it, then rolls shut as you pass. Its numeral fades in and out in step.",
      "stack": [
        "GSAP",
        "ScrollTrigger"
      ]
    },
    {
      "slug": "lens-distortion",
      "title": "Lens Distortion",
      "summary": "One photograph fills the frame, bent as though seen through a sheet of glass. Its edges smear into red, green and blue fringes that breathe in and out on a four-second loop.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "post-processing"
      ]
    },
    {
      "slug": "clipped-visual",
      "title": "Clipped Visual",
      "summary": "One photograph laid down twice, the second copy with its subject cut out. A giant headline crawls between the layers, passing behind the subject and in front of the background. A black LOADING plate lifts, then the headline and four corner captions fade in letter by letter.",
      "stack": [
        "CSS layering",
        "mix-blend-mode overlay",
        "CSS keyframes",
        "container queries",
        "GSAP"
      ]
    },
    {
      "slug": "video-filter",
      "title": "Video Filter",
      "summary": "A looping clip fills the frame under a flat red sheet that collapses it to near monochrome crimson. A heavy uppercase lockup sits left of centre in white.",
      "stack": [
        "CSS mix-blend-mode",
        "HTML5 video",
        "container queries",
        "React"
      ]
    },
    {
      "slug": "scaled-sections",
      "title": "Scaled Sections",
      "summary": "Seven tall photo panels sit in a row on grey. Scrolling grows the desaturated centre panel into a full colour image that fills the frame, shoving its six neighbours off both edges.",
      "stack": [
        "Lenis",
        "CSS container queries",
        "React"
      ]
    },
    {
      "slug": "grid-tiles",
      "title": "Grid Tiles",
      "summary": "A photograph rebuilt from a 20x20 wall of small cubes, each carrying its own tile of the picture. A wave crosses the grid, pushing two of every three cubes back into depth, while the image crossfades to a second photograph and returns.",
      "stack": [
        "three.js InstancedMesh (400 instances, one BoxGeometry)",
        "ShaderMaterial + GLSL",
        "InstancedBufferAttribute aTile (vec4 sub-rect per cell)",
        "GSAP timeline + tween",
        "GlScene / GlFrame house lifecycle",
        "CSS Modules with container queries"
      ]
    },
    {
      "slug": "signal-404",
      "title": "Signal 404",
      "summary": "A white serif 404 NOT FOUND lockup on black, washed by a slow grey noise cloud that drifts across the frame. Every few seconds the type tears into horizontal shear with cyan and magenta fringing.",
      "stack": [
        "three.js",
        "GLSL",
        "DOM"
      ]
    },
    {
      "slug": "sinking-panel",
      "title": "Sinking Panel",
      "summary": "One photograph cut into an 18x18 wall of flat tiles on a charcoal field. Tiles shrink to nothing wherever the cursor goes, so a circular crater with a graded rim follows it across the picture.",
      "stack": [
        "three.js",
        "GLSL",
        "instancing"
      ]
    },
    {
      "slug": "three-rooms",
      "title": "Three Rooms",
      "summary": "Three cube rooms stacked in a near-black space, holding a tree, a torus and a modelled head. Arrow buttons fly the camera straight up or down to the next room while its name cross-fades.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP"
      ]
    },
    {
      "slug": "dissolve-burn",
      "title": "Dissolve Burn",
      "summary": "A glossy black headset materialises out of a drifting field of magenta noise on a plum ground, holds, then burns away again. Sparks live only where the burning front is crossing the surface.",
      "stack": [
        "three.js",
        "GSAP",
        "ScrollTrigger",
        "CSS transforms"
      ]
    },
    {
      "slug": "balloon-letters",
      "title": "Balloon Letters",
      "summary": "The word SOFT stands in fat white balloon lettering on a cream floor, an olive backdrop rising behind it. Three small spheres bob alongside. The camera drifts in and out, and the pointer swings the view.",
      "stack": [
        "three.js",
        "GSAP",
        "ScrollTrigger",
        "CSS transforms"
      ]
    },
    {
      "slug": "canvas-curve",
      "title": "Canvas Curve",
      "summary": "A black wedge sweeps across a white screen, its leading edge bowed out at mid-height. As the wedge lands, a photograph opens outward from the centre.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "CSS transforms"
      ]
    },
    {
      "slug": "level-up",
      "title": "Level Up",
      "summary": "A counter reads Level 27, then flips to an orange 28. The view pulls back out of a tilted wall of scrolling LEVEL UP marquee type, every character rolling upward without stopping.",
      "stack": [
        "GSAP",
        "ScrollTrigger",
        "CSS transforms"
      ]
    },
    {
      "slug": "pointer-collision",
      "title": "Pointer Collision",
      "summary": "Five hundred white beads drift in from a loose ring and pack into a ball on a pale blue-grey field. An invisible sphere follows the pointer and ploughs a channel through them.",
      "stack": [
        "three.js",
        "MeshToonMaterial",
        "InstancedMesh",
        "custom sphere physics",
        "sweep and prune",
        "raycast to plane"
      ]
    },
    {
      "slug": "arrow-field",
      "title": "Arrow Field",
      "summary": "A block of 351 grey arrows. Every one swivels to point at the cursor while it crosses the block, and springs back to horizontal when it leaves.",
      "stack": [
        "GSAP",
        "DOM"
      ]
    },
    {
      "slug": "text-escape",
      "title": "Text Escape",
      "summary": "Four white letters spelling unit sit over a full-bleed photograph. They shove themselves clear of the cursor and drift back once it retreats.",
      "stack": [
        "GSAP",
        "DOM"
      ]
    },
    {
      "slug": "drag-frame",
      "title": "Drag Frame",
      "summary": "Two photographs stack inside a thin frame. Dragging opens a square window through the top image onto the one beneath, and a throw lets that window coast to a stop.",
      "stack": [
        "GSAP",
        "CSS transforms"
      ]
    },
    {
      "slug": "distance-grid",
      "title": "Distance Grid",
      "summary": "A near-black panel ruled in mint holds 510 dots, dim until the cursor passes. Dots near it scale up and brighten, revealing the small dark cross inside each one.",
      "stack": [
        "React 19 client component",
        "TypeScript (strict, noUncheckedIndexedAccess)",
        "CSS Modules",
        "requestAnimationFrame + ResizeObserver",
        "no GSAP, no canvas, no WebGL"
      ]
    },
    {
      "slug": "hold-to-swap",
      "title": "Hold to Swap",
      "summary": "A snow village sits in near-darkness, a warped wireframe sphere breathing at its centre. Hold the pointer down and a ring counts to a hundred, then the image flips into an inverted, grainy twin.",
      "stack": [
        "three.js",
        "GLTF",
        "GLSL",
        "render targets",
        "InstancedMesh",
        "GSAP",
        "Web Audio"
      ]
    },
    {
      "slug": "clash-ring",
      "title": "Clash Ring",
      "summary": "Sixteen thousand orange dots hold the shape of a torus on a warm off-white field, a faint wireframe ring behind them. Drag through it and the nearest dots blast outward, then spring back onto the ring.",
      "stack": [
        "three.js",
        "GPUComputationRenderer",
        "GPGPU ping-pong",
        "GLSL",
        "THREE.Points",
        "raycasting"
      ]
    },
    {
      "slug": "trail-images",
      "title": "Trail Images",
      "summary": "A near-black field carries nothing but the last second of pointer movement. Every hundred pixels of travel drops a small photo tile that pops to full size, then shrinks and fades out.",
      "stack": [
        "three.js",
        "GSAP",
        "CSS transforms",
        "DOM"
      ]
    },
    {
      "slug": "draggable-cards",
      "title": "Draggable Cards",
      "summary": "Three portrait photo cards sit side by side in a thin outlined stage. They wipe into view on load, and each can be thrown around the stage with momentum, squashing slightly while held.",
      "stack": [
        "GSAP",
        "ScrollTrigger"
      ]
    },
    {
      "slug": "image-threshold",
      "title": "Image Threshold",
      "summary": "A giant white numeral sits behind everything on a pale grey page. Moving the pointer sprays rounded photo tiles that pop in and spin away, and the numeral is the distance between one tile and the next.",
      "stack": [
        "GSAP",
        "CSS transforms",
        "DOM"
      ]
    },
    {
      "slug": "toon-outline",
      "title": "Toon Outline",
      "summary": "A large cel-shaded torus with a hard black outline floats on a ground of its own colour. Pressing the button rolls a new palette and snaps the torus through a half turn.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP",
        "ScrollTrigger",
        "DOM"
      ]
    },
    {
      "slug": "distortion-swap",
      "title": "Distortion Swap",
      "summary": "A full-screen photograph melts into a second one at the press of a button. A noise map smears the swap while the whole frame bulges outward and settles like a flexing lens.",
      "stack": [
        "three.js",
        "GLSL",
        "GSAP"
      ]
    },
    {
      "slug": "emoji-pit",
      "title": "Emoji Pit",
      "summary": "Twenty-three emoji discs rain onto a flat orange page and pile up over a giant white EMOJI wordmark. Any one of them can be picked up and flung across the field.",
      "stack": [
        "DOM"
      ]
    },
    {
      "slug": "pointer-shift",
      "title": "Pointer Shift",
      "summary": "A single large photo panel sits centred on the page. It eases a short distance the other way from the pointer and always drifts back toward centre.",
      "stack": [
        "GSAP",
        "CSS transforms"
      ]
    }
  ]
}
