# Motion Pages

> Build production-ready immersive motion pages in the award-site mold — Three.js worlds (foggy heroes, glass product stages with sonar rings, drag-orbit dome galleries, scroll-driven camera journeys with particle morphs, walkable procedural game worlds with HUD chrome) AND non-Three.js motion (raw-WebGL liquid-glass ripple typography, springy draggable poster walls, cursor-trail image reveals, horizontal scroll-snap stories, scroll-scrubbed process reveals, cursor mask reveals, gesture control) — each a single HTML file with a crisp DOM overlay, responsive to phone/tablet, touch-aware, with a mandatory multi-viewport screenshot loop and a design-review (aesthetic + conversion) pass. Use when asked for a "3D landing page", "Three.js hero", "living/breathing homepage", a 3D product page, a 3D gallery, a scroll-story page, "liquid glass", a ripple/distortion hero, a draggable poster wall, "images that follow the cursor", a horizontal-scrolling story, a "scroll to build it" process explainer for a business, a game

- Skill: `jiangyurong609/motion-pages` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add jiangyurong609/motion-pages`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jiangyurong609/motion-pages/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: jiangyurong609 (https://skillmd.com/u/jiangyurong609)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jiangyurong609/motion-pages

---


# Motion Pages — Award-Site Immersive & Motion Recipes

Recipe derived from (and verified by replicating) the "Sylva — Step into the living
world" example: a monochromatic foggy 3D world fills the viewport, organic hero
geometry sweeps across the lower half, and ALL text/UI lives in a crisp DOM overlay —
never as 3D text. Single HTML file, < 1 MB of code, 60 fps. The same world can later be
dimmed and reused as a backdrop under a normal content page.

Working reference implementations ship with this skill — read the one nearest your
task before building:
- `examples/sylva-replica.html` — the foggy hero (every base pattern below);
- `examples/fernline-hero.html` — the hero applied to a fictional SaaS brand;
- `examples/sona-product-hero.html` — glass product stage + sonar rings + orbitals;
- `examples/dome-gallery.html` — drag-orbit dome gallery + click-to-focus fly;
- `examples/boreal-journey.html` — scroll-scrubbed camera rail + particle morphs;
- `examples/pura-liquid-hero.html` — raw-WebGL liquid-glass ripple typography (no three.js);
- `examples/paperworks-posterwall.html` — springy draggable poster wall (no WebGL at all);
- `examples/halde-trail.html` — cursor-trail image reveal: prints surface under the pointer (pure DOM);
- `examples/kiln-horizontal.html` — horizontal scroll-snap story: wheel drives a sideways rail (pure DOM).
- `examples/alder-build.html` — scroll-scrubbed process reveal: the hero builds what the business sells as you scroll, then hands off to the page (pure DOM + canvas).
- `examples/hollowmere-world.html` — live game-world hero: a walkable procedural forest (canvas-painted textures, leaf-card canopies, wind, shadow maps, IBL) with the game's HUD as chrome and a trailer shot from the same scene (three.js, zero image assets).

## Architecture (non-negotiables)

1. **One `.html` file + a vendored `vendor/three.module.js` beside it.** Load three
   local-first with a CDN fallback — never CDN-only (first paint waits on the CDN ⇒
   visitors stare at a blank void), and never local-only (`file://` blocks local ES
   module imports with a CORS "origin null" error, so standalone preview dies):
   ```js
   let THREE;
   try { THREE = await import("./vendor/three.module.js"); }        // instant when served
   catch { THREE = await import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"); }
   ```
   Everything else inline. Card/photo art = inline SVG gradients, never external images.
2. **Three layers + a vignette, fixed, in this z-order:**
   - `.atmo` (z 0) — 2–3 large soft `radial-gradient` light blooms (one behind the
     headline, one near the focal card, one low horizon glow). A flat single-color
     background reads as dead space, not atmosphere.
   - `.ghost` (z 0) — optional giant translucent brand wordmark, **sized so the whole
     word fits the viewport** (total width ≈ 65vw ⇒ ~10.5vw font for a 9-letter word).
     A wordmark that overflows crops into gibberish ("RONTD"). Place it low (top ~70%)
     so the 3D world overlaps it — occlusion sells the depth.
   - `<canvas>` (z 1) — the world, `WebGLRenderer({alpha:true})` with NO
     `scene.background`, so atmo + ghost sit BEHIND the 3D world; body CSS carries
     the background color.
   - `.vignette` (z 1, after canvas) — radial darkening toward the edges; frames the
     shot and pushes the eye to the hero + CTA.
   - `#ui` (z 2) — DOM overlay; `pointer-events:none` on the layer, `auto` on
     interactive elements.
3. **Monochromatic fog world:** `scene.fog = new Fog(WORLD, ~8, ~24)` where `WORLD` ==
   the body background color. Geometry fading into fog at the horizon is the single
   trick that produces the "infinite living world" depth.
4. **Perf budget:** `setPixelRatio(min(devicePixelRatio,2))`, ACESFilmic tone mapping,
   NO shadow maps, InstancedMesh for anything repeated, one hemisphere + one
   directional light.

## ⚠️ The transform trap (this WILL bite you)

Entrance animations, pointer parallax, centering, and card rotation all want the same
CSS `transform` — and silently clobber each other. Separate them:

- **Parallax** → dedicated absolutely-positioned wrapper divs (`.px[data-px]`,
  `inset:0`), shifted via the standalone `translate` property from JS.
