Keep the personality of an effect (timing, direction, restraint), rebuild it at the lowest tier that carries it. Tiers and budgets: motion §3. Recipes: motion/references/recipes.md (§ numbers below). Library-shaped effects: the components skill.
1. Mapping table
| Source effect (typical stack) | Cost in the source | void equivalent | Tier / JS | Where |
|---|---|---|---|---|
| Preloader / intro video before content (hellohello ~8s) | time-to-content, LCP | delete; hero renders immediately, one ≤ 600ms lockup settle | T1 / 0 | recipes §10 |
| Char-by-char hero reveal (GSAP SplitText, rAF) | 45–70 KB, hidden LCP | H1 void-settle (translate, no fade) + staggered sub/CTA void-rise |
T1 / 0 | recipes §10 |
Masked line reveal on scroll (ScrollTrigger + translate3d(0,N%,0) in overflow-hidden) |
GSAP + ScrollTrigger ~70 KB, main-thread scroll | authored lines + CSS view-timeline masked rise, one statement section |
T1 / 0 | recipes §10 |
Per-word reveal (SplitText, Framer staggerChildren) |
per-node motion components | server-split words + @starting-style / view() stagger |
T1 / 0 | components → RevealText |
Section fade-up on scroll (framer whileInView, AOS) |
+32–46 KB, JS per section | reveal utility on 1–3 key visuals |
T1 / 0 | craft / base.css |
| Scroll-scrubbed grey→white word fill | ScrollTrigger scrub | per-word color animation on a named view timeline (§2), one paragraph; or drop |
T1-paint / 0 | below |
Pinned full-viewport scenes (ScrollTrigger pin) |
scroll hijack feel, layout thrash | one sticky track (h-[200svh] wrapper, sticky top-0 child) + CSS view-timeline for progress; or normal flow |
T1 / 0 | below |
| Parallax image stacks (ScrollTrigger / Locomotive) | main-thread transforms | animation-timeline: view() translate ≤ 6% on one image per viewport |
T1 / 0 | speed/references/rendering-smoothness.md §3 |
| Smooth scroll (Lenis, Locomotive, ScrollSmoother) | ~10 KB + permanent rAF, scroll on main thread | native scroll; scroll-behavior: smooth for anchors only (base) |
— | motion §8 |
| WebGL CRT / film / dither shader (OGL, three) | 10–160 KB + GPU, context limits | CSS scanlines + grain + inset vignette over a static AVIF; or one canvas under all rules |
T0 / 0 (or T4 budgeted) | motion/references/webgl-canvas.md §7 |
| Shader gradient / aurora / mesh (three, ogl, Stripe-like ribbon) | 140 KB+ | 2–3 blurred radial layers drifting via translate 20–40s, paused offscreen |
T1 / 0 | components → AuroraBackdrop |
| Particle / star / flow field (tsParticles, canvas) | perpetual rAF | static SVG dot field + one slow layer translate; or useCanvas2D ≤ 30fps |
T0–T1 / 0, or T3 | webgl-canvas.md §4 |
| 3D globe (three) | 140 KB+ | static SVG/AVIF; cobe (5 KB) only if it must rotate |
T0 / T4 | components |
| Lottie illustrations | lottie-web ~60 KB + JSON | SVG with CSS keyframes on 1–3 paths, or an AVIF/short muted video with poster | T1 / 0 | — |
| Background video hero (Mux/HLS) | 2–7 MB | AVIF poster; started in view, pausable |
T0 / 0 | speed |
| Custom cursor / cursor follower | rAF + hidden native cursor | drop; or spotlight gradient following pointer on one card grid, pointer-fine only |
T2 / ~0.5 KB | components → SpotlightCard |
| Magnetic buttons | window listeners + setState | drop (hurts precision); or element-scoped translate ≤ 4px | T2 / ~0.5 KB | components → Magnetic |
| Infinite logo marquee (JS slider) | perpetual JS loop, duplicate a11y | CSS two-track marquee, inert duplicate, pause offscreen and on hover/focus |
T1 / 0 | components → Marquee |
| Animated counters (CountUp, springs) | SSR "0", replay on every view | SSR the real value; animate once if it starts offscreen; or @number-flow/react |
T2 / ~0.6 KB | components → NumberTicker |
| Border beam / animated gradient border | infinite JS | rotating conic layer with @property, one element |
T1 / 0 | components → BeamBorder |
| Shiny / shimmer text | perpetual JS | CSS background-position keyframes on one short label, paused offscreen; never on headlines |
T1-paint / 0 | components |
Page transitions (Barba, GSAP Flip, framer AnimatePresence routes) |
JS orchestration | React + CSS ≤ 400ms |
T1 / 0 | recipes §11 |
Shared-element morph (Framer layoutId, GSAP Flip) |
+46 KB | for route changes; LazyMotion layoutId only in-page when essential |
T1 / T2 | recipes §11, motion §7 |
| Hover-play project videos | many videos preloading | poster images; preload="none"; play on hover/focus under pointer-fine, one at a time |
T1 / small | — |
| Typewriter headline | JS timers, CLS risk | static headline; type a terminal log instead (fixed box, CSS steps()) |
T1 / 0 | craft/references/directions.md terminal |
2. Recipes not covered elsewhere
Scrubbed word fill (one paragraph; Chromium/Safari 26; static full color elsewhere):
const words = statement.split(" ");
<p className="void-fill text-display-sm">
{words.map((w, i) => (
<span key={i} className="void-fill-word" style={{ "--i": i / words.length } as React.CSSProperties}>{w} </span>
))}
</p>@layer components {
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.void-fill { view-timeline-name: --void-fill; }
.void-fill-word {
color: var(--fg-subtle);
animation: void-fill linear both;
animation-timeline: --void-fill;
animation-range: cover calc(25% + var(--i) * 30%) cover calc(30% + var(--i) * 30%);
}
}
}
}
@keyframes void-fill { to { color: var(--fg); } }Color animation paints; keep it to one paragraph of ≤ 30 words. The text is in HTML either way.
Single sticky scene instead of a pinned timeline:
<section className="void-scene relative h-[200svh]" aria-labelledby="scene-title">
<div className="sticky top-0 flex min-h-svh items-center">
<Container className="grid gap-10 lg:grid-cols-12">
<h2 id="scene-title" className="text-display text-fg lg:col-span-5">…</h2>
<div className="void-scene-media lg:col-span-7">{/* real visual */}</div>
</Container>
</div>
</section>@layer components {
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.void-scene { view-timeline-name: --void-scene; }
.void-scene-media { animation: void-scene linear both; animation-timeline: --void-scene; animation-range: contain 0% contain 100%; }
}
}
@media (prefers-reduced-motion: reduce) { .void-scene { height: auto; } }
}
@keyframes void-scene { from { scale: 0.92; opacity: 0.4; } to { scale: 1; opacity: 1; } }One per page. Firefox shows the final state in a tall sticky section; if that looks empty, drop h-[200svh] inside @supports not (animation-timeline: view()).
CRT look without a shader (terminal/agency hero):
<div className="relative overflow-clip rounded-xl bg-bg-subtle">
<Image src={still} alt="…" className="h-auto w-full [filter:contrast(1.1)_saturate(0.8)]" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(to_bottom,transparent_0_2px,color-mix(in_oklch,var(--bg)_55%,transparent)_2px_3px)] opacity-50" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 shadow-[inset_0_0_120px_var(--bg)]" />
</div>Add grain on the wrapper for noise. A slow roll bar (translate keyframes 7s linear infinite, data-fx/PauseOffscreen, none under reduced motion) is the only motion allowed here.
3. Deciding what to drop entirely
Drop when any is true: it's the source brand's most recognizable signature; it hides content or delays the fold; it hijacks scroll or the cursor; it needs > 1 WebGL context; it has no reduced-motion story and can't get one; it would be the second T2+ effect on the page. Replace with a stronger static idea (typography, a real product surface, a better photo) before adding any other motion.