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)
- One
.htmlfile + a vendoredvendor/three.module.jsbeside 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):
Everything else inline. Card/photo art = inline SVG gradients, never external images.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"); } - Three layers + a vignette, fixed, in this z-order:
.atmo(z 0) — 2–3 large softradial-gradientlight 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 NOscene.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:noneon the layer,autoon interactive elements.
- Monochromatic fog world:
scene.fog = new Fog(WORLD, ~8, ~24)whereWORLD== the body background color. Geometry fading into fog at the horizon is the single trick that produces the "infinite living world" depth. - 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 standalonetranslateproperty from JS. - Entrance →
.enterclass on the INNER element, animatingopacity+translate, flipped by adding.readyto<body>. - Element's own placement → nav centering via
inset-inline:0; margin-inline:auto; width:fit-content(nevertranslate:-50%— the entrance animation ownstranslateand will clobber it, pinning the nav at 50% and cropping it off-screen; this exact regression shipped once), card tilt via the standalonerotateproperty.
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-framedt— headless Chrome under--virtual-time-budgetruns few rAF frames, sodt-accumulated state stalls forever (and real browsers hitch too). - Creature/state machines driven by
dtare fine for live motion, but give them a snap-to-final in still mode. - Build a
?stillmode:?stillin the URL ⇒ addready stillclasses to body, force scan progress k=1, snap creatures to their perch, and CSSbody.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→TubeGeometrycurves 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
InstancedMeshof ~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-instancesetColorAtwith 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,lookAtfixed. DOM.pxwrappers 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 starttransparent: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. Flipbody.readyfrom 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.readyanyway so a WebGL failure never leaves the page blank. - Scan-line image reveal —
::afteroverlay on card art: dark cover with a bright 2 px line,background-size:100% 220%, keyframe sweepsbackground-positionto −120%,forwards, staggered delays per card. - A living creature — butterfly: two
PlaneGeometrywings hinged at the body (geometry.translate(w/2,0,0), second wingrotation.z=π), flap =sin(t*18)onrotation.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 setmapto a canvas radial-gradient sprite (white core → transparent edge, ~3 stops, 64px) withtransparent, depthWrite:falseand 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), alphavF*.9+.04, additive, DoubleSide,depthWrite:false) over a faint dark fill shell + aPointLightINSIDE the object. Bright live edges, clear center — real glass read with zero env maps. ⚠️ NeverMeshPhysicalMaterial{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
Pointsof ~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 feTurbulencedata-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.6withopacity (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
EllipseCurvelines (opacity ~.3) each carrying one small glowing dot atgetPointAt((t*speed+phase)%1). - Put product + rings + orbits in ONE
stagegroup; reposition per breakpoint inresize()(desktop:x≈+2.3beside the copy; phone:y≈+2.1, scale .62above 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:
CanvasTexturefake 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*=.95on 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 toopacity .18, show a DOM caption pill + "Back" (Esc works too).
- DENSITY sells it: ~100 cards in ~8 latitude bands (counts 9…19 per band),
Scroll-driven camera journey (Igloo/ITOM-style) — a fixed canvas under a tall scroll track (
#track{height:500vh}); progressp = scrollY/max, smoothed (p += (target-p)*.07), scrubs the camera along aCatmullRomCurve3rail with a parallel look-at rail; DOM caption blocks fade per progress segment.- Add a
?p=0..1override that pins progress — scrolled states become plain--screenshotcalls (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=Nfor 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.
- Add a
Particle shape morph (Igloo's creature / gesture demos) — ONE
Pointscloud (~6k, soft sprites + sparkle sub-cloud from the kit), several precomputedFloat32Arraytarget sets. Morph = per-particle smoothstepped lerp with a per-particle stagger offset (s=clamp((blend-stagger*.5)/.5)), plussin(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.
- a soft central burst (windowed by scroll:
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,castShadowwith acustomDepthMaterial(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 = oneonBeforeCompilethat reads the instance origin, addssin(uTime + ip.x·.6 + ip.z·.8)displacement scaled byuv.y²so roots stay planted; amplitude .16 grass, .07 fern, .05 leaves. FreezeuTimein 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.fromSceneon 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 + aCircleGeometrywith the water normal map scrolling (offset = t·.012), roughness .1, envMapIntensity 1.3. Pollen = 320 additive soft points drifting as a function oft, faded by daylight. Light shafts = a DOMrepeating-linear-gradientat 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 fromtod, then flicker multiplicatively fromt— neverintensity *= …across frames. The trailer pinstodto .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;?litehalves 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.
- Every texture is painted on a canvas at load (~1 s of JS, cached by the GPU):
periodic value noise (
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 wavessin((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.
- Analytic ripples, not FBO water sim: keep a pool of ~24
- 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.5per 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 tiltrotateX/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.
- Infinite wrap: one COLS×ROWS block of posters tiled 3×3; each cell's screen
position =
- 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:multiplyso 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:hiddenview holds a flex rail of100vwsections translated by-p*(N-1)*100vw,psmoothed per frame (p += (t-p)*.09). The clip on the fixed view is what keepsscrollWidth == 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
scrollYto 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 withp. ?p=0..1pins 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.
- A tall invisible
- 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 aposition:sticky;top:0;height:100vhstage 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 aseg(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 amix(day,dusk,d)pair, so the finale (lights on, stars, smoke) is the same scene, not a second one. - Captions: five pre-rendered
.capnodes, index frompthresholds,.ontoggles a soft-landing crossfade — never swap text mid-transition. A 2 px bar with stage ticks, a01 / 05counter, a hint pill that leaves after 2 % of scroll. - White type over a sky needs scrims: top and bottom
linear-gradientpseudo-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
drawSceneat 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..1pins (100vh track, body scroll locked);?still= .69 (roof mid-drop) withtfrozen so clouds/stars/smoke hold;prefers-reduced-motion=pfollows 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) orvideo.currentTime = p·durationon a muted<video>; keep theseg()captions, scrims and hand-off exactly as they are.
- A
- 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.
- logo chip: white ROUNDED-SQUARE (radius ~13px, not a circle) with a monochrome
brand glyph, raised shadow, playful hover (
- 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:textaccent) → 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; standalonerotate:±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-labels,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:
- 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>plusinset 0 1px 0 rgba(255,255,255,.2)top rim; - 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; - cursor-following light —
::afterradial gradient centered atvar(--mx) var(--my),opacity:0→1on hover; - hover bloom: lift −2px + brighter outer glow;
:active{scale(.97)}; - 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.
- glass gradient base stacked over the brand fill:
- 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. Needsoverflow:hiddenand content wrapped above the pseudos (.cta>*{z-index:2}). - JS — one
pointermovelistener per shader surface (CTA, nav bar, AND each nav link —".cta, nav, nav a") writing element-local--mx/--mypercentages fromgetBoundingClientRect(). - Nav glass — cursor light on the pill bar, plus a scoped light inside each
link's hover pill (
nav a::afterradial at--mx/--my;display:noneon.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 torotate: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. Defaultease .2sis what makes a page feel "basic". - ⚠️ Stagger-delay trap — entrance
.d1–.d5classes settransition-delaythat would lag every hover FOREVER. Addbody.settled .enter{transition-delay:0s}and flip.settled~1.6s after.ready(immediately in still mode). Also disable sheen/ripple keyframes underbody.still(they run on the REAL clock and break deterministic screenshots) and underprefers-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:hiddendemo 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
.pxparallax layers and their children toposition:static, and use flexorderon 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.
- One-screen standalone hero (
- 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 ahasPointerflag: 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)