Scrub statement
One long statement that fills in word by word as you scroll, a plain string underneath.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/scrub-statement.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/scrub-statement
A single sentence held in the middle of the window while its words go from muted to foreground.
Usage
import { ScrubStatement } from "@/components/ui/scrub-statement"
<ScrubStatement aria-label="Our position">
One long sentence that says what you stand for, read at the speed you scroll.
</ScrubStatement>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | The statement, as one plain string, required. | |
| pace | number | 22 | Scroll distance per word, in px. |
Source
scrub-statement/scrub-statement.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);
const MOTION = "(prefers-reduced-motion: no-preference)";
/** Each word takes this many steps to light, so the edge of the reading line stays soft. */
const SOFT = 5;
type ScrubStatementProps = Omit<React.ComponentProps<"section">, "children"> & {
/** The statement, as one plain string. */
children: string;
/** Scroll distance per word, in px. */
pace?: number;
};
/**
* One long statement that fills in word by word as you scroll. Two split copies sit on top of each
* other, muted below and foreground above, and only the opacity of the top words changes. A third
* copy, unsplit, is what assistive tech reads. Native sticky holds it in place, so touch scrolling
* is untouched. Under reduced motion or with no script the text simply sits in foreground.
*/
function ScrubStatement({ pace = 22, className, style, children, ...props }: ScrubStatementProps) {
const root = React.useRef<HTMLElement>(null);
const words = children.trim().split(/\s+/).length;
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, () => {
const section = root.current;
const rest = section?.querySelector<HTMLElement>("[data-slot=scrub-statement-rest]");
const lit = section?.querySelector<HTMLElement>("[data-slot=scrub-statement-lit]");
if (!section || !rest || !lit) return;
// Both layers split the same way, so every word sits on its twin at any width.
const split = SplitText.create([rest, lit], { type: "words", aria: "none" });
const targets = split.words.filter((word) => lit.contains(word));
gsap
.timeline({
scrollTrigger: { trigger: section, start: "top top", end: "bottom bottom", scrub: true },
})
.fromTo(targets, { opacity: 0 }, { opacity: 1, duration: SOFT, ease: "none", stagger: 1 }, 0)
// A short hold, so the finished sentence gets a moment before the section lets go.
.to({}, { duration: words * 0.08 });
section.setAttribute("data-staged", "");
return () => section.removeAttribute("data-staged");
});
return () => media.revert();
},
{ scope: root, dependencies: [children, words] },
);
return (
<section
ref={root}
data-slot="scrub-statement"
className={cn("group/scrub relative bg-background motion-safe:h-[calc(100svh+var(--scrub-length))]", className)}
style={{ "--scrub-length": `${words * pace}px`, ...style } as React.CSSProperties}
{...props}
>
<div className="flex min-h-svh items-center px-6 py-24 motion-safe:sticky motion-safe:top-0 motion-safe:h-svh motion-safe:py-0 md:px-8">
<p
key={children}
className="relative m-0 mx-auto w-full max-w-5xl text-3xl font-medium tracking-tight text-pretty md:text-5xl"
>
<span className="sr-only">{children}</span>
<span aria-hidden="true" data-slot="scrub-statement-rest" className="block text-muted-foreground">
{children}
</span>
<span
aria-hidden="true"
data-slot="scrub-statement-lit"
className="absolute inset-0 text-foreground motion-safe:[@media(scripting:enabled)]:not-group-data-staged/scrub:opacity-0"
>
{children}
</span>
</p>
</div>
</section>
);
}
export { ScrubStatement };
For AI agents
Hand your agent this page, the markdown at /components/scrub-statement/prompt.md, or the Copy AI prompt button above.
When to use
- One sentence that carries the pitch on a landing page
- A pause between two busy sections
- A manifesto or a mission line that should be read, not skimmed
When not to use
- For body copy or anything people come back to look up
- More than about fifty words, because the scroll gets long
- Text with links or inline elements: it takes one plain string
Agent notes
- GSAP and its SplitText plugin install from npm under GSAP's own licence. No GSAP code ships in this registry
- children must be a plain string, and it stays one for assistive tech: the split copies are aria-hidden
- It holds with CSS sticky, so no ancestor may set overflow hidden, auto or scroll
- Reduced motion and no script both show the statement fully in foreground at natural height
The maths
- Section height is 100svh + words × pace, so 39 words at 22px scroll 858px while the text holds still.
- Words start one step apart and take 5 steps to light, so about 5 words are mid fade and the reading edge stays soft.
- Muted to foreground is a stacked pair with only the top opacity moving, so no text repaints while scrolling.