Skip to content
Kinetic
TextDOM2 files

Converging Headline Words

A stacked headline assembles out of the margins as you approach it. Odd lines enter from the right, even lines from the left, and both land on zero at the same scrub position, so they cross the frame in opposite directions and lock into a sentence. Because the tween is scrubbed, scrolling back up disassembles it — the headline is a function of scroll position, not a playback that has happened or not happened.

gsapscrolltriggerscrubheadlinesplit

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/converging-scrub-words.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: Converging Headline Words

A stacked headline assembles out of the margins as you approach it. Odd lines enter from the right, even lines from the left, and both land on zero at the same scrub position, so they cross the frame in opposite directions and lock into a sentence. Because the tween is scrubbed, scrolling back up disassembles it — the headline is a function of scroll position, not a playback that has happened or not happened.

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

Headline words slide in from opposite margins and lock into a sentence, pinned to scroll position.

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

## How it works

1. Each word is a block-level span with `data-word`. A single `gsap.timeline` with a `scrollTrigger` holds one `fromTo` per word, all inserted at position `0`, so every word shares the timeline's full length and therefore the full scroll range.
2. The travel is `xPercent`, not `x`: percent of *the element's own width*, so a short word and a long word both start entirely outside the frame and neither needs a hand-tuned pixel value.
3. Direction alternates on index parity — `i % 2 === 0 ? -travel : travel` — and the text alignment alternates with it, so each word enters from the side it is aligned to and travels the shortest distance to its resting place.
4. `ease: "none"` on every tween. Under `scrub` the scroll position *is* the easing curve; layering `power2.out` on top means the words accelerate away from the scrollbar and then wait for it, which is precisely the sensation people describe as the scroll feeling sticky.
5. `fromTo`, not `to`. A bare `to` reads its start value from the DOM, and the DOM already says `xPercent: 0` because the rendered markup is the assembled headline — that is what the no-JS and reduced-motion renders show.
6. `gsap.registerPlugin(ScrollTrigger)` is called inside the effect rather than at module scope, because `registerPlugin` touches `window` and this module is still evaluated during server rendering.

## 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".

- `TRAVEL = 120`
  Percent of each word's own width. At exactly 100 the word starts flush against the frame edge and is already peeking in on the first pixel of scroll; the extra 20% buys a moment of empty margin before it arrives.
- `SCRUB = 1`
  One second of catch-up between the scrollbar and the words. `scrub: true` welds them exactly, which on a trackpad exposes every jitter in the input; past ~1.5s the headline is still assembling well after the reader has stopped scrolling.
- `START = 'top bottom'`
  The scrub begins the instant the headline's top edge enters the viewport. Starting later means the words are still outside the frame when the headline is already half on screen, which reads as missing content rather than as an entrance.
- `END = 'top 25%'`
  Assembly completes when the headline top reaches the upper quarter of the viewport. Ending at centre leaves the headline unresolved while it is the dominant thing on screen; ending earlier compresses the whole effect into the bottom sliver of the viewport.

## API

```ts
function ConvergingScrubWords(props: { words: string[]; travel?: number; scrub?: number; start?: string; end?: string; scroller?: Element | null; className?: string }): JSX.Element
```

- `words: string[]`
  One per line. Even indices enter from the left, odd from the right.
- `travel: number` — default `120`
  Percent of each word's own width that it starts offset by.
- `scrub: number` — default `1`
  Seconds of catch-up between the scrollbar and the words.
- `start: string` — default `'top bottom'`
  ScrollTrigger `start`.
- `end: string` — default `'top 25%'`
  ScrollTrigger `end`.
- `scroller: Element | null` — default `null`
  Scroll container, when the headline is not in the document scroller.
- `className: string`
  Merged onto the wrapper via `cn` — type size and tracking go here.

## 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
const TRAVEL = 120;
const SCRUB = 1;
const START = 'top bottom';
const END = 'top 25%';

