Text im Scroll
Große Schrift, die sich genau mit dem Scroll bewegt, eine Aussage pro Seite. Zeilen gleiten gegeneinander, Wörter finden zusammen, ein Wort schrumpft an seinen Platz, Zeilen steigen auf oder ein Umriss füllt sich. Ohne JavaScript steht die Aussage fertig da.
Eine von 3 Components in Scroll-Effekte
Variants
Details
Was Text im Scroll mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Effekte
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/text-scrub.tsx
- Bringt mit
- utilskitbuttonsectionscroll-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TextScrubProps aus components/ui/text-scrub.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalTextScrubVariantlinesconvergescalestackoutline
linesPflichtstring[]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
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 286 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Text im Scroll?
Mit aiw add @ai-website/text-scrub. Der Befehl legt die Datei components/ui/text-scrub.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, section und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Text im Scroll?
5 Varianten, Gleitende Zeilen, Wörter finden zusammen, Ein Wort schrumpft an seinen Platz, Zeilen steigen nacheinander und Umriss füllt sich. Du wählst sie mit der Prop variant, zum Beispiel variant="converge".
Kann ein KI-Assistent Text im Scroll einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Text im Scroll mit aiw_components und installieren sie mit aiw_add @ai-website/text-scrub. Nenn im Prompt die Variante, etwa variant="lines", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Text im Scroll ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden TextScrub-Code zum Kopieren.