Source: First-party build (2026-07-09). Files: weo-motion.css + weo-motion.js (web) and weo-motion-gsap.js (video) under Brand/web/motion/; design spec docs/superpowers/specs/2026-07-09-weo-motion-library.md; render proof Brand/web/motion/lab/. Origin: a live deck “motion menu” catalog with every effect playing.

A brand motion library built to serve two very different runtimes from one visual vocabulary: a live website / slide deck (where animation is fire-and-forget) and a frame-seek video renderer (where the same effect must render deterministically to MP4). The interesting part isn’t the 38 effects — it’s the discovery that a motion library meant for both web and video has to be implemented twice, because the browser primitive that makes web animation trivial (CSS animation) is exactly the one a headless video renderer cannot use. This article documents that fork as a reusable pattern; the concrete library is the worked example. It sits directly on top of the HeyGen HyperFrames rendering model — see HyperFrames GSAP Animation for the paused-timeline contract and HyperFrames Core Concepts for why the renderer seeks.

Key Takeaways

  • CSS animation is wall-clock; it does not frame-seek. A live page plays CSS keyframes against the real clock, which is perfect. A headless renderer captures video by seeking a timeline to arbitrary timestamps (frame 0, frame 42, frame 137…) and screenshotting each — and CSS animations render frozen or wrong under that seek. So a vocabulary that must work in both places needs two implementations: CSS keyframes for the web, and a paused GSAP timeline the renderer can seek for video.
  • Same menu, two files. weo-motion.css implements the 38-effect vocabulary as portable @keyframes behind a neutral data-anim="<slug>" trigger API; weo-motion-gsap.js implements the same menu as 36 deterministic GSAP factory functions, each returning a gsap.timeline() you .add() into a comp’s paused master. (weo-motion.js is a small dependency-free helper that wires the common web triggers.)
  • Count-up / odometer are the proof case. A requestAnimationFrame counter cannot be frame-seeked — seek to t=3s and you get whatever number the rAF loop happened to reach, not the correct one. A GSAP proxy tween (gsap.to({v:0}, {v:target, onUpdate})) makes the number a pure function of timeline position, so every seeked frame is correct. This is what finally makes animated numbers renderable to MP4.
  • Infinite ambient loops crash the renderer. A repeat:-1 GSAP loop makes the master timeline’s duration Infinity; the HyperFrames renderer aborts with Set maximum size exceeded. Ambient loops must be finite for video — size the repeat count to the composition duration. Infinite looping is a web-only affordance.
  • Verified by rendering, not by inspection. The library ships with a lab/ composition that adds every factory to one paused master and renders to MP4 (1920×1080, 7 s, 24 fps, 168 frames). Sampling frames at multiple timestamps shows distinct animation progress at each seek — the actual test of seekability, which no amount of reading the code proves.

The core problem: why one vocabulary needs two implementations

Most “make it move” work on the web reaches for CSS:

@keyframes rise { from { opacity:0; transform:translateY(24px) } to { opacity:1; transform:none } }
.headline { animation: rise .6s ease both; }

The browser plays this against the wall clock the moment the element is live. Fire-and-forget, GPU-composited, zero dependencies. For a website or a presented slide deck it is the correct tool.

A video renderer works on a completely different principle. It does not “play” anything in real time. It advances a virtual frame clock, and for each frame it seeks the document to that timestamp, waits for paint, captures the frame, and encodes it (the HyperFrames pipeline: frame clock → seek → BeginFrame capture → encode — see Core Concepts). Seeking is arbitrary and non-linear: the renderer may capture frame 0, then frame 137, then re-capture frame 42 on a retry. A CSS animation has no concept of “seek to t=1.75s and show me exactly that state.” Under a headless seek it renders frozen at its start state, or drifts, or shows whatever the compositor last painted. The primitive that makes web animation effortless is the one thing the renderer cannot drive.

GSAP timelines, by contrast, are seekable state machines. A gsap.timeline({ paused: true }) never plays itself — you (or the renderer) call timeline.seek(t) and every tween resolves to its exact value at time t, deterministically. That is precisely the contract HyperFrames requires: build a paused timeline, register it on window.__timelines, and hand playback to the framework (GSAP Animation).

So the fork is unavoidable. To ship one motion vocabulary across web and video, you implement it twice against two runtimes:

FileRuntimeMechanismPlayback
weo-motion.css (+ weo-motion.js)Web — website, Claude Design decksCSS @keyframes + data-anim trigger APIWall-clock, fire-and-forget
weo-motion-gsap.jsVideo — HyperFrames → MP4Deterministic GSAP factory timelinesPaused; framework seeks frame-by-frame

