Mask reveal
Type as a window onto a picture. The letters are cut out of the page, the picture moves behind them while you scroll, and in one variant the camera flies through a dot into the picture. Real, selectable text in light and dark mode.
One of 3 components in Scroll effects
Variants
Details
What Mask reveal brings along, read from the registry and the source.
- Category
- Scroll effects
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/mask-reveal.tsx
- Brings along
- utilskitbuttonsectionscroll-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MaskRevealProps interface from components/ui/mask-reveal.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalMaskRevealVariantheadlinezoom-throughlinesnumbers
linesrequiredstring[]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
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
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, 301 lines
'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>
);
}Frequent questions
How do I install Mask reveal?
With aiw add @ai-website/mask-reveal. The command puts components/ui/mask-reveal.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 Mask reveal have?
4 variants, Giant word, Fly through the dot, A picture per line and Figure as a window. You pick one with the variant prop, for example variant="zoom-through".
Can an AI assistant build with Mask reveal?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Mask reveal with aiw_components and install it with aiw_add @ai-website/mask-reveal. Name the variant in your prompt, for example variant="headline", and the assistant writes your content straight into the props.
Can I adjust Mask reveal without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching MaskReveal code to copy.