DEV Community

Isaiah Kim
Isaiah Kim

Posted on

Seek-safe animation: one paused timeline, scrubbed

I've been building HyperFrames, the animation engine behind the product demo videos I make at Kynth. It renders video from HTML: a composition is an HTML file whose DOM declares its own timing with data-* attributes, and the renderer turns that into frames. The same pipeline cuts the walkthroughs for FetchDue, CertScope and DoseTrace, alongside the Playwright harness that drives a real browser through each product and the edge-tts narration track.

The whole design collapses into one requirement: same input time → same pixels. Everything below is what that requirement cost.

Why does a render pipeline need a paused timeline instead of playback?

Because there is no playback. The renderer doesn't watch an animation run and grab screenshots — it takes a time value, seeks the page to it, and reads a pixel buffer. Frames can be sampled out of order, and they can be sampled in parallel across workers. Nothing is allowed to depend on having arrived at this frame through the previous one.

That kills a whole category of normal web animation. requestAnimationFrame loops accumulate state. setTimeout chains assume wall-clock progression. Scroll and hover listeners never fire, because the renderer has no input events. If a visual only looks right after the browser has been sitting there for two seconds, it will never look right in a render.

So every composition registers exactly one paused GSAP timeline, built synchronously at page load, keyed by the composition's own id:

// index.html — built at load, never played
const tl = gsap.timeline({ paused: true });

tl.from("#headline", { y: 24, opacity: 0, duration: 0.6, ease: "power2.out" })
  .from("#rows .row", { y: 12, opacity: 0, duration: 0.4, stagger: 0.08 }, "-=0.2");

window.__timelines["invoice-scene"] = tl;
Enter fullscreen mode Exit fullscreen mode

The key has to match data-composition-id on the root element exactly. No -mount or -host suffix, which I know because I spent real time staring at a blank frame that previewed perfectly.

The rule that makes the rest fall out

Build the timeline synchronously. Not inside async, not in a Promise.then, not in a setTimeout. The renderer can seek before an async builder finishes, and when it does you get a frame of the static HTML with none of the motion applied — and no error, because nothing failed. It's just early.

Once that rule holds, seeking is a one-liner (tl.seek(t)), and the interesting work moves to everything that isn't the timeline.

Seek-safe animation: one paused timeline, scrubbed — code

What actually breaks determinism in practice?

Clocks and randomness are the obvious ones, and they're the easy ones to lint. Date.now(), performance.now(), unseeded Math.random() — banned outright for anything that affects visual state. If you want random-looking placement, seed a PRNG so the scatter is identical on every run.

The subtler ones:

Infinite loops. repeat: -1 has no defined state at time t in a parallel sampler. Compute a finite count from the clip's duration instead:

const repeats = Math.max(0, Math.floor(duration / cycleDuration) - 1);
Enter fullscreen mode Exit fullscreen mode

floor, not ceil. ceil overshoots the declared duration, and Math.max(0, …) stops a short clip from producing -1 — which is infinite again, by the back door.

Two timelines touching the same property. GSAP's overwrite behavior is order-dependent. If two tweens both animate opacity on the same element in the same window, which one wins depends on evaluation order, and that can flip between renders. This one is nasty because it usually looks fine and then produces one wrong frame in the middle of a delivered video.

Never measure the DOM at tween time

getBoundingClientRect() inside a tween callback is the desync I hit most while building scene templates. It reads fine in preview, where frames arrive in order. Under a parallel renderer, the element you're measuring may be in a different state than you assumed, so the number you get back depends on sampling order.

The fix is boring: compute layout constants once at composition setup and reuse them. Same for text — for anything dynamic, measure through the framework's own text helpers (fitTextFontSize, or the pretext measurement layer under it) rather than reflowing the DOM per frame.

There's an allowlist for what can be animated at all: opacity, x, y, scale, rotation, color, backgroundColor, borderRadius, transforms. Never display, never raw visibility, and never width/height/top/left for layout changes. Transforms compose predictably at an arbitrary time; layout properties trigger reflow and take their neighbors with them.

How do you seek a runtime that isn't GSAP?

You make each runtime publish something the engine can address by time, and you give the engine one adapter per runtime. GSAP covers most motion work, but a composition can mix runtimes, and each one seeks differently:

  • Lottie — every player registers itself on window.__hfLottie, and the adapter calls goToAndStop(timeMs, false) on lottie-web instances, or the frame/percentage API on dotLottie.
  • Web Animations API — the adapter walks document.getAnimations(), sets each currentTime to HyperFrames time in milliseconds, then pauses it. Author with fill: "both" so seeked states persist past their active window.
  • CSS keyframes — the adapter finds elements with a computed animation-name, seeks their Animation handles when the browser exposes them, and otherwise falls back to pausing with a negative animation-delay. Use animation-fill-mode: both.
  • Three.js — the adapter deliberately does not own your scene. It sets window.__hfThreeTime and dispatches hf-seek, and your composition renders that exact time:
window.addEventListener("hf-seek", (event) => {
  const t = event.detail.time;
  mixer.setTime(t);           // authored clips
  camera.position.z = 8 - t;  // pure function of time
  renderer.render(scene, camera);
});
Enter fullscreen mode Exit fullscreen mode

The shape is the same every time: time in, frame out, no memory of the previous call.

Duration turned out to be a separate problem from seeking

This surprised me. The render engine needs a positive total duration before it captures a single frame, or capture fails outright. A GSAP timeline reports its own length, so that case is free. The others aren't.

CSS duration is inferred from the longest animation-delay + duration × finite iteration count. WAAPI comes from the longest effect's getComputedTiming().endTime. Lottie reads the asset's native length, which is finite even when loop: true. And Three.js is not inferable at all — the adapter only forwards time, it doesn't inspect your scene for an AnimationClip. So a Three.js composition must declare data-duration on its root, and the linter errors when it doesn't.

Two capabilities that felt like one: where am I on the timeline and how long is the timeline. Every adapter answers the first. Only some can answer the second.

How do you catch a desync before it reaches a delivered video?

Sample the frames a human would never scrub to. Three passes, in order of cost:

npx hyperframes check                      # lint, runtime, layout, motion, contrast
npx hyperframes snapshot --at 2.4,6.1,9.8  # eyeball the seams, not the beats
npx hyperframes render                     # only after the frames look right
Enter fullscreen mode Exit fullscreen mode

check catches the mechanical violations — a missing duration source, a repeat that overshoots, a set on a clip from a later scene. Snapshots catch the silent class: a transformed element that's still inline and therefore invisible, a full-screen background painted on the root instead of a full-bleed child (which previews correctly and renders black), a pulsing decorative that clears its neighbor at rest and collides at peak scale.

There's also an audit script that walks every registered timeline, enumerates the tweens, samples bounding boxes and writes an animation map — useful for finding dead zones and inconsistent stagger after the fact.

Almost every bug that survived to a delivered frame had the same signature: preview looked right, render didn't. Preview plays forward, in order, in one process. The renderer doesn't do any of those three things. Anything you validate only by pressing play, you haven't validated.

Top comments (0)