Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
childrenstringThe statement, as one plain string, required.
pacenumber22Scroll 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.