- **Entrance** → `.enter` class on the INNER element, animating `opacity` +
  `translate`, flipped by adding `.ready` to `<body>`.
- **Element's own placement** → nav centering via
  `inset-inline:0; margin-inline:auto; width:fit-content` (never `translate:-50%` —
  the entrance animation owns `translate` and will clobber it, pinning the nav at 50%
  and cropping it off-screen; this exact regression shipped once), card tilt via the
  standalone `rotate` property.

Never put two of these on the same element via `transform`.

## Time rules (or headless verification lies to you)

- Drive the intro/scan from **elapsed time** (`clock.elapsedTime`), never by
  accumulating per-frame `dt` — headless Chrome under `--virtual-time-budget` runs few
  rAF frames, so `dt`-accumulated state stalls forever (and real browsers hitch too).
- Creature/state machines driven by `dt` are fine for live motion, but give them a
  snap-to-final in still mode.
- **Build a `?still` mode**: `?still` in the URL ⇒ add `ready still` classes to body,
  force scan progress k=1, snap creatures to their perch, and CSS
  `body.still .enter{transition:none;opacity:1;translate:0 0}` +
  `body.still .art.reveal::after{display:none}`. CSS transitions/animations run on the
  REAL clock, not virtual time — without still mode every screenshot catches them
  mid-fade.

## The effect catalog (compose per project)

- **Organic hero limbs** — 3 sweeping `CatmullRomCurve3` → `TubeGeometry` curves in the
  LOWER half only: one long limb across the bottom, one rising toward the cards, one
  faint and deep (z ≈ −10). Keep limbs slim (radius .35–.55 at camera z ≈ 10.5, fov 42)
  with air between them — fat tubes read as ropes, not landscape.
- **Fuzz (moss/texture)** — one `InstancedMesh` of ~14k tiny cones (r .026, h .09),
  positions hugging each tube surface (`point + dir.normalize()*r*(.85+rand*.3)`),
  upward-biased random directions, scale .5–1.5, per-instance `setColorAt` with HSL
  jitter (hue .25±.06, sat .3–.5, light .2–.4 for moss). A second sparse InstancedMesh
  adds micro-details (white flowers, sparks).
