Skip to content
Kinetic
ScrollDOM4 files

Single Param Object Timeline

A scroll-driven scene written as data instead of as tweens. One mutable object holds every animated quantity, GSAP tweens the object rather than the DOM, and a single onUpdate writes one transform string and two opacities. The story is then an array of rows — section id, point through the section, params to move to — each anchored to where its section actually sits after layout.

gsapscrolltriggerscrubkeyframesarchitecturetimeline

Live preview

The real component, running in this page.

02

Install

Copies the source into your project. Nothing is imported from us at runtime.

$npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/keyframe-param-object.json
pulls ingsap@^3.15.0
03

Prompt

The whole technique, written for an agent.

Build it with your agent

A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Single Param Object Timeline

A scroll-driven scene written as data instead of as tweens. One mutable object holds every animated quantity, GSAP tweens the object rather than the DOM, and a single onUpdate writes one transform string and two opacities. The story is then an array of rows — section id, point through the section, params to move to — each anchored to where its section actually sits after layout.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

One plain object holds every animated value; the scroll story is a table of keyframes anchored to real sections.

- Category: scroll
- Runtime cost: dom
- Dependencies: gsap@^3.15.0

## How it works

1. Every animated quantity lives in one object: `{ scale, spin, driftX, driftY, glow, dissolve }`. No element is a tween target; GSAP tweens the plain object and one `onUpdate` renders it, so the number of tweens is the number of story beats rather than the number of moving parts.
2. Each keyframe is anchored to a real section: `anchor = clamp(0, 1, (el.offsetTop + el.offsetHeight * t - view / 2) / total)`, where `total = container.offsetHeight - view` is the scroll distance the trigger covers.
3. That expression reads as "when the point `t` through this section reaches the middle of the viewport". `t = 0.5` is the section's own middle, so the beat fires when the reader is looking at the copy it belongs to, at any column width.
4. Rows are sorted by anchor and each is given `duration = nextAnchor - thisAnchor`, so the scene is continuously in motion between beats instead of arriving early and sitting still until the next anchor.
5. `ease: "none"` on every row. Under a scrub the ease warps timeline time, not screen time, so an eased row reaches its params before or after the section it is anchored to — which defeats the anchoring.
6. A `useResizeObserver` on the container re-runs the whole build when its height changes, because every anchor is a function of `offsetTop` and `offsetTop` moves whenever a line wraps.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `scrub = 0.8`
  Just under a second of catch-up. Enough that the scene continues to resolve after the wheel stops, which is what makes it read as a film rather than a slider; `true` welds it to the wheel and every scroll jitter shows up in the mark.
- `at = 0.5`
  Default point through a section at which its keyframe lands. 0.5 puts the beat on the section's midpoint crossing the middle of the scroller. Values near 0 fire while the section is still entering, which reads as the scene reacting to copy the reader has not got to yet.
- `REST.glow = 0.35`
  The scene's idle state, not zero. The stage has to be worth looking at before the first keyframe and after the last one; starting at 0 means the reader's first impression of the component is an empty box.
- `total floor = 1`
  Guards `container.offsetHeight - view` against 0 or negative, which happens for one frame during layout and on a short container. Without it every anchor divides by zero, becomes Infinity, clamps to 1, and the whole story collapses onto the last frame.
- `span floor = 0.001`
  Two keyframes anchored to the same point would otherwise get duration 0. GSAP accepts it, but the row then applies as an instant jump, so a carefully anchored beat becomes a snap.
- `section gap = 45vh`
  Spacing between story sections, in scroller heights. Below about 30vh two anchors land close enough that their spans overlap heavily and the beats blur together; well above it the scene is static for long stretches.

## API

```ts
function KeyframeParamObject(props: { sections: { id: string; label: string; title: string; body?: string; at?: number; to: Partial<StageParams> }[]; scrub?: number; scrollerRef?: RefObject<HTMLElement | null>; className?: string }): JSX.Element
```

- `sections: KeyframeSection[]`
  The story. Each row names a section, an optional point through it, and the params to move to.
- `sections[].at: number` — default `0.5`
  Point through the section, 0-1, at which its keyframe lands.
- `sections[].to: Partial<StageParams>`
  Params this beat moves to. Anything omitted holds its current value.
- `scrub: number` — default `0.8`
  Seconds of catch-up behind the scroll position.
- `scrollerRef: RefObject<HTMLElement | null>`
  The scroll container that owns the scroll position. Omit for the document.
