Skip to content
UI Kit
Accent color

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

4 variants

Variants

Giant word

One giant word across the page, the picture glides upwards through its letters while the section passes.

Open in Studio

Fly through the dot

A short sentence ends in a dot. While you scroll the camera flies through the dot into the picture behind, and the offer arrives on it.

Open in Studio

A picture per line

Three short lines, each a window onto its own picture. Lines and pictures drift in opposite directions while the section passes.

Open in Studio

Figure as a window

A large figure is the window onto the picture, its meaning and a few facts stand beside it. For heights, distances, years.

Open in Studio

Details

What Mask reveal brings along, read from the registry and the source.

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

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'>.

  • variantoptional
    MaskRevealVariant

    headlinezoom-throughlinesnumbers

  • linesrequired
    string[]

    The cut out text: one line (headline, zoom-through, numbers) or one entry per line (lines)

  • eyebrowoptional
    string
  • descriptionoptional
    string
  • actionoptional
    { label: string; href: string }
  • mediaoptional
    React.ReactNode

    The picture behind the letters; lines takes one per line

  • imagesoptional
    React.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
components/ui/mask-reveal.tsx
'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.

Often searched

Jump to

Copied to the clipboard