- **Deep scenery silhouettes (3rd depth plane)** — 4–6 bare vertical shapes at
  z ≈ −10…−14 so the fog almost swallows them: tree trunks with one lean limb for a
  forest, utility poles with a crossarm for an urban/tech world — whatever the brand's
  world implies. Vary height/lean, share the scan's fade-in materials. Without this
  plane the world reads as tubes floating in a void; with it, the fog implies a whole
  landscape (the reference's background trees).
- **"Orbit using mouse"** — pointer → small target offset
  (±1.0 x, ∓0.6 y), `camera.position.lerp(target,.045)` every frame, `lookAt` fixed.
  DOM `.px` wrappers translate −pointer·(6–24 px) at different rates per layer.
- **"Particles to pointer"** — 800 `Points`, slow upward drift + sine sway, additive
  blending, `depthWrite:false`; each frame nudge x toward the pointer's world x
  (`x += (px*7 − x)*dt*.01`) so dust condenses around the cursor; respawn at bottom.
- **Wireframe scan intro (Death Stranding)** — for each limb also add a LOW-POLY wire
  clone (`TubeGeometry(curve, 34, r*1.02, 7)`, `MeshBasicMaterial{wireframe}`) — low
  segment counts give the clean triangulated scan look; full-res wireframe is noise.
  All final materials start `transparent:true, opacity:0`; over ~1.4 s (elapsed-time
  driven, smoothstepped) fade finals 0→1 and wires .55→0. **Intro budget: the page must
  feel settled by ~2.5 s** — longer intros test as "slow", not cinematic. Flip
  `body.ready` from the FIRST RENDERED FRAME (+ ~0.9 s), never a fixed timeout: if the
  lib loads slowly the whole choreography just shifts later instead of UI floating over
  a blank void. Add a ~3 s plain-`<script>` fallback that adds `.ready` anyway so a
  WebGL failure never leaves the page blank.
- **Scan-line image reveal** — `::after` overlay on card art: dark cover with a bright
  2 px line, `background-size:100% 220%`, keyframe sweeps `background-position` to
  −120%, `forwards`, staggered delays per card.
- **A living creature** — butterfly: two `PlaneGeometry` wings hinged at the body
  (`geometry.translate(w/2,0,0)`, second wing `rotation.z=π`), flap =
  `sin(t*18)` on `rotation.y`. State machine fly → rest (on a limb, +0.75 y) → flee
  when pointer's world-projected point comes within 1.5 units, then loop. Adapt the
  creature to the brand (butterfly, bird, glowing spark, paper plane).
- **Traveling pulses** — glowing spheres riding `curve.getPointAt((t*speed+off)%1)`
  toward a converging point; good for "flow" metaphors (calls, data, energy).

## Expanded archetypes (derived from award-site studies — AETHER:1, OpenPurpose, Igloo Inc, Lando Norris, MISC, ITOM)

Beyond the foggy hero, four more page shapes reuse the same architecture (fog world +
DOM overlay + still mode). Working references ship with the skill:
`examples/sona-product-hero.html`, `examples/dome-gallery.html`,
`examples/boreal-journey.html`.

**The fake-bloom kit (use it in EVERY archetype — this is the gap between "correct"
and "award-site").** Post-processing bloom needs addon passes; you don't need them.
What actually makes the reference sites glow:

- **Never render bare `Points`** — default points are SQUARES and read as confetti.
  Always set `map` to a canvas radial-gradient sprite (white core → transparent edge,
  ~3 stops, 64px) with `transparent, depthWrite:false` and usually additive blending.
- **Baked halo texture = bloom.** A 512px canvas radial gradient with a bright ring
  band (~stops .58/.68/.72/.76/.88, peak `rgba(...,.95)` at ~.72) on an additive plane
  IS the glowing-ring/portal look (AETHER's halo, Igloo's portal) — one draw call,
  works under SwiftShader.
- **Fresnel shader for glass** — ~12-line `ShaderMaterial`
  (`vF=pow(1-|dot(n,view)|,2.4)`, alpha `vF*.9+.04`, additive, DoubleSide,
  `depthWrite:false`) over a faint dark fill shell + a `PointLight` INSIDE the object.
  Bright live edges, clear center — real glass read with zero env maps.
  ⚠️ Never `MeshPhysicalMaterial{transmission}`: it renders as an OPAQUE GRAY BLOB
  under SwiftShader and muddy on weak GPUs.
- **Sparkle sub-cloud** — alongside a big soft-particle cloud, a 2nd `Points` of ~500
  larger glints whose positions COPY random indices of the main cloud each frame
  (they morph for free) with slowly pulsing opacity.
- **Film grain** — a fixed CSS overlay (`SVG feTurbulence` data-URI, `opacity:.06,
  mix-blend-mode:overlay`) placed under the text layer; kills flat-gradient banding
  and adds the cinematic finish every reference site has.
- **Layered gradient atmosphere** behind the canvas (2–3 radial glows + a vertical
  linear ramp), even for light themes — one flat bg color reads as dead space.

- **Glass product stage** (AETHER:1-style earbuds/device hero) — the product floats
  right of the copy column inside a breathing halo + expanding sonar rings: fresnel
  glass case (kit above), emissive buds inside, interior point light, baked halo plane
  + a wide soft nebula plane behind it, thin geometry rings expanding past the halo,
  two counter-rotating wireframe icosahedron shells and a two-layer soft starfield in
  the deep background.
  - Sonar rings: 3 thin `RingGeometry(1.94,2.0)`, additive, staggered phases,
    `scale 1→2.6` with `opacity (1-ph)²*.4`. THIN is the trick — a fat ring reads as
    a donut, not a pulse. In still mode give each ring a DIFFERENT frozen phase so the
    screenshot shows the expanding sequence.
  - Orbital arcs: tilted `EllipseCurve` lines (opacity ~.3) each carrying one small
    glowing dot at `getPointAt((t*speed+phase)%1)`.
  - Put product + rings + orbits in ONE `stage` group; reposition per breakpoint in
    `resize()` (desktop: `x≈+2.3` beside the copy; phone: `y≈+2.1, scale .62` above it).
  - Sound toggle homage: generate a 3-oscillator WebAudio hum (no media files), and
    make ring amplitude/opacity react — an honest, working "Sound: on/off" chip.
- **Dome media gallery** (OpenPurpose-style) — dozens of cards on the inside of a
  sphere, camera at center; drag to orbit, click to fly to a card.
  - DENSITY sells it: ~100 cards in ~8 latitude bands (counts 9…19 per band),
    `position.setFromSphericalCoords(R≈9.5,…)` + `lookAt(0,0,0)`, card height ~1.55.
    Five sparse bands read as floating litter, not a dome.
  - Card art: `CanvasTexture` fake site thumbnails from a SEEDED rand — zero image
    requests, deterministic screenshots. Art-direct them like a portfolio, not a
    wireframe: 4–5 templates (classic site, giant-typography poster, duotone
    image-led, dark product spotlight), ~15% dark cards, occasional accent color, and
    **bake a soft drop shadow into each canvas** (transparent padding +
    `shadowBlur≈22, offsetY≈10`) — the shadows are what make cards pop off the void.
  - Drag orbit with inertia (`v*=.95` on release) + slow idle auto-rotate; disambiguate
    click vs drag by pointer travel (>6 px = drag, swallow the click).
  - Click focus: raycast → tween camera to `cardPos*0.62`, `lookAt(card)`, dim all
    other cards to `opacity .18`, show a DOM caption pill + "Back" (Esc works too).
- **Scroll-driven camera journey** (Igloo/ITOM-style) — a fixed canvas under a tall
  scroll track (`#track{height:500vh}`); progress `p = scrollY/max`, smoothed
  (`p += (target-p)*.07`), scrubs the camera along a `CatmullRomCurve3` rail with a
  parallel look-at rail; DOM caption blocks fade per progress segment.
  - **Add a `?p=0..1` override** that pins progress — scrolled states become plain
    `--screenshot` calls (no CDP needed), and every keyframe of the journey is
    verifiable. Generalize this: ANY interactive state a screenshot can't reach by
    itself gets a forcing query param (`?focus=N` for the dome's fly-to, etc.).
  - ⚠️ The rail must clear the terrain: displaced-noise ground WILL rise above a
    low camera mid-path and fill the frame with a featureless slab. Flatten a clearing
    around the subject (`amplitude *= clamp((d-6)/11,0,1)`) and keep rail y above the
    local terrain max; verify with `?p=` shots at 0/.25/.5/.75/.95.
  - Captions over a bright fog world need a scrim (blurred dark glass pill), not just
    text-shadow — white-on-white particles swallow bare text.
- **Particle shape morph** (Igloo's creature / gesture demos) — ONE `Points` cloud
  (~6k, soft sprites + sparkle sub-cloud from the kit), several precomputed
  `Float32Array` target sets. Morph = per-particle smoothstepped lerp with a
  per-particle stagger offset (`s=clamp((blend-stagger*.5)/.5)`), plus
  `sin(ss*π)`-weighted scatter so particles loosen mid-flight and re-condense — that
  scatter is what makes it read as dissolution rather than a cheap tween.
  - Don't sample smooth surfaces — QUANTIZE targets into structure: the igloo is
    brick cells on a hemisphere (rows offset half-a-brick for a running bond, a
    skipped wedge per bottom row forming the entrance arch, plus a half-cylinder
    tunnel); the gaps between cells read as mortar seams. Structure > density.
  - Creature = union of ~9 offset spheres (body/head/ears/paws/snout) sampled
    volumetrically; silhouette matters, detail doesn't — particles forgive crude
    geometry. During the ring act, back the particle torus with the baked halo plane
    + a soft central burst (windowed by scroll: `w=clamp(1-|p-.5|*5)`) — the particles
    alone are a shape; the halo makes it a PORTAL.
- **Cursor mask reveal** (Lando-Norris-style) — mostly DOM: stack alternate hero
  layers (helmet/visor variants) and drive `clip-path:circle(r at x y)` (or a WebGL
  uv-discard mask) from the pointer; snap layer choice to pointer zones so elements
  change the instant the cursor crosses them.
- **Live game-world hero** (`hollowmere-world.html`, the "landing page IS level one"
  pattern — an indie isometric ARPG whose site opens on a walkable procedural world with
  the game's HUD as chrome; the reference had procedural trees/rocks/monuments/characters
  and a whole game under 2 MB before art and music) — Three.js, one scene, zero image
  assets, and the one archetype that EARNS shadow maps:
  - **Every texture is painted on a canvas at load** (~1 s of JS, cached by the GPU):
    periodic value noise (`pn(x,y,P)` with a lattice modulo so tiles wrap) → fbm; ground
    albedo 768² with grass↔moss by noise, leaf litter/dry-blade speckles, the road baked
    in via distance-to-polyline with a noise-wobbled edge, mud around the pond; a tiled
    bump map; bark (streaky low-frequency + dark seams) + bump; stone (noise + moss tint +
    stroked cracks) + bump; a water NORMAL map from finite differences of noise; leaf
    clusters (46 gradient ellipses with a midrib), grass blades (14 tapered quadratic
    curves), a fern frond (stem + 20 leaflet pairs), flowers — all alpha-tested cards.
  - **Foliage is cards, not blobs**: a canopy = 22–34 instanced leaf-card planes in a
    flattened ball (`cbrt(rand)` radius so the shell is denser), random Euler rotation,
    per-instance HSL that darkens toward the underside; `alphaTest:.5`, `DoubleSide`,
    `castShadow` with a `customDepthMaterial` (`MeshDepthMaterial` + the same map +
    alphaTest) or the shadow is a solid square. Grass = 5,600 instanced blade cards
    (2,600 on portrait), ferns = 3 crossed cards, flowers = 2. **Wind** = one
    `onBeforeCompile` that reads the instance origin, adds `sin(uTime + ip.x·.6 + ip.z·.8)`
    displacement scaled by `uv.y²` so roots stay planted; amplitude .16 grass, .07 fern,
    .05 leaves. Freeze `uTime` in still mode.
  - **Shadow maps, but only one**: a single directional light, `PCFSoft`, 2048 (1024 on
    lite), ortho frustum ±19 units that FOLLOWS the focus (`sun.position = SUNDIR +
    target`), `bias −.0007`, `normalBias .04`. Rocks/ruins/trunks/knight cast; grass only
    receives. Hemisphere + a painted-sky PMREM environment (`PMREMGenerator.fromScene` on
    a vertex-coloured gradient sphere + a bright sun ball; `scene.environmentIntensity`
    .55 by day, .18 by night) — the IBL is what makes steel (metalness .88) read as steel
    instead of black.
  - **Fog must start beyond the focus distance**: camera at 44 units, fog 50→84. With
    fog at 34 the whole clearing sat a third of the way into the haze and every frame
    looked washed; only the far rim should fade.
  - Camera = PerspectiveCamera fov 30 (44 on portrait) at `normalize(1,1.28,1)×44`,
    focus lerping 35 % toward the player plus pointer parallax. Leave the quadrant in
    front of the camera treeless (`x+z > 13 → skip`) or the canopies occlude the hero.
    Rocks = three noise-displaced icosahedra (detail 3) instanced with non-uniform
    scale, low-value colours (HSL L .38–.62 — pale rocks read as paper). A pond = a
    terrain depression + a `CircleGeometry` with the water normal map scrolling
    (`offset = t·.012`), roughness .1, envMapIntensity 1.3. Pollen = 320 additive soft
    points drifting as a function of `t`, faded by daylight. Light shafts = a DOM
    `repeating-linear-gradient` at 112°, `mix-blend-mode:screen`, radial-masked to the
    sun corner, drifting on a linear loop, opacity tied to daylight.
  - **The character is ~25 primitives on pivots**: leg pivots (mail capsule + steel
    greave + boot), a mail skirt cone, capsule torso + a breastplate sphere, pauldrons,
    a helmet sphere with a visor bar and gold crest, arm pivots carrying a tapered
    cylinder blade (scaled flat) with a gold crossguard and a squashed-sphere shield
    with a torus boss, a cloak plane hinged at the neck. Walk cycle = `sin(t×9.5)` on
    the pivots scaled by a walk blend, plus a bob; the cloak leans with speed.
  - **Play, not just look**: click/tap → raycast onto the y=0 plane → clamp to the
    clearing (and out of the pond) → the character turns (shortest-angle lerp) and
    walks; a pulsing ring marks the target. Three "waystones" light when reached
    (emissive lamp + PointLight) and drive a quest counter in the HUD; 3/3 toasts a
    wishlist link. Hotbar + keys map to real verbs (strike swing, guard pose, dodge dash
    with a stamina dent, nightfall, rest). **Autopilot**: after 5 s without input the
    character walks the quest himself — the hero is never still and the quest completes
    for a passive viewer.
  - **Time of day** as one scalar `tod`: sun/hemisphere/fog colours, environment
    intensity, exposure and fire/lamp intensities lerp from a daylight weight + a dusk
    gaussian; night must still read (sun ≥ .9, hemi ≥ .75, exposure ≥ .95). Set each
    light's base from `tod`, then flicker multiplicatively from `t` — never
    `intensity *= …` across frames. The trailer pins `tod` to .5 for its 12 s.
  - **The trailer is the same scene**: swap to a PerspectiveCamera for three 4 s shots
    (low orbit at the fire → dolly to the arch → crane up to a serif title card),
    letterbox bars, HUD fades, Esc/Exit returns. Press screenshots are rendered ONCE by
    the same renderer from other cameras at other hours into 2D canvases
    (`renderer.setSize(w,h,false)` → render → `ctx.drawImage(renderer.domElement)` →
    restore). No second pipeline, no images.
  - HUD = DOM: glass panels (rgba .8 over a bright world — .62 fails contrast) with a
    gold hairline, an inset second hairline and four corner dots; a circular minimap
    drawn on a 2D canvas from the same object lists; a vitality orb; inline-SVG hotbar
    icons with keycaps. Text over the world needs an opaque backing (chip/pill) or the
    contrast audit samples the grass; invisible overlays (toast, cinematic title) need
    `visibility:hidden`, not just opacity 0.
  - `?still` = t frozen, tod .47, first waystone lit, character at the fire, no
    autopilot; `?tod=` pins the hour; `?lite` halves textures/shadow map/grass;
    portrait shrinks the walk radius so the character never leaves the frame. 60 fps
    on an Apple-silicon integrated GPU at DPR 2.

## Motion beyond Three.js (same architecture, no 3D library)

The reference reels are NOT all Three.js — liquid-glass typography and physical
poster walls are raw WebGL / pure DOM. Same non-negotiables apply (single file,
DOM overlay, `?still` mode, screenshot loop).

- **Liquid-glass ripple typography** (`pura-liquid-hero.html`, the OYLA/"Ripple
  Distortion" look) — paint the editorial page ONCE on an offscreen 2D canvas (giant
  serif display type, gradient product orb, captions — this layer is what distorts;
  CTAs/nav stay crisp DOM above), then a raw-WebGL fragment shader over it:
  - **Analytic ripples, not FBO water sim**: keep a pool of ~24 `vec3(x,y,age)`
    uniforms; each fragment sums expanding ring waves
    `sin((d-age*speed)*44) * exp(-band²*260) * exp(-age*1.6)` and displaces the sample
    uv along the radial. Ping-pong float FBOs die on SwiftShader/older GPUs; analytic
    rings are deterministic (still mode = a preset ripple array) and look identical.
  - Liquid-glass lens on the cursor: pull uvs toward the pointer
    (`-inl²*.05`, inl=smoothstep(R,0,d)) + a rim highlight at the lens edge.
  - Chromatic aberration = sample R/G/B with displacement ×1.06/1.0/0.94; wet look =
    add white proportional to the summed wave crest (~.24 — more goes milky).
  - Cover-fit the source texture in-shader (compare canvas vs texture aspect) so
    resize never letterboxes; spawn ripples throttled (~70 ms) on pointermove, a burst
    of 3 on click; idle autopilot drifts the lens and drips ripples so the page is
    never dead.
- **Springy poster wall** (`paperworks-posterwall.html`, MISC-style) — no WebGL at
  all; the paper physics is a spring in CSS transforms:
  - **Infinite wrap**: one COLS×ROWS block of posters tiled 3×3; each cell's screen
    position = `mod(base + offset + B/2, B*3) - B*1.5` per axis — drag forever, no
    edges, 36 DOM nodes total.
  - **Bend from lag, not velocity**: smooth the offset (`o += (target-o)*.14`) and use
    the LAG `(o-target)` as bend energy → target tilt `rotateX/rotateY` (clamped
    ±22–26°), integrated per-poster with a spring (`v += (t-r)*.16; v *= .82`) and a
    per-poster stiffness factor so the wall ripples like sheets, not a rigid grid.
  - Poster art = seeded canvas paintings (bold palette, stacked display words, glyphs,
    zigzags, circled numerals + catalog footer line) — zero image requests.
  - Drag + wheel both pan; momentum on release (`v *= .94`); click-vs-drag gate
    (>6 px travel swallows the click); click → backdrop-blur modal enlarge.
  - Floating UI over busy art needs its own surface: brand chip and hint pill get a
    translucent bg + backdrop blur, never bare text.
- **Cursor-trail image reveal** (`halde-trail.html`, the "images follow the mouse"
  portfolio hero) — pure DOM; the archive surfaces wherever the pointer goes:
  - **A pool, not a stream**: ~16 print nodes recycled in order, newest gets the top
    z-index. Never create/destroy nodes per move — GC hitches read as stutter.
  - **Spawn by distance, not time**: one print per ~64 px of pointer travel (52 on
    phones) so a slow hand leaves a sparse trail and a fast one a dense fan. Track
    velocity with a low-pass (`v = v*.6 + d*.4`).
  - **Exit along the path**: entrance is scale .6→1 on a soft-landing curve (~.4 s);
    after ~700 ms the print lets go — fades, shrinks to .92 and DRIFTS ~70 px along the
    pointer's own direction (+ a little lift). That directional exit is what makes it a
    trail instead of a pile. Reset classes + force a reflow before re-placing a node so
    the entrance transition always plays.
  - Prints are seeded canvas duotones (horizon / ridgeline / doorway / figure) inside a
    white print border with a mono caption row — zero requests, deterministic still.
  - The headline sits ABOVE the trail with `mix-blend-mode:multiply` so prints read
    through the letters; click = a five-print burst; idle autopilot (phantom cursor on
    a figure-eight after ~2.6 s) keeps the page alive; `?still` = eight prints on a
    fixed S-curve.
- **Horizontal scroll-snap story** (`kiln-horizontal.html`, the sideways chapter
  page) — pure DOM; vertical scroll drives a horizontal rail:
  - A tall invisible `#track{height:N*100vh}` gives the document its scroll; a fixed,
    `overflow:hidden` view holds a flex rail of `100vw` sections translated by
    `-p*(N-1)*100vw`, `p` smoothed per frame (`p += (t-p)*.09`). The clip on the fixed
    view is what keeps `scrollWidth == innerWidth` — the phone-overflow audit rule
    will catch you if the rail leaks.
  - **Parallax from LOCAL progress**: each chapter gets `--p = clamp(p*(N-1)-i,-1,1)`
    and its layers move at different rates from it (numeral −18vw, artwork −9vw with a
    −6° roll, copy +6vw). Three speeds = depth; one speed = a slideshow.
  - **Soft snap**: ~160 ms after the last scroll input, glide `scrollY` to the nearest
    chapter (ease-out cubic, ~650 ms); ANY user scroll cancels the glide. Dots +
    arrow keys jump chapters; a 2 px top bar scales with `p`.
  - `?p=0..1` pins progress (collapse the track, lock body scroll) so every chapter is
    a plain screenshot; `?still` = a between-chapters value (.31) so the parallax is
    visibly mid-flight. `prefers-reduced-motion` = no smoothing, no snap.
  - Artwork = seeded canvas vessels lathed from a smooth radius function (48
    samples) — a distinct silhouette per chapter so the story reads at a glance.
- **Scroll-scrubbed process reveal** (`alder-build.html`, the "scroll to build it"
  business hero — a pool builder's site does this with construction photos, and agencies
  charge for exactly this screen) — pure DOM + one canvas; scroll progress builds the
  thing the business sells:
  - A `#track{height:500vh}` with a `position:sticky;top:0;height:100vh` stage inside
    it (sticky, not fixed — the stage scrolls away by itself when the track ends, no
    unpin logic). `p = scrollY/(track.offsetHeight−innerHeight)`, smoothed per frame
    (`p += (t−p)*.1`) so wheel notches read as a scrub, not steps.
  - **One draw function, one timeline**: `drawScene(ctx,w,h,p,t)`; every stage is a
    `seg(p,a,b)` window (stakes .02–.09, string .06–.16, soil .14–.22, piers .18–.25
    staggered, deck .28–.36, posts .36–.44, plates .42–.50, rafters .48–.56, walls
    .56–.64, gable .62–.68, roof .66–.74, openings .72–.78, dusk .78–.92, window glow
    .84–.97). Windows overlap a little so the build never pauses.
  - **Nothing snaps in**: piers grow from the ground, the deck drops from +1.4 units on
    an ease-out, posts extend, beams and rafters draw along their length (partial
    `lineTo`), walls rise with a clipped height, the roof lands from above. Grow, drop,
    draw — three verbs, reused for every part.
  - Scene = isometric projection `[ox+(x−y)·.866k, oy+(x+y)·.5k−z·k]` on a 2D canvas,
    no 3D library. Pitch the roof steeper than the view angle (rise > half the depth)
    or the camera sees the back slope over the ridge; draw the chimney BEFORE the roof
    so the slope hides its base. Every colour is a `mix(day,dusk,d)` pair, so the
    finale (lights on, stars, smoke) is the same scene, not a second one.
  - Captions: five pre-rendered `.cap` nodes, index from `p` thresholds, `.on` toggles
    a soft-landing crossfade — never swap text mid-transition. A 2 px bar with stage
    ticks, a `01 / 05` counter, a hint pill that leaves after 2 % of scroll.
  - White type over a sky needs scrims: top and bottom `linear-gradient`
    pseudo-elements on the stage (.72→0, .82→0) — without them the contrast audit
    fails on the day frames.
  - The hero HANDS OFF: statement → dark stats strip → gallery (the same `drawScene` at
    p=.77 / 1 / a winter palette into 640×480 minis = three "projects", zero images) →
    process steps → testimonial → quote form. The build is the argument; the form is
    the close.
  - `?p=0..1` pins (100vh track, body scroll locked); `?still` = .69 (roof mid-drop)
    with `t` frozen so clouds/stars/smoke hold; `prefers-reduced-motion` = `p` follows
    scroll instantly, no entrances.
  - Swap the drawing for photographs when the client has them: the same timeline
    drives `ctx.drawImage(frames[Math.round(p·(N−1))])` from a preloaded frame
    sequence (the Apple product-page trick) or `video.currentTime = p·duration` on a
    muted `<video>`; keep the `seg()` captions, scrims and hand-off exactly as they are.
- **Gesture control** (webcam demos) — MediaPipe Hands (or FaceLandmarker) mapped onto
  the SAME particle-morph machinery: pinch distance → gather/scatter blend, palm x/y →
  group rotation. Keep it an optional progressive enhancement behind a permission
  prompt; pointer fallback always works.

## DOM overlay kit (matches the reference look)

- Pill navbar top-center, `backdrop-filter:blur(16px)`, dark 50% bg — with the
  reference's full state system (a color-change-only nav reads as basic):
  - logo chip: white ROUNDED-SQUARE (radius ~13px, not a circle) with a monochrome
    brand glyph, raised shadow, playful hover (`rotate(-6deg) scale(1.05)`);
  - EVERY link gets an 11px stroke icon + uppercase letter-spaced label (10.5px `.18em`);
  - active link: SOLID white pill, dark text, raised shadow — not a translucent wash;
  - hover: a raised pill materializes — vertical gradient bg, hairline
    `rgba(255,255,255,.14)` border, `translateY(-1px)`, inner top light + drop shadow;
  - the nav's own CTA (Enter/Start free): visually distinct at rest — bordered
    glass pill, or a mini shader-gradient pill for conversion-focused brands.
- Hero reads top-to-bottom in ONE left column: category badge → headline (Quicksand
  500, ~56–60px, 2 lines, one value word in a gradient `background-clip:text` accent) →
  **subheadline DIRECTLY UNDER the H1** (≥15px, key value phrases in `<b>`) → CTA row.
  Never park the explainer paragraph beside the headline in tiny muted type — that is
  where the core value goes to die.
- CTA row: the primary CTA must be the highest-contrast element on the page —
  brand-gradient fill + colored glow shadow; a dark pill on a dark world is camouflage.
  Label = action + time-to-value ("Start free — live in 5 minutes →"). Secondary
  demo button beside it: glowing pulsing ring + a TEXT label naming the content and its
  honest length ("Hear it answer a call · 1½-minute demo") — bare play circles get
  ignored, and check the real media duration (`mdls -name kMDItemDurationSeconds`),
  not the filename.
- Trust line: ✓-marked risk reversals ("No credit card · Keep your number · Cancel
  anytime") sit DIRECTLY UNDER the CTA row at ≥13px — risk reversal works at the point
  of decision, never orphaned in a far corner of the viewport.
- Glass cards: white `#f7f8f4`, 22px radius, 9px padding, art block 15px radius with
  inline-SVG gradient art, kicker (10px uppercase) + 18.5px claim, small circular
  action button bottom-right; standalone `rotate:±1.4deg`; deep soft shadow.
- Stat chips: frosted-glass pills (blur + border, matching the nav) with a small-caps
  label over a ~21px bold value — bare floating micro-text over the world is invisible.
- Every interactive element must WORK on day 1: demo button opens a `<video>` modal
  (Esc / backdrop / × close, `aria-label`s, `preload="metadata"`, `<source>` list =
  local-relative file then site-absolute fallback); nav/CTA links point at the REAL
  app's routes and verified anchor ids (`/#how`, `/onboarding`) — relative when the
  hero will be mounted in the app, absolute only for a standalone share.

## Fluid UI layer (shader buttons + cursor light — the difference between "basic three.js" and the reference)

The reference's fluidity is mostly NOT in the 3D scene — it's that every interactive
surface behaves like a lit object. Pure CSS + ~10 lines of JS, no libraries:

- **Shader button** (primary CTA, key pills) — four layers on one element:
  1. glass gradient base stacked over the brand fill:
     `background:linear-gradient(180deg,rgba(255,255,255,.15),transparent 40%[,rgba(0,0,0,.28)]), <brand>`
     plus `inset 0 1px 0 rgba(255,255,255,.2)` top rim;
  2. **idle specular sweep** — a blurred diagonal white streak (`::before`, ~42% wide,
     `rotate:14deg`, `filter:blur(5px)`) gliding across every ~5.5s
     (`@keyframes sheen{0%,55%{left:-45%}85%,100%{left:115%}}`) — the button breathes
     even when idle;
  3. **cursor-following light** — `::after` radial gradient centered at
     `var(--mx) var(--my)`, `opacity:0→1` on hover;
  4. hover bloom: lift −2px + brighter outer glow; `:active{scale(.97)}`;
  5. **backlit under-glow on hover** (the reference's signature) — the button looks lit
     from behind its bottom edge: stack a second radial in the same `::after`
     (`radial-gradient(115% 95% at 50% 132%, <glow>, transparent 58%)`) plus a bottom
     outer shadow (`0 14px 38px -8px <glow>`) and a bottom inset
     (`inset 0 -12px 26px -14px <glow>`). Glow color is brand light: warm dawn gold for
     nature themes, the accent (e.g. violet) for tech brands. Tint the sheen streak the
     same temperature.
- **Frosted-glass play disc** — not a flat translucent circle: vertical gradient going
  milky toward the bottom (`rgba(255,255,255,.03) → .16 @62% → .32`),
  `backdrop-filter:blur(7px)` so the world refracts through it, bright bottom inner rim
  (`inset 0 -9px 18px -6px rgba(255,255,255,.38)`), top hairline; hover brightens the
  glass and scales 1.07.
  Needs `overflow:hidden` and content wrapped above the pseudos (`.cta>*{z-index:2}`).
- **JS** — one `pointermove` listener per shader surface (CTA, nav bar, AND each nav
  link — `".cta, nav, nav a"`) writing element-local `--mx/--my` percentages from
  `getBoundingClientRect()`.
- **Nav glass** — cursor light on the pill bar, plus a scoped light inside each
  link's hover pill (`nav a::after` radial at `--mx/--my`; `display:none` on
  `.active` — light on a solid white pill is invisible noise). The hovered pill's
  fill follows the pointer instead of being a static gradient.
- **Cards** — lift on the WRAPPER (`.cardpos:hover{translate:0 -6px}` — the wrapper has
  no `.enter`, so no transform-trap conflict), tilt flattens to `rotate:0`, shadow
  deepens, art scales 1.05 over .8s; corner icon button inverts + `scale(1.12)
  rotate(8deg)` on hover.
- **Play/demo rings** — slow ripple outward (`scale .92→1.16`, fade), staggered ~1.7s.
- **Easing system** — `:root{--e:cubic-bezier(.22,1,.36,1)}` (expo-out) on every
  micro-interaction, .35–.5s. Default `ease .2s` is what makes a page feel "basic".
- **⚠️ Stagger-delay trap** — entrance `.d1–.d5` classes set `transition-delay` that
  would lag every hover FOREVER. Add `body.settled .enter{transition-delay:0s}` and
  flip `.settled` ~1.6s after `.ready` (immediately in still mode). Also disable
  sheen/ripple keyframes under `body.still` (they run on the REAL clock and break
  deterministic screenshots) and under `prefers-reduced-motion`.

## Easing grammar (curves have semantics — don't use one for everything)

`--e` above is the *hover* curve. Pick curves by what the motion MEANS, then reuse
the same handful everywhere — a consistent bezier set is what makes motion feel
like one brand instead of a component kit:

- **Entering** (cards/modals/toasts appearing) → **ease-out** (fast feedback,
  soft landing): `cubic-bezier(0,0,.2,1)` or the expo-out `--e`.
- **Leaving** (dismiss, close, fade-away) → **ease-in** (`cubic-bezier(.4,0,1,1)`)
  — slow start reads as "letting go", and nobody needs to track the end of an exit.
- **Moving within the page** (reorder, expand/collapse, panel slide, page
  transitions) → **ease-in-out** (`cubic-bezier(.45,0,.55,1)`) — the whole journey
  is on screen, so show both the pickup and the set-down.
- **Loops** (orbit drift, marquees, rotation, pulse rings) → **linear** or sine —
  any easing in a loop reads as a stutter.
- **Brand signature**: define the full set as custom properties once
  (`--e-out/--e-in/--e-inout`) and never write a raw curve at a use site. One
  distinctive overshoot curve (e.g. `cubic-bezier(.34,1.56,.64,1)`) reserved for
  the ONE hero moment (CTA settle, logo land) is a recognizable motion identity;
  overshoot everywhere is a toy.

## Responsive + touch (mandatory — this ships to phones on day 1)

Absolute desktop positioning collapses into a card-pile on a 390px screen. Two
breakpoints minimum:

- **Phone (`max-width:740px`)** — single column: nav becomes a full-width bar
  (logo left, CTA right, middle links hidden; don't try to keep it center-pinned —
  `left:4%;right:4%;justify-content:space-between`). Two modes depending on the page:
  - *One-screen standalone hero* (`overflow:hidden` demo page): hide the stat chips,
    secondary cards and edge micro-labels; keep ONE card peeking from a bottom corner
    (`right:-24px;bottom:-34px;rotate:4deg`) as an artistic cue.
  - *Scrolling product page*: **reflow, never hide** — real users on real phones notice
    missing stats and cards. Make the hero viewport an auto-height flex column, switch
    the `.px` parallax layers and their children to `position:static`, and use flex
    `order` on the layers to sequence badge → H1 → sub → CTA → trust → chips → ALL
    cards (centered, `width:min(300px,100%)`, slight rotate kept). Gate the DOM
    parallax off on phones (`if (innerWidth <= 740) return`) — touch-drag fires
    pointermove and would shift static flow blocks.
- **Tablet (`741–1180px`)** — keep the collage but shrink cards (~215px), drop one
  chip, and re-check that no card covers the demo button's label.
- **Touch = no `pointermove`, ever.** Gate on a `hasPointer` flag: until the first
  pointer event, drive the camera with a slow idle drift
  (`pointer.x = sin(t*.22)*.35`) so the world still b

…(truncated)