- `className: string`
  Merged onto the container via `cn`.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
export function KeyframeParamObject({
  sections,
  scrub = 0.8,
  scrollerRef,
  className,
}: KeyframeParamObjectProps) {
  const root = useRef<HTMLDivElement>(null);
  const mark = useRef<HTMLDivElement>(null);
  const halo = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  // Rebuild the anchor table whenever the column reflows: every anchor is a
  // function of offsetTop, and offsetTop changes when a line wraps.
  const { height } = useResizeObserver(root);

  useGsap(
    gsap,
    () => {
      const container = root.current;
      const markEl = mark.current;
      const haloEl = halo.current;
      if (!container || !markEl || !haloEl) return;

      // The one mutable object. Cloned per build so a rebuild starts clean
      // rather than inheriting wherever the last scrub left it.
      const params: StageParams = { ...REST };

      const render = () => {
        markEl.style.transform =
          `translate3d(${params.driftX}%, ${params.driftY}%, 0) ` +
          `rotate(${params.spin * 360}deg) scale(${params.scale})`;
        markEl.style.opacity = String(Math.max(0, 1 - params.dissolve));
        haloEl.style.opacity = String(params.glow);
        haloEl.style.transform = `scale(${1 + params.glow * 0.6})`;
      };

      if (reduced) {
        // Land on the last keyframe: the end of the story, statically.
        for (const section of sections) Object.assign(params, section.to);
        render();
        return;
      }

      const scroller = scrollerRef?.current ?? null;
      const view = scroller ? scroller.clientHeight : window.innerHeight;
      // Scroll distance the trigger actually covers. Guarded against 0, which
      // would make every anchor Infinity and stack the whole story on frame 1.
      const total = Math.max(1, container.offsetHeight - view);

      const anchor = (el: HTMLElement, t: number) =>
        gsap.utils.clamp(0, 1, (el.offsetTop + el.offsetHeight * t - view / 2) / total);

      const rows = sections
        .map((section) => {
          const el = container.querySelector<HTMLElement>(`[data-keyframe="${section.id}"]`);
          return el ? { at: anchor(el, section.at ?? 0.5), to: section.to } : null;
        })
        .filter((row): row is { at: number; to: Partial<StageParams> } => row !== null)
        // Authoring order is document order, but a custom `at` can reorder
        // them; the timeline needs them sorted to compute each row's span.
        .sort((a, b) => a.at - b.at);

      const timeline = gsap.timeline({
        scrollTrigger: {
          trigger: container,
          scroller: scroller ?? undefined,
          start: 'top top',
          end: 'bottom bottom',
          scrub,
          invalidateOnRefresh: true,
        },
        onUpdate: render,
      });

      rows.forEach((row, i) => {
        const next = rows[i + 1];
        // Each row travels until the next one begins, so the scene is always
        // in motion between beats and never sits finished waiting for the
        // next anchor.
        const span = Math.max(0.001, (next ? next.at : 1) - row.at);
        // `ease: 'none'` — under a scrub the ease warps timeline time, so an
        // eased row arrives at its params before or after its own section.
        timeline.to(params, { ...row.to, duration: span, ease: 'none' }, row.at);
      });

      render();
    },
    root,
    [sections, scrub, scrollerRef, reduced, height],
  );
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** `useGsap` reverts the `gsap.context`, which kills the timeline and its ScrollTrigger and drops the reference to the params object. This runs on every reflow as well as on unmount: a surviving trigger would keep scrubbing a timeline built against stale `offsetTop` values while a new one runs alongside it, and the two would write conflicting transforms to the same element on the same frame.

**Reduced motion.** No timeline and no ScrollTrigger are created. Every keyframe is folded into the params object in order and rendered once, so the stage shows the end of the story and the copy is fully readable and correctly laid out.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Hand-tuning keyframe positions as raw timeline fractions. A single line wrap moves every section below it, so the beat that was on the right paragraph at 1440px is a paragraph early at 1280px. Anchoring to `offsetTop` is the fix and is the reason for the whole `anchor` expression.
- Not rebuilding when the container reflows. The anchors are computed once from measurements taken at build time; a font swap, an image load or an orientation change moves the sections and the timeline keeps using the old numbers.
- Tweening the DOM directly instead of the object. With six properties across three elements you get a tween per property per beat, they fight over the same transform string, and the last one to write wins non-deterministically.
- Easing the rows under a scrub. The ease is applied to timeline time, so the params reach their target at a different scroll position than the anchor says — the beat visibly detaches from its heading.
- Reusing one module-level params object across builds. On rebuild the tweens start from wherever the previous scrub left the object rather than from the resting state, so the scene is subtly different after every resize.
- Letting `total` be zero. It happens on the first layout pass and on any container shorter than the viewport; every anchor becomes Infinity, clamps to 1, and the entire story plays in the last pixel of scroll.

## Notes

- The stage is `position: sticky`, not fixed, so it is bounded by the component rather than by the page and works inside any scroll container.
- Pass `sections` as a stable reference (module scope or memoised) — it is a dependency of the build effect.

## Definition of done

Scroll through the column: the mark scales, spins, drifts and dims, and each change lines up with the heading that names it reaching the middle of the panel. Resize the panel so the copy rewraps and scroll again — the beats still land on their headings. Stop mid-scroll and the scene keeps resolving for around a second. With reduced motion on, the stage shows the final state and nothing moves.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
197 lines · 1837 words
04

Source

4 files, exactly as installed.

installs tocomponents/motion/keyframe-param-object.tsx
'use client';

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useRef, type RefObject } from 'react';
import { cn } from '@/lib/utils';
import { useGsap } from '@/hooks/use-gsap';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';

