Scroll does not start this animation — scroll is the animation’s clock.
A triggered animation is fire-and-forget. The reader crosses a line, something plays at its own speed, and from that moment the two are strangers: scroll back up and nothing rewinds. A scrubbed timeline binds the playhead to a range of scroll instead, so the reader is not an audience — their hand is on the transport, and the animation can neither get ahead of them nor fall behind.
The statement is one timeline over thirteen words. Each starts at opacity: 0.12 under a 6px blur, ten pixels below where it belongs, and resolves to full contrast, no blur, no offset. Nothing in that list is unusual. What makes it read as handwriting rather than as thirteen fades is the spacing, an empty beat at the end, and the fact that the panel it happens on is not pinned.
Half a beat behind
STAGGER is 0.5 and WORD_DUR is 1, so a word begins inking when the word before it is halfway resolved and two or three are always mid-stroke. That overlap is the effect. Set the stagger equal to the duration and the words queue, arriving one finished block at a time like a slideshow; set it to zero and there is no sequence left at all.
// The signature scrubbed moment: the sentence sits as faint "ghost" type and scroll
// inks it in, word by word. Edit the line freely. (Lenis-safe: a tall track section +
// a sticky inner panel gives the pinned feel without ScrollTrigger's pin.)
const STATEMENT = "Worlds rendered live in the browser. You feel them before you read them.";
const WORDS = STATEMENT.split(" ");
// word i inks over [i*STAGGER, i*STAGGER + WORD_DUR] on the timeline
const STAGGER = 0.5;
const WORD_DUR = 1;
// empty timeline beat appended after the last word — the inked statement holds
// pinned for DWELL/(inking+DWELL) of the scrub track before releasing
const DWELL = 2.5;Thirteen words at that spacing make the inking (13 − 1) × 0.5 + 1 = 7 beats long. The ease is none, deliberately: the reader’s hand is already supplying the easing. Any other curve and a word accelerates through a stretch of scroll the wheel was crossing at a constant rate, which reads as the page holding an opinion about when you should be reading.
const WORD_DUR = 1;
// the timeline's own length, in the same abstract beats GSAP counts in
const inking = (WORDS.length - 1) * stagger + WORD_DUR;
const total = inking + dwell;
const time = (reduced ? 1 : progress) * total;
return (
<>
<div className="absolute inset-0 flex items-center px-[clamp(20px,5vw,56px)]">
<p className="font-serif text-[clamp(22px,4.4vw,54px)] leading-[1.08] tracking-[-.02em]">
{WORDS.map((w, i) => {
const start = i * stagger;
const p = Math.max(0, Math.min(1, (time - start) / WORD_DUR));
const opacity = reduced ? 1 : 0.12 + p * 0.88;
const blur = reduced ? 0 : (1 - p) * 6;
const y = reduced ? 0 : (1 - p) * 10;
return (
<span
key={i}
className="inline-block"
style={{
opacity,
filter: blur ? `blur(${blur.toFixed(2)}px)` : "none",
transform: y ? `translateY(${y.toFixed(2)}px)` : "none",
}}
>
{w}
{i < WORDS.length - 1 ? " " : ""}
</span>
);
})}1 the words queue; at 0 the sentence is a single fadeWORD_DUR − STAGGERThe beat where nothing happens
tl.to({}, { duration: DWELL }, ">") tweens an empty object for two and a half beats. No target, no property, no visible change — the line exists to occupy time, and it is the most valuable one in the component.
Without it the last word resolves at the end of the timeline, which is the end of the ScrollTrigger’s range, which is the moment the panel begins to leave. The reader never sees the finished sentence standing still; they watch it complete and depart in one gesture. Two and a half beats of nothing is 26% of a 9.5-beat timeline, so about 38vh of scroll passes with the statement fully inked and motionless — a landing rather than an exit.
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionRef.current,
start: "top top",
end: "bottom bottom",
scrub: 0.6,
},
});
tl.fromTo(
words,
{ opacity: 0.12, filter: "blur(6px)", y: 10 },
{ opacity: 1, filter: "blur(0px)", y: 0, stagger: STAGGER, ease: "none", duration: WORD_DUR }
);
// dwell: an empty beat after the last word resolves, so the fully-inked
// statement HOLDS centred for ~a third of a screen of scroll before the
// panel un-pins — a landing, not an instant exit. (The taller track keeps
// the word-inking pace unchanged; only the hold is added.)
tl.to({}, { duration: DWELL }, ">");scrub: 0.6 supplies the rest of the feel. A scrub of true welds the playhead to the scroll position exactly; a number gives it that many seconds to catch up, so the words trail the wheel slightly and settle after you have stopped. Stop halfway and the sentence stays halfway — some words solid, one mid-stroke, the rest still ghosts. That state is worth checking, because a scrubbed timeline that only looks right at 0 and 1 is a triggered animation wearing a costume.
Sticky, not pinned
ScrollTrigger has pin, and this does not use it. Pin earns the held-still feel by taking the element out of flow, fixing it, and inserting a spacer of exactly its height so the document does not collapse — a layout edit performed in the middle of a scroll. The scroll position here belongs to Lenis, eased towards a target at lerp: 0.08 and feeding ScrollTrigger by hand through lenis.on("scroll", ScrollTrigger.update). Rewriting document height underneath that is a fight worth not picking, particularly when CSS already ships the feature.
So the section is a plain tall track at 245vh, and the panel inside it is position: sticky, top: 0, min-h-screen. The panel holds the viewport for the length of the track, and the track — never the panel — is the trigger. start: "top top" to end: "bottom bottom" across 245vh leaves 145vh of real scroll once the panel’s own screen is subtracted, and the timeline’s 9.5 beats are mapped onto it. The beats are ratios; the 245vh is what turns them into distance.
return (
<section
ref={sectionRef}
className="relative z-[1]"
style={{ height: reduced ? "auto" : "245vh" }}
>
<div
ref={panelRef}
className="flex min-h-screen items-center px-gutter py-[clamp(60px,12vh,140px)]"
style={{ position: reduced ? "static" : "sticky", top: 0 }}
>
{/* The real sentence for screen readers; the scrubbed copy below is presentation.
Each visual word renders via ::before/attr() so its faint pre-scroll ghost
state isn't judged as low-contrast body text — it inks to full contrast on
scroll (and is fully opaque under reduced motion). */}
<p className="sr-only">{STATEMENT}</p>
<p ref={paraRef} aria-hidden className="max-w-[1180px] font-serif text-[clamp(34px,6vw,108px)] font-normal leading-[1.04] tracking-[-.02em]">
{WORDS.map((w, i) => (
<span
key={i}
ref={(el) => {
wordRefs.current[i] = el;
}}
data-word={w + (i < WORDS.length - 1 ? " " : "")}
className="inline-block will-change-[opacity,transform,filter] before:content-[attr(data-word)]"
style={reduced ? undefined : { opacity: 0.12, filter: "blur(6px)", transform: "translateY(10px)" }}
/>
))}
</p>
</div>
</section>
);A sentence with no text in it
The visible words are empty span elements. Each carries its own word in a data-word attribute, and the glyphs arrive through a ::before rule whose content is attr(data-word) — generated content, not a text node. The trailing space is part of the attribute rather than the markup, because inline-block siblings would otherwise close up.
The reason is the ghost state. Twelve per cent opacity under a 6px blur is an automatic contrast failure as real text, and it is the state a checker or a scrape encounters first, because neither of them scrolls. As generated content there is no body copy to judge. The sentence itself lives once, above, in an sr-only paragraph; the visual copy is aria-hidden. Two renderings, one STATEMENT constant, so they cannot drift.