The two files cover the same 38-effect menu (Entrances · Emphasis · Data · Ambient · Transitions). They are not byte-for-byte parallel — the GSAP side exposes 36 factories because a couple of visually-distinct effects share one parameterized function (e.g. left/right slides are one slideIn({dir}); every stroke-reveal — bracket, checkmark, ring, donut — runs through one drawStroke), and the four A→B transitions are inherently two-layer and video-only. The point is parity of the vocabulary an author thinks in, not symmetry of the implementation.

Web side — CSS keyframes behind a neutral trigger

The CSS file avoids coupling to any particular framework or scroll library by firing on a neutral trigger: an element carries data-anim="<slug>", and its animation fires when any ancestor becomes “live.” Two ancestor signals are accepted:

  • .wm-live — added by the helper on scroll-into-view, or by you on page load
  • [data-deck-active] — stamped by Claude Design on the currently-presented slide
:is(.wm-live,[data-deck-active]) [data-anim="fade-rise"]{ animation:wm-rise var(--wm-dur) var(--wm-ease) both }

Three details make it robust and reusable:

  • JS-gated base states. Hidden starting states (opacity:0, clipped, translated) are scoped under a .wm-js class that the helper adds to <html> immediately. With no JavaScript, the class never appears, the hidden base states never apply, and content renders fully visible — the animation degrades to “already there” rather than “invisible forever.” This is the correct default for a marketing site.
  • Stagger via a CSS variable. Siblings set an inline --wm-i (0,1,2,…) and the shared rule computes animation-delay: calc(var(--wm-i) * --wm-stagger). No per-element CSS.
  • Reduced-motion is a hard settle. Under prefers-reduced-motion: reduce, every [data-anim] drops its animation and resolves to its final visible state (opacity:1, scaleX(1), stroke-dashoffset:0, odometer parked on its target digit). Accessibility isn’t an afterthought bolted on; it’s the same end-state the animation was heading toward.

The weo-motion.js helper is small and dependency-free: it sets html.wm-js, wires an IntersectionObserver that adds .wm-live when a data-wm section scrolls in (data-wm="repeat" re-fires on every re-entry), and drives the two numeric effects (data-countup, data-odometer). It is optional — the CSS works with any code that puts .wm-live on an ancestor.

Video side — deterministic GSAP factories

The video file is a factory library. Each effect is a function (target, opts) => gsap.timeline() that you compose into a comp’s single paused master:

var master = gsap.timeline({ paused: true });
master.add(WEOMotion.fadeRise('.hero'), 0);
master.add(WEOMotion.countUp('#stat', 4657), 0.5);   // proxy tween → seekable
master.add(WEOMotion.meshDrift('.bg', { duration: 8 }), 0); // finite ambient loop
window.__timelines['scene'] = master;

Two constraints are load-bearing:

  • Determinism is mandatory. No Date.now(), no unseeded Math.random() — a seek renderer must produce identical output for the same frame every time. Any per-element variation is index-derived (particle phase is i / count, stagger is i * step), never sampled from a clock or RNG. This is the same determinism rule the HyperFrames runtime enforces globally (Core Concepts).
  • The timeline’s duration is the composition’s duration. Because factories build one master and the framework reads its length, the last tween’s end time is the video length. The lab comp ends with tl.to({}, { duration: 0.01 }, 6.99) — a zero-effect hold that pins the master to the full 7 s so nothing gets truncated. (A too-short master silently cutting the video is the single most common HyperFrames mistake — see Common Mistakes.)

The proof case: frame-seekable numbers

Animated counters are where the CSS-vs-GSAP distinction stops being abstract. The naive web implementation is a requestAnimationFrame loop:

function loop(now){ const p = (now - t0) / dur; el.textContent = fmt(target * easeOut(p)); if (p < 1) requestAnimationFrame(loop); }

This is time-of-wall-clock driven. It works beautifully on a live page and is exactly what weo-motion.js does for the web. But it is fundamentally unseekable: the displayed number is a side effect of however many rAF callbacks have fired, not a function of a timeline position. Ask a seek renderer for the frame at t=3s and the counter shows an arbitrary value — whatever the loop reached before capture, which on a headless render is often 0 (the loop barely started) or the final value (it ran ahead). Animated numbers simply could not be rendered to video this way.

The GSAP factory fixes it with a proxy tween:

countUp: function (t, target, o) {
  var el = $(t)[0], proxy = { v: o.from || 0 };
  function render(){ el.textContent = fmt(proxy.v); }          // reads proxy, writes DOM
  return tl().to(proxy, { v: target, duration: o.dur || 1.15, ease: "power2.out", onUpdate: render });
}

GSAP tweens a plain object’s .v from 0 to the target; onUpdate formats and writes it to the DOM. Now the number is a pure function of timeline positionseek(1.75) sets proxy.v to exactly its eased value at 1.75s and repaints. Every seeked frame is correct, retries are idempotent, and the counter renders to MP4 like any other property. The odometer variant applies the same idea to a mechanical digit reel: each column is a stack of digits whose yPercent is tweened to land on the target digit, so scrubbing the timeline rolls the reels deterministically. This closed a real, known gap — the reason earlier pipelines burned counters in as pre-rendered PNG sequences instead of animating them live in the comp.