/* ==========================================================================
   Single Param Object Timeline
   --------------------------------------------------------------------------
   The scroll-driven scene as a data table rather than a pile of tweens.

   One plain object owns every animated quantity:

     { scale, spin, driftX, driftY, glow, dissolve }

   Nothing in the DOM is tweened directly. GSAP tweens the object; a single
   `onUpdate` reads it and writes one transform string and two opacities.
   The "film script" is then just an array of `[sectionId, t, partialParams]`
   rows, and adding a beat to the story is adding a row — not wiring another
   element into another timeline.

   The anchoring is the other half. Each row is pinned to a real section:

     anchor = (section.offsetTop + section.offsetHeight * t - view / 2) / total

   which is "when the point `t` through this section reaches the middle of
   the viewport, expressed as a fraction of the whole scroll". That is what
   keeps the beats attached to the copy when the copy reflows — a row placed
   at a hand-tuned `0.42` drifts off its paragraph the moment a line wraps.
   ========================================================================== */

if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);

/** Every animated quantity in the scene. Nothing else is tweened. */
export interface StageParams {
  scale: number;
  /** Turns, not degrees — 1 is a full revolution. */
  spin: number;
  /** Percent of the mark's own size. */
  driftX: number;
  driftY: number;
  /** 0-1 opacity of the halo behind the mark. */
  glow: number;
  /** 0-1, subtracted from the mark's opacity. */
  dissolve: number;
}

const REST: StageParams = {
  scale: 1,
  spin: 0,
  driftX: 0,
  driftY: 0,
  glow: 0.35,
  dissolve: 0,
};

export interface KeyframeSection {
  id: string;
  label: string;
  title: string;
  body?: string;
  /**
   * Point through this section, 0-1, at which its keyframe lands. 0.5 is the
   * section's own middle passing the middle of the scroller.
   */
  at?: number;
  /** The params this section moves the scene to. Anything omitted holds. */
  to: Partial<StageParams>;
}

export interface KeyframeParamObjectProps {
  sections: KeyframeSection[];
  /** Seconds of catch-up behind the scroll position. */
  scrub?: number;
  scrollerRef?: RefObject<HTMLElement | null>;
  className?: string;
}