export function ConvergingScrubWords({
  words,
  travel = TRAVEL,
  scrub = SCRUB,
  start = START,
  end = END,
  scroller = null,
  className,
}: ConvergingScrubWordsProps) {
  const root = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  useEffect(() => {
    const el = root.current;
    // Reduced motion: the JSX below already renders the assembled headline at
    // its final position, so declining to attach the trigger is the whole
    // implementation. Nothing is hidden and nothing is left off-screen.
    if (!el || reduced) return;

    // Registered here rather than at module scope: registerPlugin touches
    // window, and this file is still evaluated during server rendering.
    gsap.registerPlugin(ScrollTrigger);

    const wordEls = Array.from(el.querySelectorAll<HTMLElement>('[data-word]'));
    if (wordEls.length === 0) return;

    const ctx = gsap.context(() => {
      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: el,
          scroller: scroller ?? undefined,
          start,
          end,
          scrub,
        },
      });

      wordEls.forEach((w, i) => {
        // fromTo, not to. A bare `to` would read the start value off the DOM,
        // and the DOM already says 0 because the inline style below is the
        // final position for the no-JS and reduced-motion renders.
        tl.fromTo(
          w,
          { xPercent: i % 2 === 0 ? -travel : travel },
          { xPercent: 0, ease: 'none' },
          0,
        );
      });
    }, el);

    return () => ctx.revert();
  }, [words, travel, scrub, start, end, scroller, reduced]);