The gotcha: ambient loops must be finite for video

Ambient effects (drifting glow, floating particles, a parallax grid, an aurora wash) loop forever on a website. The obvious GSAP translation is repeat: -1. Do not do this in a comp destined for a seek renderer. An infinite child makes the master timeline’s .duration() evaluate to Infinity, and the renderer — which allocates per-frame work against that duration — aborts with Set maximum size exceeded.

The library’s fix is a small helper that converts a desired period into a finite repeat count sized to the composition:

function loops(o, leg){ return Math.max(1, Math.ceil((o.duration || 30) / leg)); }
// meshDrift over an 8s comp: repeat enough half-cycles to fill 8s, then stop.
meshDrift: function (t, o){ return tl({ repeat: loops(o, (o.period||26)/2), yoyo:true }).to($(t), {...}); }

Callers pass opts.duration (the comp length) and get a loop that visually reads as continuous but has a real, finite end. The genuinely-infinite version lives only in the CSS file, where wall-clock playback makes infinite free and correct. This is the general shape of the lesson: effects that depend on “forever” have to be re-expressed as “long enough” the moment they cross into a seek renderer.

Verification: render and seek, don’t inspect

Seekability is not visible in source — a factory can look perfectly correct and still be unseekable (the rAF counter is the cautionary example). The only real test is to render and scrub. The library ships a lab/ HyperFrames composition that adds all 36 factories to one paused master and renders to MP4:

  • Output: 1920×1080, 7.0 s, 24 fps, 168 frames, H.264.
  • Test: sample frames at several timestamps and confirm each effect shows distinct progress — the count-up at a different number, bars at different heights, the line chart drawn further, the before/after wipe at a different position. Frozen or identical frames across seeks = not seekable = fail.
  • Result: distinct progress at every sampled seek; hyperframes lint + validate pass.

One caveat inherited from the render pipeline: dark radial-glow gradients on navy band under 8-bit H.264, so the final render gets a mandatory deband + grain post-pass (raising bitrate alone does not fix it). That is a rendering-stack rule, orthogonal to the motion library, but it applies to anything this library renders.

Why this generalizes

Nothing here is specific to one brand’s palette or to HeyGen. The transferable lessons:

  1. A motion system that targets both live web and rendered video is two codebases sharing one vocabulary — plan for that from the start. The temptation is to write CSS once and assume a renderer will “just capture it.” It won’t, if the renderer seeks.
  2. “Seekable” is the design constraint for any headless video renderer, not just HyperFrames — Remotion (Remotion Motion Graphics), Puppeteer-screenshot pipelines, and Lottie-to-video all share the property that the frame is a function of a timestamp. Any animation whose state depends on wall-clock elapsed time (rAF loops, CSS animation, setInterval) breaks; any animation expressed as a pure function of a scrubbable clock (a paused GSAP timeline, keyframe interpolation) works.
  3. Theme via tokens, not hard-coded values. The library exposes its palette and timing as CSS custom properties (--wm-*) so a consumer overrides brand without forking the effects. The brand-specific values live in the private brand vault, not here — the mechanism (overridable tokens) is the reusable part.
  4. Verify by rendering. For any effect claimed to be renderable, the proof is a rendered MP4 scrubbed at multiple seeks — reading the code is not evidence. This mirrors the wider “the edit is text / verify the artifact” discipline in The Edit Is Text.

This is one more instance of the HTML-is-the-canvas thesis — the same authoring medium (HTML + CSS + a JS animation lib) feeds a live page and a rendered video — with the sharp footnote that the animation layer does not port for free across those two media.

Try It

  1. Feel the difference in a scratch page. Drop a CSS @keyframes counter and a GSAP proxy-tween counter side by side, then open both under a headless screenshot tool and capture at a fixed non-zero timestamp. The CSS/rAF one shows 0 or the final value; the GSAP one shows the correct interpolated value. That single experiment is the whole article.
  2. Build the minimal seekable comp. One paused gsap.timeline({paused:true}), one WEOMotion.countUp(el, 1000) added at t=0.5, registered on window.__timelines. Render with hyperframes render, extract frames at t=0.6/1.0/1.5, confirm three different numbers.
  3. Break it on purpose. Add an ambient loop with repeat:-1 and re-render — watch the renderer die with Set maximum size exceeded. Then swap to a finite loops(opts, leg) count and watch it succeed. Internalize that infinite is web-only.
  4. Audit your own motion for wall-clock dependencies. Grep an existing web animation layer for requestAnimationFrame, setInterval, Date.now, Math.random, and CSS animation: — each is a thing that will not survive a seek renderer and needs a timeline-based twin before it can render to video.