Skip to content
UI Kit
Accent color

Text scrub

Large type that moves exactly with the scroll, one statement per page. Lines slide against each other, words come together, a word shrinks into its place, lines rise or an outline fills. Without JavaScript the statement stands finished.

One of 3 components in Scroll effects

5 variants

Variants

Sliding lines

Three or four giant lines slide against each other while the section passes, small round pictures sit between the words.

Open in Studio

Words come together

The words of one sentence start far apart on both sides and come together in the middle while you scroll, then the line below arrives.

Open in Studio

One word shrinks into place

One word fills the window and shrinks to its place in the line while you scroll, the sentence below arrives once it has landed.

Open in Studio

Lines rise one by one

Each line rises out of its own slot, one after another as you scroll, like a page being set. The last line carries the accent.

Open in Studio

Outline fills

Giant outlined lines fill with color from left to right while you scroll, line by line. The last one fills in the accent.

Open in Studio

Details

What Text scrub brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/text-scrub.tsx
Brings along
utilskitbuttonsectionscroll-scene
npm packages
None
Languages
Texts come through props

Props

The TextScrubProps interface from components/ui/text-scrub.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    TextScrubVariant

    linesconvergescalestackoutline

  • linesrequired
    string[]

    The statement, one entry per line. In a line, `[0]` places picture 0 between the words.

  • eyebrowoptional
    string
  • descriptionoptional
    string

    Text under the statement

  • actionoptional
    { label: string; href: string }
  • imagesoptional
    React.ReactNode[]

    lines: small pictures placed with [0], [1] … in the lines

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 286 lines
components/ui/text-scrub.tsx
'use client';

import { useCallback, useRef } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { lerp, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';

/*
 * Large type that moves with the scroll, as one statement per page. The movement follows the
 * scroll exactly (scrubbed), nothing loops on its own. Words and lines move by transform, the
 * outline fill by clip-path on the text alone.
 *
 * Without JavaScript and with reduced motion the statement stands in its finished, readable form.
 *
 *   lines     three or four giant lines slide against each other, small pictures sit between words
 *   converge  the words of one sentence come together from both sides into the middle
 *   scale     one word fills the window and shrinks to its place, the line below arrives
 *   stack     lines rise out of their own slot one after another, like a page being set
 *   outline   a giant outlined line fills with color from left to right
 */

export type TextScrubVariant = 'lines' | 'converge' | 'scale' | 'stack' | 'outline';

export interface TextScrubProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: TextScrubVariant;
  /** The statement, one entry per line. In a line, `[0]` places picture 0 between the words. */
  lines: string[];
  eyebrow?: string;
  /** Text under the statement */
  description?: string;
  action?: { label: string; href: string };
  /** lines: small pictures placed with [0], [1] … in the lines */
  images?: React.ReactNode[];
}

type Els = Record<string, HTMLElement | null>;
const tf = (el: HTMLElement | null | undefined, t: string) => {
  if (el) el.style.transform = t;
};
const op = (el: HTMLElement | null | undefined, o: number) => {
  if (el) el.style.opacity = String(round(o, 3));
};

const BIG = 'font-semibold leading-[0.95] tracking-[-0.045em] text-foreground';

/** A line with [n] markers turned into small rounded pictures */
function Line({ text, images }: { text: string; images?: React.ReactNode[] }) {
  const parts = text.split(/(\[\d+\])/g).filter(Boolean);
  return (
    <>
      {parts.map((part, i) => {
        const m = part.match(/^\[(\d+)\]$/);
        if (!m) return <span key={i}>{part}</span>;
        const img = images?.[Number(m[1]) % Math.max(1, images.length)];
        return img ? (
          <span key={i} aria-hidden className="mx-[0.12em] inline-block h-[0.78em] w-[1.5em] translate-y-[0.06em] overflow-hidden rounded-full align-baseline [&>*]:size-full">
            {img}
          </span>
        ) : null;
      })}
    </>
  );
}

/** Words of all lines, with their line index, for converge */
const wordsOf = (lines: string[]) => lines.flatMap((l, li) => l.replace(/\[\d+\]/g, '').split(/\s+/).filter(Boolean).map((w) => ({ w, li })));
const plain = (lines: string[]) => lines.map((l) => l.replace(/\[\d+\]/g, '').replace(/\s+/g, ' ').trim()).join(' ');

/* ------------------------------------------------------------------ */