```

## Requirements

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

**Cleanup.** `ctx.revert()` on the `gsap.context` scoped to the root. That kills the timeline, disposes the ScrollTrigger instance (so it stops measuring a detached element and stops contributing to `ScrollTrigger.refresh()`), and strips the inline transforms GSAP wrote.

**Reduced motion.** No ScrollTrigger is attached and no tween is created. The markup React renders is already the assembled headline at `xPercent: 0`, so the finished sentence is on screen from first paint — nothing is off-screen waiting for a scroll that will not drive it.

**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

- Using an ease other than `none` under `scrub`. The words lead or lag the scrollbar and the page feels like it is fighting the input — the single most common complaint about scrubbed animation.
- Using `to` instead of `fromTo`. GSAP samples the current DOM value as the start, which is the resting position, so nothing moves — or worse, it moves only after a resize invalidates the cached start.
- Animating `x` in pixels. A word that is 400px wide and one that is 120px wide need different offsets to clear the frame; `xPercent` is self-scaling and survives a font-size change.
- Setting `overflow: hidden` on the container. It stops the horizontal scrollbar, but CSS forces `overflow-y` to `auto` when one axis is `hidden`, which silently breaks any sticky ancestor and clips descenders. `overflow-x: clip` does the same job without that side effect.
- Omitting the `scroller` option when the headline lives inside a scrolling panel rather than the document. ScrollTrigger measures against the window, so the trigger fires at the wrong time or never.
- Registering ScrollTrigger at module scope. `registerPlugin` reaches for `window` and throws during server rendering.
- Cleaning up with `kill()` on the timeline only. The ScrollTrigger instance survives and keeps measuring a detached element; `ctx.revert()` disposes both and strips the inline transforms.

## Notes

- No text-splitting library: the words arrive as an array prop and are rendered as spans by React. A splitter would add a dependency and a DOM-mutation lifecycle for something the caller already has as data.
- If you feed this component text you split elsewhere, gate the split on `document.fonts.ready` — measuring before the webfont lands gives fallback-font line breaks that never re-flow. See `font-safe-text-split`.
- The `scroller` prop exists so the component can be demoed and used inside an `overflow-y-auto` panel rather than only in the document scroller.

## Definition of done

Scroll down slowly: the words travel inward from alternating sides and are exactly aligned when the headline top reaches the upper quarter of the viewport. Scroll back up and they separate again, tracking the scrollbar. No horizontal scrollbar appears on the document at any point. With reduced motion enabled the assembled headline is present on first paint and does not move.

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

Source

2 files, exactly as installed.

installs tocomponents/motion/converging-scrub-words.tsx
'use client';

import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useReducedMotion } from '@/hooks/use-reduced-motion';

/* ==========================================================================
   Converging Headline Words
   --------------------------------------------------------------------------
   A two-word headline assembles out of the margins as you approach it. The
   first word enters from -120% of its own width, the second from +120%, and
   both land on zero at the same scrub position — they cross the frame in
   opposite directions and lock into a sentence.

   `ease: 'none'` on both tweens, deliberately. Under `scrub` the scroll
   position *is* the easing curve; layering `power2.out` on top means the words
   accelerate away from the scrollbar and then wait for it, which is the exact
   sensation people describe as "the scroll feels sticky".

   ±120%, not ±100%: at exactly 100% a word starts flush against the frame
   edge and is already peeking in on the first pixel of scroll. The extra 20%
   buys a moment of empty margin before it arrives.
   ========================================================================== */

export interface ConvergingScrubWordsProps {
  /** Even indices enter from the left, odd from the right. */
  words: string[];
  /** Percent of each word's own width. */
  travel?: number;
  /** Seconds of catch-up between the scrollbar and the words. */
  scrub?: number;
  /** ScrollTrigger `start`. */
  start?: string;
  /** ScrollTrigger `end`. */
  end?: string;
  /** Scroll container, when the headline is not in the document scroller. */
  scroller?: Element | null;
  className?: string;
}

// #region critical
const TRAVEL = 120;
const SCRUB = 1;
const START = 'top bottom';
const END = 'top 25%';

export function ConvergingScrubWords({
  words,
  travel = TRAVEL,
  scrub = SCRUB,
  start = START,
  end = END,
  scroller = null,
  className,
}: ConvergingScrubWordsProps) {
  const root = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  useEffect(() => {
    const el = root.current;
    // Reduced motion: the JSX below already renders the assembled headline at
    // its final position, so declining to attach the trigger is the whole
    // implementation. Nothing is hidden and nothing is left off-screen.
    if (!el || reduced) return;

    // Registered here rather than at module scope: registerPlugin touches
    // window, and this file is still evaluated during server rendering.
    gsap.registerPlugin(ScrollTrigger);

    const wordEls = Array.from(el.querySelectorAll<HTMLElement>('[data-word]'));
    if (wordEls.length === 0) return;

    const ctx = gsap.context(() => {
      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: el,
          scroller: scroller ?? undefined,
          start,
          end,
          scrub,
        },
      });

      wordEls.forEach((w, i) => {
        // fromTo, not to. A bare `to` would read the start value off the DOM,
        // and the DOM already says 0 because the inline style below is the
        // final position for the no-JS and reduced-motion renders.
        tl.fromTo(
          w,
          { xPercent: i % 2 === 0 ? -travel : travel },
          { xPercent: 0, ease: 'none' },
          0,
        );
      });
    }, el);

    return () => ctx.revert();
  }, [words, travel, scrub, start, end, scroller, reduced]);
  // #endregion critical

  return (
    // overflow-x: clip, not hidden. Both stop a word translated 120% to the
    // right from adding a horizontal scrollbar to the whole document; only
    // `clip` leaves vertical overflow alone, so descenders and any sticky
    // ancestor keep working. `hidden` on one axis forces `auto` on the other.
    <div
      ref={root}
      className={cn(
        'w-full overflow-x-clip text-[clamp(44px,12vw,160px)] leading-[0.95] tracking-[-0.04em]',
        className,
      )}
    >
      {words.map((word, i) => (
        <span
          key={`${i}-${word}`}
          data-word
          className={cn(
            'block will-change-transform',
            i % 2 === 0 ? 'text-left' : 'text-right',
          )}
        >
          {word}
        </span>
      ))}
    </div>
  );
}
05

API

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

function ConvergingScrubWords(props: { words: string[]; travel?: number; scrub?: number; start?: string; end?: string; scroller?: Element | null; className?: string }): JSX.Element
Parameters
wordsstring[]One per line. Even indices enter from the left, odd from the right.
travelnumber = 120Percent of each word's own width that it starts offset by.
scrubnumber = 1Seconds of catch-up between the scrollbar and the words.
startstring = 'top bottom'ScrollTrigger start.
endstring = 'top 25%'ScrollTrigger end.
scrollerElement | null = nullScroll container, when the headline is not in the document scroller.
classNamestringMerged onto the wrapper via cn — type size and tracking go here.
06

How it works

The technique in order, not the code in order.

  1. 01

    Each word is a block-level span with data-word. A single gsap.timeline with a scrollTrigger holds one fromTo per word, all inserted at position 0, so every word shares the timeline's full length and therefore the full scroll range.

  2. 02

    The travel is xPercent, not x: percent of *the element's own width*, so a short word and a long word both start entirely outside the frame and neither needs a hand-tuned pixel value.

  3. 03

    Direction alternates on index parity — i % 2 === 0 ? -travel : travel — and the text alignment alternates with it, so each word enters from the side it is aligned to and travels the shortest distance to its resting place.

  4. 04

    ease: "none" on every tween. Under scrub the scroll position *is* the easing curve; layering power2.out on top means the words accelerate away from the scrollbar and then wait for it, which is precisely the sensation people describe as the scroll feeling sticky.

  5. 05

    fromTo, not to. A bare to reads its start value from the DOM, and the DOM already says xPercent: 0 because the rendered markup is the assembled headline — that is what the no-JS and reduced-motion renders show.

  6. 06

    gsap.registerPlugin(ScrollTrigger) is called inside the effect rather than at module scope, because registerPlugin touches window and this module is still evaluated during server rendering.

Load-bearing constants

Load-bearing constants
TRAVEL = 120Percent of each word's own width. At exactly 100 the word starts flush against the frame edge and is already peeking in on the first pixel of scroll; the extra 20% buys a moment of empty margin before it arrives.
SCRUB = 1One second of catch-up between the scrollbar and the words. scrub: true welds them exactly, which on a trackpad exposes every jitter in the input; past ~1.5s the headline is still assembling well after the reader has stopped scrolling.
START = 'top bottom'The scrub begins the instant the headline's top edge enters the viewport. Starting later means the words are still outside the frame when the headline is already half on screen, which reads as missing content rather than as an entrance.
END = 'top 25%'Assembly completes when the headline top reaches the upper quarter of the viewport. Ending at centre leaves the headline unresolved while it is the dominant thing on screen; ending earlier compresses the whole effect into the bottom sliver of the viewport.
07

Behaviour

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

Reduced motion

No ScrollTrigger is attached and no tween is created. The markup React renders is already the assembled headline at xPercent: 0, so the finished sentence is on screen from first paint — nothing is off-screen waiting for a scroll that will not drive it.

Cleanup

ctx.revert() on the gsap.context scoped to the root. That kills the timeline, disposes the ScrollTrigger instance (so it stops measuring a detached element and stops contributing to ScrollTrigger.refresh()), and strips the inline transforms GSAP wrote.

Definition of done

Scroll down slowly: the words travel inward from alternating sides and are exactly aligned when the headline top reaches the upper quarter of the viewport. Scroll back up and they separate again, tracking the scrollbar. No horizontal scrollbar appears on the document at any point. With reduced motion enabled the assembled headline is present on first paint and does not move.

Pitfalls

  • Using an ease other than none under scrub. The words lead or lag the scrollbar and the page feels like it is fighting the input — the single most common complaint about scrubbed animation.
  • Using to instead of fromTo. GSAP samples the current DOM value as the start, which is the resting position, so nothing moves — or worse, it moves only after a resize invalidates the cached start.
  • Animating x in pixels. A word that is 400px wide and one that is 120px wide need different offsets to clear the frame; xPercent is self-scaling and survives a font-size change.
  • Setting overflow: hidden on the container. It stops the horizontal scrollbar, but CSS forces overflow-y to auto when one axis is hidden, which silently breaks any sticky ancestor and clips descenders. overflow-x: clip does the same job without that side effect.
  • Omitting the scroller option when the headline lives inside a scrolling panel rather than the document. ScrollTrigger measures against the window, so the trigger fires at the wrong time or never.
  • Registering ScrollTrigger at module scope. registerPlugin reaches for window and throws during server rendering.
  • Cleaning up with kill() on the timeline only. The ScrollTrigger instance survives and keeps measuring a detached element; ctx.revert() disposes both and strips the inline transforms.

Notes

  • No text-splitting library: the words arrive as an array prop and are rendered as spans by React. A splitter would add a dependency and a DOM-mutation lifecycle for something the caller already has as data.
  • If you feed this component text you split elsewhere, gate the split on document.fonts.ready — measuring before the webfont lands gives fallback-font line breaks that never re-flow. See font-safe-text-split.
  • The scroller prop exists so the component can be demoed and used inside an overflow-y-auto panel rather than only in the document scroller.