// #region critical
export function KeyframeParamObject({
  sections,
  scrub = 0.8,
  scrollerRef,
  className,
}: KeyframeParamObjectProps) {
  const root = useRef<HTMLDivElement>(null);
  const mark = useRef<HTMLDivElement>(null);
  const halo = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();
  // Rebuild the anchor table whenever the column reflows: every anchor is a
  // function of offsetTop, and offsetTop changes when a line wraps.
  const { height } = useResizeObserver(root);

  useGsap(
    gsap,
    () => {
      const container = root.current;
      const markEl = mark.current;
      const haloEl = halo.current;
      if (!container || !markEl || !haloEl) return;

      // The one mutable object. Cloned per build so a rebuild starts clean
      // rather than inheriting wherever the last scrub left it.
      const params: StageParams = { ...REST };

      const render = () => {
        markEl.style.transform =
          `translate3d(${params.driftX}%, ${params.driftY}%, 0) ` +
          `rotate(${params.spin * 360}deg) scale(${params.scale})`;
        markEl.style.opacity = String(Math.max(0, 1 - params.dissolve));
        haloEl.style.opacity = String(params.glow);
        haloEl.style.transform = `scale(${1 + params.glow * 0.6})`;
      };

      if (reduced) {
        // Land on the last keyframe: the end of the story, statically.
        for (const section of sections) Object.assign(params, section.to);
        render();
        return;
      }

      const scroller = scrollerRef?.current ?? null;
      const view = scroller ? scroller.clientHeight : window.innerHeight;
      // Scroll distance the trigger actually covers. Guarded against 0, which
      // would make every anchor Infinity and stack the whole story on frame 1.
      const total = Math.max(1, container.offsetHeight - view);

      const anchor = (el: HTMLElement, t: number) =>
        gsap.utils.clamp(0, 1, (el.offsetTop + el.offsetHeight * t - view / 2) / total);

      const rows = sections
        .map((section) => {
          const el = container.querySelector<HTMLElement>(`[data-keyframe="${section.id}"]`);
          return el ? { at: anchor(el, section.at ?? 0.5), to: section.to } : null;
        })
        .filter((row): row is { at: number; to: Partial<StageParams> } => row !== null)
        // Authoring order is document order, but a custom `at` can reorder
        // them; the timeline needs them sorted to compute each row's span.
        .sort((a, b) => a.at - b.at);

      const timeline = gsap.timeline({
        scrollTrigger: {
          trigger: container,
          scroller: scroller ?? undefined,
          start: 'top top',
          end: 'bottom bottom',
          scrub,
          invalidateOnRefresh: true,
        },
        onUpdate: render,
      });

      rows.forEach((row, i) => {
        const next = rows[i + 1];
        // Each row travels until the next one begins, so the scene is always
        // in motion between beats and never sits finished waiting for the
        // next anchor.
        const span = Math.max(0.001, (next ? next.at : 1) - row.at);
        // `ease: 'none'` — under a scrub the ease warps timeline time, so an
        // eased row arrives at its params before or after its own section.
        timeline.to(params, { ...row.to, duration: span, ease: 'none' }, row.at);
      });

      render();
    },
    root,
    [sections, scrub, scrollerRef, reduced, height],
  );
  // #endregion critical

  return (
    <div ref={root} className={cn('relative', className)}>
      {/* Sticky, not fixed: the stage tracks the scroller it lives in and
          stops at the end of the column instead of the end of the page. */}
      <div className="sticky top-0 flex h-[60vh] items-center justify-center overflow-hidden">
        <div
          ref={halo}
          aria-hidden
          className="absolute h-32 w-32 rounded-full bg-accent blur-2xl"
          style={{ opacity: REST.glow }}
        />
        <div
          ref={mark}
          aria-hidden
          className="relative h-24 w-24 rounded-lg border border-line-strong bg-surface will-change-transform"
        >
          <div className="absolute inset-3 rounded border border-accent" />
          <div className="absolute inset-x-3 top-1/2 h-px bg-accent" />
        </div>
      </div>

      <div className="relative -mt-[30vh] flex flex-col gap-[45vh] pb-[30vh]">
        {sections.map((section) => (
          <section key={section.id} data-keyframe={section.id} className="max-w-[34ch]">
            <p className="meta">{section.label}</p>
            <h3 className="mt-1.5 text-base leading-snug tracking-tight text-fg">
              {section.title}
            </h3>
            {section.body !== undefined && (
              <p className="mt-1.5 text-xs leading-relaxed text-muted">{section.body}</p>
            )}
          </section>
        ))}
      </div>
    </div>
  );
}
05

API

Every parameter, its type and what happens when you change it.

function KeyframeParamObject(props: { sections: { id: string; label: string; title: string; body?: string; at?: number; to: Partial<StageParams> }[]; scrub?: number; scrollerRef?: RefObject<HTMLElement | null>; className?: string }): JSX.Element
Parameters
sectionsKeyframeSection[]The story. Each row names a section, an optional point through it, and the params to move to.
sections[].atnumber = 0.5Point through the section, 0-1, at which its keyframe lands.
sections[].toPartial<StageParams>Params this beat moves to. Anything omitted holds its current value.
scrubnumber = 0.8Seconds of catch-up behind the scroll position.
scrollerRefRefObject<HTMLElement | null>The scroll container that owns the scroll position. Omit for the document.
classNamestringMerged onto the container via cn.
06

How it works

