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
Variants
Details
What Text scrub brings along, read from the registry and the source.
- Category
- Scroll effects
- Area
- Marketing
- 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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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'>.
variantoptionalTextScrubVariantlinesconvergescalestackoutline
linesrequiredstring[]The statement, one entry per line. In a line, `[0]` places picture 0 between the words.
eyebrowoptionalstringdescriptionoptionalstringText under the statement
actionoptional{ label: string; href: string }imagesoptionalReact.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
'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.