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.
Converging Headline Words
Live preview
The real component, running in this page.
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.jsonPrompt
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.
Source
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| words | string[] | — | One per line. Even indices enter from the left, odd from the right. |
| travel | number | = 120 | Percent of each word's own width that it starts offset by. |
| scrub | number | = 1 | Seconds of catch-up between the scrollbar and the words. |
| start | string | = 'top bottom' | ScrollTrigger start. |
| end | string | = 'top 25%' | ScrollTrigger end. |
| scroller | Element | null | = 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. |
How it works
The technique in order, not the code in order.
Each word is a block-level span with
data-word. A singlegsap.timelinewith ascrollTriggerholds onefromToper word, all inserted at position0, so every word shares the timeline's full length and therefore the full scroll range.The travel is
xPercent, notx: 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.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.ease: "none"on every tween. Underscrubthe scroll position *is* the easing curve; layeringpower2.outon 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.fromTo, notto. A baretoreads its start value from the DOM, and the DOM already saysxPercent: 0because the rendered markup is the assembled headline — that is what the no-JS and reduced-motion renders show.gsap.registerPlugin(ScrollTrigger)is called inside the effect rather than at module scope, becauseregisterPlugintoucheswindowand this module is still evaluated during server rendering.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| 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. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
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.
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.
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
noneunderscrub. 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
toinstead offromTo. 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
xin pixels. A word that is 400px wide and one that is 120px wide need different offsets to clear the frame;xPercentis self-scaling and survives a font-size change. - Setting
overflow: hiddenon the container. It stops the horizontal scrollbar, but CSS forcesoverflow-ytoautowhen one axis ishidden, which silently breaks any sticky ancestor and clips descenders.overflow-x: clipdoes the same job without that side effect. - Omitting the
scrolleroption 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.
registerPluginreaches forwindowand 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. Seefont-safe-text-split. - The
scrollerprop exists so the component can be demoed and used inside anoverflow-y-autopanel rather than only in the document scroller.