/** Large type that moves with the scroll. */
export function TextScrub({ variant = 'lines', lines, eyebrow, description, action, images, className, ...rest }: TextScrubProps) {
  const live = useLive();
  const els = useRef<Els>({});
  const set = (k: string) => (n: HTMLElement | null) => {
    els.current[k] = n;
  };
  const words = wordsOf(lines);

  const apply = useCallback(
    (p: number) => {
      const e = els.current;
      const w = window.innerWidth;
      if (variant === 'lines') {
        // Each line slides against its neighbour, up to a fifth of the window over the whole pass, half as far on phones
        const f = w < 640 ? 0.5 : 1;
        lines.forEach((_, i) => tf(e[`l${i}`], `translate3d(${round((i % 2 ? 1 : -1) * (p - 0.5) * w * 0.22 * f, 1)}px, 0, 0)`));
      }
      if (variant === 'converge') {
        const k = smooth(span(p, 0.08, 0.62));
        words.forEach((_, i) => {
          const side = i % 2 ? 1 : -1;
          const far = w * (0.35 + ((i * 37) % 23) / 100);
          tf(e[`w${i}`], `translate3d(${round(side * far * (1 - k), 1)}px, ${round((i % 3 - 1) * 40 * (1 - k), 1)}px, 0)`);
          op(e[`w${i}`], lerp(0.12, 1, k));
        });
        const d = smooth(span(p, 0.62, 0.85));
        op(e.after, d);
        tf(e.after, `translate3d(0, ${round(20 * (1 - d), 1)}px, 0)`);
      }
      if (variant === 'scale') {
        const k = smooth(span(p, 0.05, 0.6));
        // The word is set at its start size and scaled down, so it stays sharp all the way
        const start = Number(e.word?.dataset.start) || 4;
        tf(e.word, `translate(-50%, -50%) scale(${round(lerp(1, 1 / start, k), 4)})`);
        const d = smooth(span(p, 0.55, 0.8));
        for (const k2 of ['after', 'after2']) {
          op(e[k2], d);
          tf(e[k2], `translate3d(0, ${round(24 * (1 - d), 1)}px, 0)`);
        }
      }
      if (variant === 'stack') {
        lines.forEach((_, i) => {
          // The first line is in place when the stage pins, so the scene never opens on an empty screen
          const a = i === 0 ? -0.22 : 0.06 + (i * 0.6) / Math.max(1, lines.length);
          const k = smooth(span(p, a, a + 0.22));
          // Glyphs reach above the tight line box, 130 % keeps their tops out of the mask too
          tf(e[`l${i}`], `translate3d(0, ${round((1 - k) * 130, 2)}%, 0)`);
        });
        const d = smooth(span(p, 0.72, 0.9));
        op(e.after, d);
        tf(e.after, `translate3d(0, ${round(20 * (1 - d), 1)}px, 0)`);
      }
      if (variant === 'outline') {
        lines.forEach((_, i) => {
          const a = 0.08 + (i * 0.5) / Math.max(1, lines.length);
          const k = span(p, a, a + 0.5 / Math.max(1, lines.length) + 0.12);
          const el = e[`f${i}`];
          if (el) el.style.clipPath = `inset(0 ${round((1 - k) * 100, 2)}% 0 0)`;
        });
        const d = smooth(span(p, 0.7, 0.9));
        op(e.after, d);
        tf(e.after, `translate3d(0, ${round(20 * (1 - d), 1)}px, 0)`);
      }
    },
    [variant, lines, words],
  );
  const track = useSceneProgress<HTMLDivElement>(live, { mode: variant === 'lines' ? 'pass' : 'pin', self: false, onProgress: apply });

  const after = (description || action) && (
    <div className={cn('mx-auto mt-10 max-w-[34rem] text-center', variant === 'outline' && 'mx-0 mt-0 text-left')}>
      {description && <p className="text-pretty text-lead text-muted-foreground">{description}</p>}
      {action && (
        <div className={cn('mt-7 flex justify-center', variant === 'outline' && 'justify-start')}>
          <div className="max-sm:w-full max-sm:[&>span]:w-full">
            <Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
              {action.label}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
  const eyebrowEl = eyebrow && <Eyebrow className={cn('text-center', variant === 'outline' && 'text-left')}>{eyebrow}</Eyebrow>;

  /* ---------- lines: not pinned, the lines slide while the section passes ---------- */
  // Without JavaScript and with reduced motion the lines do not slide, so they wrap like the finished statement below
  if (variant === 'lines' && live) {
    return (
      <section {...rest} className={cn('relative overflow-hidden py-20 sm:py-28 lg:py-36', className)}>
        <div ref={track}>
          <div className="kit-container">{eyebrowEl}</div>
          <h2 className={cn('text-[clamp(1.75rem,0.2rem+7vw,9rem)]', BIG)}>
            <span className="sr-only">{plain(lines)}</span>
            {lines.map((l, i) => (
              <span key={i} aria-hidden className={cn('block whitespace-nowrap', i % 2 ? 'text-right' : 'text-left')}>
                <span ref={set(`l${i}`)} className={cn('inline-block will-change-transform', i % 2 ? 'pr-[6vw]' : 'pl-[6vw]', i === lines.length - 1 && 'kit-accent-text')}>
                  <Line text={l} images={images} />
                </span>
              </span>
            ))}
          </h2>
          <div className="kit-container">{after}</div>
        </div>
      </section>
    );
  }

  /* ---------- static: finished statement ---------- */
  if (!live) {
    return (
      <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28 lg:py-36', className)}>
        <div className="kit-container">
          {eyebrowEl}
          <h2 className={cn('text-balance text-[clamp(2.5rem,1.2rem+5vw,6.5rem)]', variant === 'outline' ? 'text-left' : 'text-center', BIG)}>
            {lines.map((l, i) => (
              <span key={i} className={cn('block', i === lines.length - 1 && variant !== 'outline' && 'kit-accent-text')}>
                {l.replace(/\[\d+\]/g, '')}
              </span>
            ))}
          </h2>
          {after}
        </div>
      </section>
    );
  }

  /* ---------- pinned scenes ---------- */
  return (
    <section {...rest} className={cn('relative overflow-x-clip', className)}>
      <div ref={track} data-clip-ok className="relative" style={{ height: '240svh' }}>
        <div className="sticky top-0 flex h-svh flex-col justify-center overflow-clip">
          <div className="kit-container w-full">
            {eyebrowEl}
            {variant === 'converge' && (
              <h2 className={cn('text-balance text-center text-[clamp(2.5rem,1.2rem+5vw,6.5rem)]', BIG)}>
                <span className="sr-only">{plain(lines)}</span>
                {lines.map((_, li) => (
                  <span key={li} aria-hidden className="block">
                    {words.map((w, i) =>
                      w.li === li ? (
                        <span key={i} ref={set(`w${i}`)} className={cn('mx-[0.12em] inline-block will-change-transform', li === lines.length - 1 && 'kit-accent-text')}>
                          {w.w}
                        </span>
                      ) : null,
                    )}
                  </span>
                ))}
              </h2>
            )}
            {variant === 'scale' && (
              <h2 className={cn('text-center text-[clamp(3rem,1.4rem+7vw,9rem)]', BIG)}>
                {/* The word starts large enough to fill the window and shrinks into the line; the invisible copy holds its place, the sr-only copy is what screen readers get */}
                <span className="sr-only">{lines[0]}</span>
                <span aria-hidden className="relative inline-block">
                  <span className="invisible">{lines[0]}</span>
                  <span ref={set('word')} data-start={4.2} aria-hidden className="kit-accent-text absolute left-1/2 top-1/2 origin-center whitespace-nowrap text-[4.2em] will-change-transform" style={{ transform: 'translate(-50%, -50%)' }}>
                    {lines[0]}
                  </span>
                </span>
                {lines.slice(1).map((l, i) => (
                  <span key={i} ref={i === 0 ? set('after') : undefined} className="mt-2 block text-[0.42em] leading-tight tracking-[-0.03em] text-muted-foreground" style={{ opacity: 0 }}>
                    {l}
                  </span>
                ))}
              </h2>
            )}
            {variant === 'stack' && (
              <h2 className={cn('text-center text-[clamp(2.25rem,1.1rem+4.6vw,6rem)]', BIG)}>
                <span className="sr-only">{plain(lines)}</span>
                {lines.map((l, i) => (
                  <span key={i} aria-hidden className="block overflow-hidden pb-[0.14em]">
                    <span ref={set(`l${i}`)} className={cn('block will-change-transform', i === lines.length - 1 && 'kit-accent-text')}>
                      {l.replace(/\[\d+\]/g, '')}
                    </span>
                  </span>
                ))}
              </h2>
            )}
            {variant === 'outline' && (
              <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-end lg:gap-16">
              <h2 className={cn('text-[clamp(3.25rem,1rem+9vw,11rem)]', BIG)}>
                <span className="sr-only">{plain(lines)}</span>
                {lines.map((l, i) => (
                  <span key={i} aria-hidden className="relative block whitespace-nowrap">
                    <span className="block text-transparent [-webkit-text-stroke:1.5px_color-mix(in_oklab,var(--foreground)_45%,transparent)]">{l}</span>
                    <span ref={set(`f${i}`)} className={cn('absolute inset-0 block', i === lines.length - 1 ? 'text-primary' : 'text-foreground')} style={{ clipPath: 'inset(0 100% 0 0)' }}>
                      {l}
                    </span>
                  </span>
                ))}
              </h2>
              {after && (
                <div ref={set('after')} className="lg:pb-6" style={{ opacity: 0 }}>
                  {after}
                </div>
              )}
              </div>
            )}
            {variant !== 'scale' && variant !== 'outline' && after && (
              <div ref={set('after')} style={{ opacity: 0 }}>
                {after}
              </div>
            )}
            {variant === 'scale' && after && (
              <div ref={set('after2')} style={{ opacity: 0 }}>
                {after}
              </div>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

Frequent questions

How do I install Text scrub?

With aiw add @ai-website/text-scrub. The command puts components/ui/text-scrub.tsx into your project, the code is yours from then on. It brings along button, section and scroll-scene. It needs no further npm packages.

Which variants does Text scrub have?

5 variants, Sliding lines, Words come together, One word shrinks into place, Lines rise one by one and Outline fills. You pick one with the variant prop, for example variant="converge".

Can an AI assistant build with Text scrub?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Text scrub with aiw_components and install it with aiw_add @ai-website/text-scrub. Name the variant in your prompt, for example variant="lines", and the assistant writes your content straight into the props.

Can I adjust Text scrub without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching TextScrub code to copy.

Often searched

Jump to

Copied to the clipboard