The technique in order, not the code in order.

  1. 01

    Every animated quantity lives in one object: { scale, spin, driftX, driftY, glow, dissolve }. No element is a tween target; GSAP tweens the plain object and one onUpdate renders it, so the number of tweens is the number of story beats rather than the number of moving parts.

  2. 02

    Each keyframe is anchored to a real section: anchor = clamp(0, 1, (el.offsetTop + el.offsetHeight * t - view / 2) / total), where total = container.offsetHeight - view is the scroll distance the trigger covers.

  3. 03

    That expression reads as "when the point t through this section reaches the middle of the viewport". t = 0.5 is the section's own middle, so the beat fires when the reader is looking at the copy it belongs to, at any column width.

  4. 04

    Rows are sorted by anchor and each is given duration = nextAnchor - thisAnchor, so the scene is continuously in motion between beats instead of arriving early and sitting still until the next anchor.

  5. 05

    ease: "none" on every row. Under a scrub the ease warps timeline time, not screen time, so an eased row reaches its params before or after the section it is anchored to — which defeats the anchoring.

  6. 06

    A useResizeObserver on the container re-runs the whole build when its height changes, because every anchor is a function of offsetTop and offsetTop moves whenever a line wraps.

Load-bearing constants

Load-bearing constants
scrub = 0.8Just under a second of catch-up. Enough that the scene continues to resolve after the wheel stops, which is what makes it read as a film rather than a slider; true welds it to the wheel and every scroll jitter shows up in the mark.
at = 0.5Default point through a section at which its keyframe lands. 0.5 puts the beat on the section's midpoint crossing the middle of the scroller. Values near 0 fire while the section is still entering, which reads as the scene reacting to copy the reader has not got to yet.
REST.glow = 0.35The scene's idle state, not zero. The stage has to be worth looking at before the first keyframe and after the last one; starting at 0 means the reader's first impression of the component is an empty box.
total floor = 1Guards container.offsetHeight - view against 0 or negative, which happens for one frame during layout and on a short container. Without it every anchor divides by zero, becomes Infinity, clamps to 1, and the whole story collapses onto the last frame.
span floor = 0.001Two keyframes anchored to the same point would otherwise get duration 0. GSAP accepts it, but the row then applies as an instant jump, so a carefully anchored beat becomes a snap.
section gap = 45vhSpacing between story sections, in scroller heights. Below about 30vh two anchors land close enough that their spans overlap heavily and the beats blur together; well above it the scene is static for long stretches.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

No timeline and no ScrollTrigger are created. Every keyframe is folded into the params object in order and rendered once, so the stage shows the end of the story and the copy is fully readable and correctly laid out.

Cleanup

useGsap reverts the gsap.context, which kills the timeline and its ScrollTrigger and drops the reference to the params object. This runs on every reflow as well as on unmount: a surviving trigger would keep scrubbing a timeline built against stale offsetTop values while a new one runs alongside it, and the two would write conflicting transforms to the same element on the same frame.

Definition of done

Scroll through the column: the mark scales, spins, drifts and dims, and each change lines up with the heading that names it reaching the middle of the panel. Resize the panel so the copy rewraps and scroll again — the beats still land on their headings. Stop mid-scroll and the scene keeps resolving for around a second. With reduced motion on, the stage shows the final state and nothing moves.

Pitfalls

  • Hand-tuning keyframe positions as raw timeline fractions. A single line wrap moves every section below it, so the beat that was on the right paragraph at 1440px is a paragraph early at 1280px. Anchoring to offsetTop is the fix and is the reason for the whole anchor expression.
  • Not rebuilding when the container reflows. The anchors are computed once from measurements taken at build time; a font swap, an image load or an orientation change moves the sections and the timeline keeps using the old numbers.
  • Tweening the DOM directly instead of the object. With six properties across three elements you get a tween per property per beat, they fight over the same transform string, and the last one to write wins non-deterministically.
  • Easing the rows under a scrub. The ease is applied to timeline time, so the params reach their target at a different scroll position than the anchor says — the beat visibly detaches from its heading.
  • Reusing one module-level params object across builds. On rebuild the tweens start from wherever the previous scrub left the object rather than from the resting state, so the scene is subtly different after every resize.
  • Letting total be zero. It happens on the first layout pass and on any container shorter than the viewport; every anchor becomes Infinity, clamps to 1, and the entire story plays in the last pixel of scroll.

Notes

  • The stage is position: sticky, not fixed, so it is bounded by the component rather than by the page and works inside any scroll container.
  • Pass sections as a stable reference (module scope or memoised) — it is a dependency of the build effect.