ether-scroll
Technique reference for the Lenis + GSAP ScrollTrigger stack in the ether engine. Invoke before writing scroll-driven motion on a site built on it.
Symbol names are the durable references. Paths cited here are ether repo paths (src/...); your site's consumer scene is referred to by role.
When to use
Any of:
- Adding a new section that binds visual state to scroll position.
- Modifying an existing ScrollTrigger in a scene's
setupScrollTrigger(). - Wiring a shader uniform, camera transform, or DOM class to scroll progress.
- Diagnosing scroll feel — jank, mis-firing triggers, ghost updates after view transitions.
Do NOT invoke for:
- Pure CSS scroll-snap or
scroll-timeline()work (those don't need ScrollTrigger). - Sites that don't use
ether/scroll(this skill encodes its bridge architecture).
Read first
recipes.mdin this directory — ten numbered recipes.- The engine bridge at
src/scroll/—ScrollBridge(Lenis lifecycle, idempotent plugin registration, seconds→ms raf) andcreateScrollProgress(page progress 0..1 → callback). - Your site's scene
setupScrollTrigger()— the consumer.
Hard rules
- The bridge is ticked from the engine's render loop, not its own. Your scene's
tick(time)callsthis.scroll?.raf(time)—ScrollBridgeconverts seconds→ms internally; never multiply at the call site. Never calllenis.start(). Two rAF loops produce one-frame lag between scroll and 3D. - The
ScrollBridgeis conditional onquality.enableSmoothScroll. Null on LOW tier only — touch gets the bridge (LenissyncTouchsmooths on top of native iOS momentum, the fix for choppy mobile scroll-to-3D; it does NOT hijack scroll). When null, ScrollTrigger falls back to native scroll automatically — don't paper over the null with a fake bridge. Progress scrubs still work without it:createScrollProgressregisters the plugin itself. - Construct the bridge at
enterTransitionSTART, never the scene constructor. Lenis intercepts wheel from the moment it exists but only moves the page when its raf is pumped — andtick()only runs once the scene is the manager's activeScene, after preload. A constructor-built bridge eats every wheel event for the whole preload window, then lurches when ticking starts. Native scroll covers input until enter. - Triggers born mid-range teleport. Creation-time
onUpdatefires with RAW progress — scrub smooths linked animations, not creation. If the user can be scrolled whensetupScrollTriggerruns (they scrolled during the intro), reset consumers to rest and ease to the live state with a one-shot catch-up tween. - Progress scrubs come from
createScrollProgress(ether/scroll), not hand-rolledScrollTrigger.create. Event-style triggers (class/attr toggles) stay inline — site-specific DOM hooks. - ScrollTriggers go through
setupScrollTrigger(), called from introonComplete, not from the constructor and not frompreload(). Triggers measure layout at construction time; create them after the intro has finished modifying layout. history.scrollRestoration = 'manual'lives inline in your layout's<head>. Do not move it to a module script. Do not remove it. Without it, mid-scroll reloads causeonUpdate(>0)to fire on first render and snap state mid-animation.- Every trigger you create has a paired
.kill()in dispose (and the bridge a.destroy()). Same commit. View transitions (transition:persist) ghost old triggers if you skip this. - Pull thresholds and scrub values from your constants module when reusable. Inline only with a one-line comment explaining the value (e.g. a
top 72%reveal threshold tuned to a section's margin).
Slop indicators (do not ship)
- A second
requestAnimationFrameloop driving scroll updates. lenis.start()called anywhere.ScrollTrigger.updatebound directly towindow.scrollevents.new Lenis(...)or a bare progress-scrubScrollTrigger.createin site code —ether/scrollowns those primitives.raf(time * 1000)at a call site — the bridge converts; double-scaling teleports the scroll.- ScrollTriggers created in module scope or in the scene constructor.
- Mutating three.js objects directly from
onUpdate(bypassestick()'s deltaTime-aware path). scrub: true— defeats the smoothing the bridge exists to provide.onTogglecollapsing enter + exit into one callback when the intent is asymmetric.- A ScrollTrigger created without a matching
.kill()indispose(). history.scrollRestorationset in a module script instead of inline-head.
Procedure for a new scroll-driven section
- Pick the pattern. Progress-scrub via
createScrollProgress(§4 in recipes), class-toggle on threshold (§5), or body data-flag (§6). Often you need two — scrub for 3D, class-toggle for CSS reveals. - Create the trigger inside
setupScrollTrigger()— not in the constructor. Store the instance on the scene (this.fooTrigger). - Write per-frame state in
onUpdate, read it fromtick(). Never mutate three.js objects fromonUpdate. - Add the
.kill()todispose()in the same commit. Always paired. - Test the LOW-tier fallback — Lenis null, native scroll. Scrub feel changes but values must still drive correctly.
After implementing, run premium-review.
After the skill
- Point Claude at
ether-threejsif the scroll-bound value is a shader uniform. - Point at
ether-shadersif the value drives material parameters. - Recipes in
recipes.mdshow the copy-paste skeletons.
Files
SKILL.md— this file (the script).recipes.md— ten numbered technique recipes.evals/triggers.json— should/shouldn't-trigger regression set for the description.