scroll-and-webgl
This is the one tier where motion is allowed to be the point. The rules are stricter, not looser, because the failure mode is a page that is beautiful on the author's laptop and unusable everywhere else.
Where it belongs
| Surface | Allowed |
|---|---|
| Marketing page, launch, portfolio, case study | Yes, with the budget below |
| Docs, blog, reading | A title reveal at most |
| Product UI, dashboards, tools, settings | No. Load the motion skill instead |
If a scroll effect is proposed for a product screen, the answer is no, and the reason is that people use product screens hundreds of times.
The budget
Set before starting, checked at the end:
- 60fps on a 4× throttled CPU for the whole scroll. One dropped frame per second is a fail.
- LCP under 2.5s with the effect present. The hero's text is in the HTML and visible before any script runs.
- INP under 200ms: scroll handlers never do layout reads; everything is on the compositor or in a
requestAnimationFramebatch. - Memory: one WebGL context per page. Textures under 2048px. Dispose on unmount.
- One spectacle per viewport. A hero shader or a pinned scroll scene or a split-text reveal at a time. Two at once is noise.
Scroll-driven, in order of cost
1. CSS scroll-driven animations
Zero JavaScript, runs on the compositor, respects reduced motion with one media query:
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@keyframes fade-up { from { opacity: 0; transform: translateY(24px); } }
Use for: reveal on enter, progress bars, parallax under 10%, sticky headers that shrink. Falls back to "just visible" in browsers without support, which is the correct fallback.
2. IntersectionObserver + a class
For reveal-once effects where the CSS timeline is not supported or a library's entrance is wanted. Observe, add the class, unobserve. Threshold 0.15–0.3; rootMargin: "0px 0px -10% 0px" so things reveal slightly before the bottom edge.
3. GSAP ScrollTrigger
For anything scrubbed, pinned, or sequenced:
scrub: true(or0.5–1for a little lag) ties the timeline to the scrollbar. Nothing else is smooth enough.pin: trueholds a section while a scene plays; keep pinned sections under 300vh of scroll or people get lost.anticipatePin: 1on pins to avoid a one-frame jump.- One
ScrollTriggerper scene, not per element; sequence elements inside a timeline. matchMediato remove pins and scrubs below 768px and under reduced motion.ScrollTrigger.refresh()after fonts load and after any layout change.
4. Lenis (smooth scroll)
Lenis makes scroll-linked animation feel continuous by interpolating wheel input. It is right for marketing pages and wrong for everything else:
- Off under reduced motion. Off on touch (native momentum is better). Off in any region with its own scrollbar (
data-lenis-prevent). - Duration 1.1–1.3,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)). - Drive GSAP's ticker from Lenis (
lenis.on("scroll", ScrollTrigger.update)) so the two agree. - Anchors go through
lenis.scrollTo(el, { offset: -header }), not nativescrollIntoView. - Modals and menus call
lenis.stop()while open.
Text reveals
Split into lines or words, never characters, unless the text is short (a single word logo). Characters give a typewriter feel that dates quickly.
- Lines:
translateY(100%)inside anoverflow: hiddenwrapper, 80ms stagger, 700ms ease-out-expo. The classic, still the best. - Words: opacity +
translateY(6px)+blur(6px→0), 30ms stagger. - Once. On first view. Then the text is text.
- The
aria-labelholds the full string on the parent; the split spans arearia-hidden. Without JS the text is plain and visible. - Re-split on resize; a split made at one width breaks lines wrong at another. GSAP SplitText handles this; a hand-rolled split must listen for it.
Canvas and WebGL
When
A shader background, a 3D object that responds to the pointer, a particle field, a product model. If it can be done with CSS gradients and a noise SVG, do that.
How
- Device pixel ratio capped at 2, 1.5 on large canvases. Retina at 3× is four times the pixels for no visible gain.
- Pause off-screen with IntersectionObserver and when
document.hidden. A hidden canvas still burning the GPU is the most common cause of a hot laptop. - Resize with
ResizeObserver, debounced, resetting the drawing buffer once, not per frame. - One animation loop for the page. Multiple
requestAnimationFrameloops fight. - Lazy import the library and the scene. The hero's text renders first; the canvas fades in at 400ms when ready. A blank hero waiting for three.js is a fail.
- Dispose geometries, materials, textures and the renderer on unmount. Memory leaks across route changes are routine in SPAs.
- Pointer response is eased: lerp the target by 0.08–0.12 per frame. Direct 1:1 tracking feels mechanical.
- Fallback: a static image or a CSS gradient of the same colour and weight, used when WebGL is unavailable, under reduced motion, and on low-end devices (check
navigator.hardwareConcurrency < 4anddeviceMemory < 4as a rough gate).
Shaders as material
A fragment shader for a background is cheaper than a 3D scene and often better: a slow gradient noise, a dithered glow, a grain. Keep it under 30 lines, animate with time and a pointer uniform, and keep contrast low so text on top passes. Dither (ordered or blue-noise) hides banding in dark gradients and adds material.
3D
Three.js or React Three Fiber. Models as compressed glTF (Draco or Meshopt), under 2MB, with a <Suspense> fallback that matches the final silhouette. Environment lighting from a small HDRI. Shadows off unless they are the point. Post-processing is one pass, or none.
Loading strategy
- HTML with all text, above-the-fold image with dimensions. Visible at first paint.
- Fonts (two weights, preloaded).
- Layout stabilised. LCP recorded here.
- Scroll library and observers.
- Canvas, WebGL, models: lazy, after idle, fading in.
If step 5 fails or is skipped, the page is complete anyway. That is the test.
Reduced motion and no JS
Under prefers-reduced-motion: reduce:
- No Lenis. No pins. No scrubs. No canvas motion (a static frame is fine).
- Reveals are not run; content is visible.
- Video does not autoplay.
Without JavaScript:
- Everything hidden for a reveal is visible (
html:not(.js) [data-reveal] { opacity: 1 }). - The page reads top to bottom with no gaps where scenes would be.
Marks of a page that will be turned off
| Sign | Fix |
|---|---|
| Scroll hijacked; the wheel feels wrong | Lenis only on marketing, off on touch, or none |
| Pinned section longer than 300vh | Cut the scene; pin less |
| Text that only exists after a script splits it | Text in HTML; split enhances |
| Canvas running while off-screen | IntersectionObserver pause |
| DPR 3 on a full-screen canvas | Cap at 2 |
| Hero blank until three.js loads | Text first; canvas fades in |
| Parallax at 30% | Under 10%, or none |
| Character-by-character reveal on a paragraph | Lines |
| Effects on a dashboard | Delete them |
From the catalogue
- GSAP and ScrollTrigger, SplitText — the timeline tools these recipes assume.
- Lenis — smooth scroll, with the caveats above.
- Three.js, React Three Fiber — 3D on the web.
- Codrops — experimental tutorials for scroll, WebGL and transitions.
- Maxime Heckel — shaders and WebGL explained by someone who ships them.
- Godly, Awwwards — study what the effect is for before copying it.
- More at stealth.pm/build.