Text als Fenster
Schrift als Fenster auf ein Bild. Die Buchstaben sind aus der Seite geschnitten, das Bild bewegt sich beim Scrollen dahinter, und in einer Variante fliegt die Kamera durch einen Punkt ins Bild. Echter, markierbarer Text in hell und dunkel.
Eine von 3 Components in Scroll-Effekte
Variants
Details
Was Text als Fenster mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Effekte
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/mask-reveal.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 MaskRevealProps aus components/ui/mask-reveal.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalMaskRevealVariantheadlinezoom-throughlinesnumbers
linesPflichtstring[]The cut out text: one line (headline, zoom-through, numbers) or one entry per line (lines)
eyebrowoptionalstringdescriptionoptionalstringactionoptional{ label: string; href: string }mediaoptionalReact.ReactNodeThe picture behind the letters; lines takes one per line
imagesoptionalReact.ReactNode[]factsoptional{ value: string; label: string }[]numbers: small facts beside the figure
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
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, 301 Zeilen
'use client';
import { useCallback, useEffect, useId, 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';
/*
* Type as a window onto a picture. The letters are cut out of a layer in the page color, so the
* picture shows only through them, and the picture moves behind them while the page scrolls.
*
* How the cut works: an SVG over the picture paints a rectangle in the page color with an SVG mask
* that holds the text in black, so the letters are holes. Fully opaque in light and dark mode and in
* every accent, in the one typeface. An invisible copy of the text in HTML gives the window its
* size, and the statement is there once more as a heading for screen readers.
*
* Without JavaScript and with reduced motion the picture rests behind the letters, readable.
*
* headline one giant line, the picture glides through it
* zoom-through a sentence ending in a dot, the camera flies through the dot into the picture
* lines three short lines, each a window onto its own picture
* numbers a large figure as the window, its caption beside it
*/
export type MaskRevealVariant = 'headline' | 'zoom-through' | 'lines' | 'numbers';
export interface MaskRevealProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: MaskRevealVariant;
/** The cut out text: one line (headline, zoom-through, numbers) or one entry per line (lines) */
lines: string[];
eyebrow?: string;
description?: string;
action?: { label: string; href: string };
/** The picture behind the letters; lines takes one per line */
media?: React.ReactNode;
images?: React.ReactNode[];
/** numbers: small facts beside the figure */
facts?: { value: string; label: string }[];
}
/** Normal line height on purpose: the window is as tall as the line box, so descenders stay inside it */
const BIG = 'font-semibold tracking-[-0.055em]';
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));
};
/**
* A rectangle in the page color with the text cut out of it. Without a viewBox one SVG unit is one
* CSS pixel, so the text takes the same font size classes as the HTML around it.
*/
function Cut({ text, textClass, anchor = 'middle', textRef, dotRef, bleed }: { text: string; textClass?: string; anchor?: 'start' | 'middle'; textRef?: (n: SVGTextElement | null) => void; dotRef?: (n: SVGCircleElement | null) => void; bleed?: boolean }) {
const id = useId().replace(/:/g, '');
// In a window the SVG reaches 4 px past every side of the box, so its own edge never shows; when it
// grows (zoom-through) the rectangle reaches far beyond
const ext = bleed ? { x: '-50%', y: '-50%', width: '200%', height: '200%' } : { x: '0', y: '0', width: '100%', height: '100%' };
return (
<svg aria-hidden className={cn('absolute', bleed ? 'inset-0 size-full overflow-visible' : '-inset-1 h-[calc(100%+0.5rem)] w-[calc(100%+0.5rem)]')}>
<defs>
<mask id={`${id}m`} maskUnits="userSpaceOnUse" {...ext}>
<rect {...ext} fill="#fff" />
<text ref={textRef} x={anchor === 'middle' ? '50%' : bleed ? '0' : '4'} y="50%" textAnchor={anchor} dominantBaseline="central" fill="#000" className={cn(BIG, textClass)}>
{text}
</text>
{dotRef && <circle ref={dotRef} r="0" fill="#000" />}
</mask>
</defs>
<rect {...ext} fill="var(--background)" mask={`url(#${id}m)`} />
</svg>
);
}
/** A picture with the cut text on top, sized by an invisible copy of the text */
function Window({ text, media, mediaRef, className, textClass, anchor = 'middle' }: { text: string; media: React.ReactNode; mediaRef?: (n: HTMLElement | null) => void; className?: string; textClass?: string; anchor?: 'start' | 'middle' }) {
return (
<div data-clip-ok className={cn('relative isolate', className)}>
{/*
The picture is clipped 2 px inside the window and the cover reaches 4 px beyond it. Two clips on
the same edge would each blend their edge pixel, and a hairline of picture would show there.
*/}
<div className="absolute inset-[2px] overflow-hidden">
<div ref={mediaRef} aria-hidden data-clip-ok className="absolute -inset-x-[20%] -inset-y-[18%] will-change-transform [&>*]:size-full">
{media}
</div>
</div>
<Cut text={text} textClass={textClass} anchor={anchor} />
<p aria-hidden className={cn(BIG, 'invisible relative whitespace-nowrap', textClass)}>
{text}
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/** Type as a window onto a picture that moves while the page scrolls. */
export function MaskReveal({ variant = 'headline', lines, eyebrow, description, action, media, images = [], facts = [], className, ...rest }: MaskRevealProps) {
const live = useLive();
const els = useRef<Els>({});
const box = useRef({ ox: 0, oy: 0, w: 0, h: 0 });
const svgText = useRef<SVGTextElement | null>(null);
const svgDot = useRef<SVGCircleElement | null>(null);
const lastP = useRef(0);
const set = (k: string) => (n: HTMLElement | null) => {
els.current[k] = n;
};
const apply = useCallback(
(p: number) => {
lastP.current = p;
const e = els.current;
if (variant === 'headline' || variant === 'numbers') {
// The picture glides upwards behind the letters, a little slower than the page
tf(e.m0, `translate3d(0, ${round((0.5 - p) * 18, 2)}%, 0) scale(1.06)`);
}
if (variant === 'lines') {
lines.forEach((_, i) => {
tf(e[`m${i}`], `translate3d(${round((i % 2 ? -1 : 1) * (p - 0.5) * 16, 2)}%, 0, 0) scale(1.12)`);
tf(e[`l${i}`], `translate3d(${round((i % 2 ? 1 : -1) * (p - 0.5) * 8, 2)}vw, 0, 0)`);
});
}
if (variant === 'zoom-through') {
const k = span(p, 0.12, 0.8);
// Exponential growth reads as a steady flight; 1 → 140 times, enough for the dot to cover any window
const s = Math.pow(140, smooth(k));
const { ox, oy } = box.current;
tf(e.cut, `translate3d(${round(-ox * (s - 1), 1)}px, ${round(-oy * (s - 1), 1)}px, 0) scale(${round(s, 3)})`);
// The layer leaves once the hole covers the window
op(e.cut, 1 - span(p, 0.74, 0.8));
tf(e.m0, `scale(${round(lerp(1.25, 1, smooth(k)), 4)})`);
op(e.head, 1 - span(p, 0.05, 0.25));
const d = smooth(span(p, 0.8, 0.95));
op(e.after, d);
tf(e.after, `translate3d(0, ${round(24 * (1 - d), 1)}px, 0)`);
}
},
[variant, lines],
);
const track = useSceneProgress<HTMLDivElement>(live, { mode: variant === 'zoom-through' ? 'pin' : 'pass', self: false, onProgress: apply });
// zoom-through: the point to fly into is the middle of the dot, measured from the cut layer
useEffect(() => {
if (!live || variant !== 'zoom-through') return;
const measure = () => {
const cut = els.current.cut;
const text = svgText.current;
const dot = svgDot.current;
if (!cut || !text || !dot) return;
cut.style.transform = '';
// The dot sits on the baseline right after the last letter, a fifth of the font size wide
const size = parseFloat(getComputedStyle(text).fontSize) || 100;
const n = text.getNumberOfChars();
const end = n ? text.getEndPositionOfChar(n - 1) : { x: 0, y: 0 };
const base = n ? text.getStartPositionOfChar(0).y : 0;
const rr = size * 0.1;
const cx = end.x + size * 0.04 + rr;
// dominant-baseline central puts y in the middle of the em box, the baseline sits about 0.35 em below it
const cy = base + size * 0.35 - rr;
dot.setAttribute('r', String(round(rr, 1)));
dot.setAttribute('cx', String(round(cx, 1)));
dot.setAttribute('cy', String(round(cy, 1)));
box.current = { ox: cx, oy: cy, w: cut.clientWidth, h: cut.clientHeight };
cut.style.transformOrigin = '0 0';
apply(lastP.current);
};
measure();
const ro = new ResizeObserver(measure);
if (els.current.stage) ro.observe(els.current.stage);
return () => ro.disconnect();
}, [live, variant, apply]);
useEffect(() => {
if (live) return;
Object.values(els.current).forEach((el) => {
el?.style.removeProperty('transform');
el?.style.removeProperty('opacity');
});
}, [live]);
const actionEl = action && (
<div className="max-sm:w-full max-sm:[&>span]:w-full sm:shrink-0">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
);
const text = lines.join(' ');
if (variant === 'zoom-through' && live) {
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: '280svh' }}>
<div ref={set('stage')} data-clip-ok className="sticky top-0 h-svh overflow-clip">
<div ref={set('m0')} aria-hidden className="absolute inset-0 will-change-transform [&>*]:size-full">
{media}
</div>
{/* The cut layer is the window, its page colored rectangle reaches far beyond, so no edge shows while it grows */}
{/* No will-change here: the browser draws the scaled letters fresh in every frame and keeps their edges sharp */}
<div ref={set('cut')} className="absolute inset-0">
<Cut
bleed
text={text}
textClass="text-[clamp(3rem,1rem+8.5vw,10rem)]"
textRef={(n) => {
svgText.current = n;
}}
dotRef={(n) => {
svgDot.current = n;
}}
/>
</div>
<h2 className="sr-only">{text}</h2>
<div ref={set('head')} className="pointer-events-none absolute inset-x-0 top-[12svh] text-center">
{eyebrow && <Eyebrow className="mb-0">{eyebrow}</Eyebrow>}
</div>
<div ref={set('after')} className="dark absolute inset-x-0 bottom-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_88%,transparent),transparent)] pt-24" style={{ opacity: 0 }}>
<div className="kit-container flex flex-col gap-6 pb-10 sm:flex-row sm:items-end sm:justify-between sm:pb-14">
{description && <p className="max-w-[32rem] text-pretty text-lead text-foreground">{description}</p>}
{actionEl}
</div>
</div>
</div>
</div>
</section>
);
}
return (
<section {...rest} className={cn('relative overflow-hidden py-20 sm:py-28 lg:py-32', className)}>
<div ref={track}>
{(variant === 'headline' || variant === 'zoom-through') && (
<>
<div className="kit-container">{eyebrow && <Eyebrow className="text-center">{eyebrow}</Eyebrow>}</div>
<h2 className="sr-only">{text}</h2>
<div aria-hidden>
<Window text={variant === 'zoom-through' ? `${text}.` : text} media={media} mediaRef={set('m0')} className="text-center" textClass="text-[clamp(4.5rem,1rem+15vw,17rem)] py-[0.04em]" />
</div>
{(description || action) && (
<div className="kit-container mt-10 flex flex-col items-center gap-6 text-center">
{description && <p className="max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{actionEl}
</div>
)}
</>
)}
{variant === 'lines' && (
<>
<div className="kit-container">{eyebrow && <Eyebrow className="text-center">{eyebrow}</Eyebrow>}</div>
<h2 className="sr-only">{text}</h2>
<div aria-hidden className="grid gap-[0.04em]">
{lines.map((l, i) => (
<div key={i} className={cn('flex', i % 2 ? 'justify-end' : 'justify-start')}>
<div ref={set(`l${i}`)}>
<Window text={l} media={images[i % Math.max(1, images.length)]} mediaRef={set(`m${i}`)} className="px-[4vw]" textClass="py-[0.04em] text-[clamp(5.5rem,1rem+12vw,14rem)]" />
</div>
</div>
))}
</div>
{(description || action) && (
<div className="kit-container mt-10 flex flex-col items-center gap-6 text-center">
{description && <p className="max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{actionEl}
</div>
)}
</>
)}
{variant === 'numbers' && (
<div className="kit-container grid grid-cols-1 items-end gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-16">
<div>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="sr-only">{text}</h2>
<div aria-hidden>
<Window text={text} media={media} mediaRef={set('m0')} anchor="start" textClass="text-[clamp(4.5rem,1rem+12vw,12.5rem)] tabular-nums" />
</div>
</div>
<div className="pb-[2%]">
{description && <p className="text-pretty text-lead text-foreground">{description}</p>}
{facts.length > 0 && (
<dl className="mt-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border">
{facts.map((f, i) => (
<div key={i} className="flex flex-col-reverse bg-card p-4">
<dt className="mt-1 text-[13px] text-muted-foreground">{f.label}</dt>
<dd className="text-[20px] font-semibold tabular-nums text-foreground">{f.value}</dd>
</div>
))}
</dl>
)}
{actionEl && <div className="mt-8">{actionEl}</div>}
</div>
</div>
)}
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Text als Fenster?
Mit aiw add @ai-website/mask-reveal. Der Befehl legt die Datei components/ui/mask-reveal.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 als Fenster?
4 Varianten, Riesiges Wort, Durch den Punkt fliegen, Ein Bild je Zeile und Zahl als Fenster. Du wählst sie mit der Prop variant, zum Beispiel variant="zoom-through".
Kann ein KI-Assistent Text als Fenster einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Text als Fenster mit aiw_components und installieren sie mit aiw_add @ai-website/mask-reveal. Nenn im Prompt die Variante, etwa variant="headline", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Text als Fenster ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MaskReveal-Code zum Kopieren.