Zoom reveal
A picture or a product that zooms from small to the full window while the page scrolls. The heading steps back, the picture grows, and its own text arrives at the end. Without JavaScript the finished layout stands there.
One of 4 components in Scroll stories
Variants
Details
What Zoom reveal brings along, read from the registry and the source.
- Category
- Scroll stories
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/zoom-reveal.tsx
- npm packages
- None
- Languages
- English and German, 2 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ZoomRevealProps interface from components/ui/zoom-reveal.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalZoomRevealVariantimageproductvideogridapp-icon
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringmediaoptionalReact.ReactNodeThe picture that zooms: a photo, a screenshot in a BrowserFrame, a video poster, the phone screen
imagesoptionalReact.ReactNode[]grid: nine pictures, the fifth one zooms
iconoptionalReact.ReactNodeapp-icon: the icon the phone grows out of
overlayoptional{ eyebrow?: string; title: string; text?: string }Text that arrives on the picture at the end (image, video, grid)
notesoptionalZoomNote[]product, app-icon: short notes that appear around the screen
actionoptional{ label: string; href: string }durationoptionalstringvideo: length shown on the player
Built-in texts
Zoom reveal writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 2 texts
| Key | English | German |
|---|---|---|
| play | English Play film | German Film abspielen |
| pause | English Pause film | German Film anhalten |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-pressed, so screen readers announce purpose and state.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 487 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { PhoneFrame } from '@/components/ui/frame';
import { lerp, scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A picture or a product that zooms from small to the full screen while the page scrolls. The
* section stands still for about two screens, the heading steps back, the picture grows, and at the
* end its own text arrives on it. Only transform and opacity move, written straight onto the moving
* elements, so drawn or photographed pictures are never repainted while scrolling.
*
* Without JavaScript and with reduced motion there is no zoom: the section shows its finished
* state, heading, picture and text, in a calm normal layout.
*
* image a photo grows from a card to the whole window, a place and an offer arrive on it
* product a product screen grows from small to wide, notes appear on it
* video a film grows to the window, the play button lands in the middle
* grid nine pictures, the middle one fills the window while the others drift away
* app-icon an app icon grows into the phone with the app, its features arrive around it
*/
export type ZoomRevealVariant = 'image' | 'product' | 'video' | 'grid' | 'app-icon';
export interface ZoomNote {
label: string;
icon?: IconName;
}
export interface ZoomRevealProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: ZoomRevealVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
/** The picture that zooms: a photo, a screenshot in a BrowserFrame, a video poster, the phone screen */
media?: React.ReactNode;
/** grid: nine pictures, the fifth one zooms */
images?: React.ReactNode[];
/** app-icon: the icon the phone grows out of */
icon?: React.ReactNode;
/** Text that arrives on the picture at the end (image, video, grid) */
overlay?: { eyebrow?: string; title: string; text?: string };
/** product, app-icon: short notes that appear around the screen */
notes?: ZoomNote[];
action?: { label: string; href: string };
/** video: length shown on the player */
duration?: string;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { play: 'Play film', pause: 'Pause film' },
de: { play: 'Film abspielen', pause: 'Film anhalten' },
};
type Els = Record<string, HTMLElement | null>;
interface Box {
w: number;
h: number;
/** grid: size of the middle tile, measured with the stage */
tw: number;
th: number;
}
const tf = (el: HTMLElement | null, t: string) => {
if (el) el.style.transform = t;
};
const op = (el: HTMLElement | null, o: number) => {
if (el) el.style.opacity = String(round(o, 3));
};
/** Like op, and a piece that has faded out completely also leaves the page, so nothing hidden lies over the picture */
const opv = (el: HTMLElement | null, o: number) => {
op(el, o);
if (el) el.style.visibility = round(o, 3) <= 0 ? 'hidden' : '';
};
/* ------------------------------------------------------------------ */
/* What every variant does with the progress */
/* ------------------------------------------------------------------ */
const NOTE_AT = [0.62, 0.7, 0.78, 0.86];
function applyHead(e: Els, p: number, a = 0.18, b = 0.42) {
const x = smooth(span(p, a, b));
op(e.head, 1 - x);
tf(e.head, `translate3d(0, ${round(-48 * x, 1)}px, 0)`);
}
function applyOverlay(e: Els, p: number) {
const x = smooth(span(p, 0.7, 0.9));
op(e.shade, span(p, 0.55, 0.85));
op(e.over, x);
tf(e.over, `translate3d(0, ${round(28 * (1 - x), 1)}px, 0)`);
}
const APPLY: Record<ZoomRevealVariant, (p: number, e: Els, b: Box) => void> = {
image(p, e, b) {
const z = smooth(span(p, 0.06, 0.68));
const s0 = b.w < 640 ? 0.6 : 0.36;
tf(e.media, `translate3d(0, ${round(lerp(b.h * (b.w < 640 ? 0.2 : 0.17), 0, z), 1)}px, 0) scale(${round(lerp(s0, 1, z), 4)})`);
tf(e.img, `scale(${round(lerp(1.3, 1, z), 4)})`);
applyHead(e, p);
applyOverlay(e, p);
},
video(p, e, b) {
APPLY.image(p, e, b);
const x = smooth(span(p, 0.66, 0.84));
op(e.play, x);
tf(e.play, `scale(${round(lerp(0.6, 1, x), 3)})`);
},
product(p, e, b) {
const z = smooth(span(p, 0.06, 0.66));
tf(e.media, `translate3d(0, ${round(lerp(b.h * (b.w < 640 ? 0.26 : 0.2), 0, z), 1)}px, 0) scale(${round(lerp(b.w < 640 ? 0.78 : 0.6, 1, z), 4)})`);
applyHead(e, p, 0.2, 0.46);
NOTE_AT.forEach((at, i) => {
const x = smooth(span(p, at, at + 0.1));
for (const k of [`note${i}`, `mnote${i}`]) {
opv(e[k], x);
tf(e[k], `translate3d(0, ${round(12 * (1 - x), 1)}px, 0) scale(${round(lerp(0.92, 1, x), 3)})`);
}
});
},
grid(p, e, b) {
const z = smooth(span(p, 0.1, 0.7));
// The middle tile grows until it covers the window, the others move out by the same rate
const tw = b.tw || 1;
const th = b.th || 1;
const full = Math.max(b.w / tw, b.h / th) * 1.02;
const s = lerp(1, full, z);
tf(e.t4, `scale(${round(s, 4)})`);
for (let i = 0; i < 9; i++) {
if (i === 4) continue;
const col = (i % 3) - 1;
const row = Math.floor(i / 3) - 1;
// Each neighbour stays next to the growing tile, so they move out as one picture plane
const dx = col * (tw * (s - 1) * 0.5);
const dy = row * (th * (s - 1) * 0.5);
tf(e[`t${i}`], `translate3d(${round(dx, 1)}px, ${round(dy, 1)}px, 0)`);
opv(e[`t${i}`], 1 - span(p, 0.3, 0.62));
}
applyHead(e, p, 0.08, 0.3);
applyOverlay(e, p);
},
'app-icon'(p, e, b) {
const z = smooth(span(p, 0.12, 0.6));
const end = b.w < 640 ? Math.min(0.78, (b.h * 0.6) / 540) : Math.min(1, (b.h * 0.78) / 540);
tf(e.media, `translate3d(0, ${round(lerp(b.h * 0.08, 0, z), 1)}px, 0) scale(${round(lerp(0.3, end, z), 4)})`);
// The icon gives way to the phone halfway through the growth
const x = smooth(span(p, 0.22, 0.42));
opv(e.icon, 1 - x);
opv(e.phone, x);
applyHead(e, p, 0.1, 0.32);
NOTE_AT.forEach((at, i) => {
const y = smooth(span(p, at, at + 0.1));
opv(e[`note${i}`], y);
tf(e[`note${i}`], `translate3d(${round((i % 2 ? 16 : -16) * (1 - y), 1)}px, 0, 0)`);
opv(e[`mnote${i}`], y);
tf(e[`mnote${i}`], `translate3d(0, ${round(12 * (1 - y), 1)}px, 0)`);
});
},
};
/* ------------------------------------------------------------------ */
/* Pieces */
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, center = true, className }: { eyebrow?: string; title: React.ReactNode; description?: string; center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h1 font-semibold text-foreground">{title}</h2>
{description && <p className={cn('mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</div>
);
}
function OverlayText({ overlay, action }: Pick<ZoomRevealProps, 'overlay' | 'action'>) {
if (!overlay) return null;
return (
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[34rem]">
{overlay.eyebrow && <p className="text-[13.5px] font-medium text-[color-mix(in_oklab,var(--primary)_35%,var(--foreground))]">{overlay.eyebrow}</p>}
<h3 className="mt-2 text-balance text-h2 font-semibold text-foreground">{overlay.title}</h3>
{overlay.text && <p className="mt-3 max-w-[30rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{overlay.text}</p>}
</div>
{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>
)}
</div>
);
}
/** A dark veil from the bottom so the overlay text reads on any picture */
const SHADE = 'dark pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_92%,transparent)_0%,color-mix(in_oklab,var(--background)_55%,transparent)_38%,transparent_70%)]';
function PlayButton({ playing, onToggle, t, duration }: { playing: boolean; onToggle: () => void; t: (typeof TEXTS)['en']; duration?: string }) {
return (
<button
type="button"
onClick={onToggle}
aria-pressed={playing}
aria-label={playing ? t.pause : t.play}
className="group flex items-center gap-3 rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)] py-2 pl-2 pr-5 text-[14px] font-medium text-foreground shadow-[var(--shadow-lg)] backdrop-blur-md transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-[1.04] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-primary motion-reduce:transition-none"
>
<span className="flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon name={playing ? 'pause' : 'play'} size={20} />
</span>
<span>{playing ? t.pause : t.play}</span>
{duration && <span className="tabular-nums text-muted-foreground">{duration}</span>}
</button>
);
}
function Progress({ playing }: { playing: boolean }) {
return (
<span aria-hidden className="absolute inset-x-0 bottom-0 h-1 bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]">
<span className={cn('absolute inset-0 origin-left bg-primary', playing ? 'animate-kit-progress' : 'scale-x-[0.18]')} style={{ '--kit-duration': '30s' } as React.CSSProperties} />
</span>
);
}
// product: the notes sit on the top and bottom edge of the screen, half outside it. There they only
// touch the window bar and the bottom margin of the screenshot, never its text or figures.
const NOTE_POS = ['left-[12%] top-0 -translate-y-1/2', 'right-[6%] top-0 -translate-y-1/2', 'left-[8%] bottom-0 translate-y-1/2', 'right-[10%] bottom-0 translate-y-1/2'];
function Note({ note, className, setRef }: { note: ZoomNote; className?: string; setRef?: (n: HTMLElement | null) => void }) {
return (
<span ref={setRef} className={cn('inline-flex items-center gap-2 rounded-full border border-border bg-card px-3.5 py-2 text-[13px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)]', className)}>
<span className="flex size-6 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name={note.icon ?? 'check'} size={13} />
</span>
{note.label}
</span>
);
}
/* ------------------------------------------------------------------ */
/** A picture or product that zooms to the full window while the page scrolls. */
export function ZoomReveal({ variant = 'image', eyebrow, title, description, media, images, icon, overlay, notes, action, duration, labels, className, onFocus, ...rest }: ZoomRevealProps) {
const t = useTexts(TEXTS, labels);
const live = useLive();
const els = useRef<Els>({});
const box = useRef<Box>({ w: 0, h: 0, tw: 0, th: 0 });
const last = useRef(0);
const [playing, setPlaying] = useState(false);
const set = (k: string) => (n: HTMLElement | null) => {
els.current[k] = n;
};
const apply = useCallback(
(p: number) => {
last.current = p;
APPLY[variant](p, els.current, box.current);
},
[variant],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
// The stage size, measured when the scene goes live and on resize, never while scrolling
useEffect(() => {
if (!live) return;
const stage = els.current.stage;
if (!stage) return;
const measure = () => {
const tile = els.current.t4;
box.current = { w: stage.clientWidth, h: stage.clientHeight, tw: tile?.offsetWidth ?? 0, th: tile?.offsetHeight ?? 0 };
apply(last.current);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(stage);
return () => ro.disconnect();
}, [live, apply]);
const list = notes ?? [];
const screens = variant === 'grid' ? 3 : 2.8;
/* ---------- Finished, static layout: no JavaScript, reduced motion, server ---------- */
if (!live) {
return (
<section {...rest} onFocus={onFocus} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
<div className="kit-container">
<Reveal>
<Head eyebrow={eyebrow} title={title} description={description} />
</Reveal>
<div className="mt-12 sm:mt-16">
{(variant === 'image' || variant === 'video') && (
<div className="relative isolate aspect-[4/5] overflow-hidden rounded-2xl bg-surface sm:aspect-[16/9] [&>.m>*]:size-full">
<div className="m absolute inset-0 -z-10">{media}</div>
<span className={cn(SHADE, '-z-10')} />
{variant === 'video' && (
// On a phone the text below is taller, so the button sits higher, clear of it
<div className="absolute inset-0 flex items-center justify-center pb-[38%] sm:pb-0">
<PlayButton playing={playing} onToggle={() => setPlaying((v) => !v)} t={t} duration={duration} />
</div>
)}
<div className="dark absolute inset-x-0 bottom-0 p-6 sm:p-10">
<OverlayText overlay={overlay} action={action} />
</div>
{variant === 'video' && <Progress playing={playing} />}
</div>
)}
{variant === 'product' && (
<div className="relative">
<div className="mx-auto max-w-[68rem]">{media}</div>
{list.length > 0 && (
<ul className="mt-8 flex flex-wrap justify-center gap-3">
{list.map((n, i) => (
<li key={i}>
<Note note={n} />
</li>
))}
</ul>
)}
</div>
)}
{variant === 'grid' && (
<div>
<div className="grid grid-cols-3 gap-2 sm:gap-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} className={cn('relative aspect-[4/3] overflow-hidden rounded-xl bg-surface [&>*]:size-full', i === 4 && 'ring-2 ring-primary ring-offset-2 ring-offset-background')}>
{images?.[i % (images.length || 1)]}
</div>
))}
</div>
<div className="mt-10">
<OverlayText overlay={overlay} action={action} />
</div>
</div>
)}
{variant === 'app-icon' && (
<div className="grid grid-cols-1 items-center gap-3 md:grid-cols-[1fr_auto_1fr] md:gap-10">
<ul className="order-2 grid grid-cols-1 gap-3 sm:grid-cols-2 md:order-1 md:grid-cols-1 md:justify-items-end">
{list.filter((_, i) => i % 2 === 0).map((n, i) => (
<li key={i}>
<Note note={n} />
</li>
))}
</ul>
<div className="order-1 mx-auto mb-7 md:order-2 md:mb-0">
<PhoneFrame className="w-[min(260px,70vw)]">{media}</PhoneFrame>
</div>
<ul className="order-3 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-1 md:justify-items-start">
{list.filter((_, i) => i % 2 === 1).map((n, i) => (
<li key={i}>
<Note note={n} />
</li>
))}
</ul>
</div>
)}
</div>
</div>
</section>
);
}
/* ---------- Live scene ---------- */
// The play button and the closing action wait for the end of the scene; tabbing to them goes there
const onSceneFocus = (e: React.FocusEvent<HTMLElement>) => {
onFocus?.(e);
const target = e.target as Node;
if (els.current.over?.contains(target) || els.current.play?.contains(target)) scrollToProgress(track.current, 0.97);
};
const head = (
<div ref={set('head')} className="relative z-20 px-6 pt-[clamp(2.5rem,9svh,6rem)] will-change-transform">
<Head eyebrow={eyebrow} title={title} description={description} />
</div>
);
return (
<section {...rest} onFocus={onSceneFocus} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${screens * 100}svh` }}>
<div ref={set('stage')} className="sticky top-0 h-svh overflow-clip">
{(variant === 'image' || variant === 'video') && (
<>
{head}
<div ref={set('media')} className="absolute inset-2 isolate overflow-hidden rounded-2xl bg-surface will-change-transform sm:inset-3">
<div ref={set('img')} data-clip-ok className="absolute inset-0 will-change-transform [&>*]:size-full">
{media}
</div>
<span ref={set('shade')} className={SHADE} style={{ opacity: 0 }} />
</div>
{variant === 'video' && (
<div ref={set('play')} className="absolute inset-0 z-20 flex items-center justify-center" style={{ opacity: 0 }}>
<PlayButton playing={playing} onToggle={() => setPlaying((v) => !v)} t={t} duration={duration} />
</div>
)}
<div ref={set('over')} className="dark absolute inset-x-2 bottom-2 z-20 sm:inset-x-3 sm:bottom-3" style={{ opacity: 0 }}>
<div className="kit-container pb-8 sm:pb-12">
<OverlayText overlay={overlay} action={action} />
</div>
{variant === 'video' && <Progress playing={playing} />}
</div>
</>
)}
{variant === 'product' && (
<>
{head}
<div className="absolute inset-0 flex items-center justify-center px-4 sm:px-8">
<div ref={set('media')} className="relative w-[min(68rem,100%,125svh)] will-change-transform">
{media}
{list.slice(0, 4).map((n, i) => (
<Note key={i} note={n} setRef={set(`note${i}`)} className={cn('absolute z-10 hidden sm:inline-flex', NOTE_POS[i])} />
))}
</div>
</div>
<ul className="absolute inset-x-4 bottom-4 z-20 grid grid-cols-2 gap-2 sm:hidden">
{list.slice(0, 4).map((n, i) => (
<li key={i} ref={set(`mnote${i}`)} style={{ opacity: 0 }}>
<Note note={n} className="w-full text-[12.5px]" />
</li>
))}
</ul>
</>
)}
{variant === 'grid' && (
<>
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 gap-2 p-2 sm:gap-3 sm:p-3">
{Array.from({ length: 9 }, (_, i) => (
<div key={i} ref={set(`t${i}`)} className={cn('relative overflow-hidden rounded-xl bg-surface will-change-transform [&>*]:size-full', i === 4 && 'z-10')}>
{images?.[i % (images.length || 1)]}
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div ref={set('head')} className="pointer-events-auto mx-6 max-w-[40rem] rounded-2xl border border-border bg-[color-mix(in_oklab,var(--background)_82%,transparent)] p-6 text-center shadow-[var(--shadow-lg)] backdrop-blur-md will-change-transform sm:p-8">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && <p className="mx-auto mt-4 max-w-[30rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
</div>
<span ref={set('shade')} className={cn(SHADE, 'z-10')} style={{ opacity: 0 }} />
<div ref={set('over')} className="dark absolute inset-x-0 bottom-0 z-20" style={{ opacity: 0 }}>
<div className="kit-container pb-8 sm:pb-12">
<OverlayText overlay={overlay} action={action} />
</div>
</div>
</>
)}
{variant === 'app-icon' && (
<>
{head}
<div className="absolute inset-0 flex items-center justify-center pt-[6svh]">
<div ref={set('media')} className="relative will-change-transform">
<div ref={set('phone')} style={{ opacity: 0 }}>
<PhoneFrame className="w-[260px]">{media}</PhoneFrame>
</div>
<div ref={set('icon')} className="absolute inset-x-0 top-1/2 flex aspect-square -translate-y-1/2 items-center justify-center overflow-hidden rounded-[58px] shadow-[var(--shadow-lg)] [&>*]:size-full">
{icon}
</div>
{list.slice(0, 4).map((n, i) => (
<Note
key={i}
note={n}
setRef={set(`note${i}`)}
className={cn('absolute z-10 hidden whitespace-nowrap md:inline-flex', i % 2 ? 'left-[calc(100%+2.5rem)]' : 'right-[calc(100%+2.5rem)]', ['top-[18%]', 'top-[34%]', 'top-[58%]', 'top-[74%]'][i])}
/>
))}
</div>
</div>
<ul className="absolute inset-x-4 bottom-4 z-20 grid grid-cols-2 gap-2 md:hidden">
{list.slice(0, 4).map((n, i) => (
<li key={i} ref={set(`mnote${i}`)} style={{ opacity: 0 }}>
<Note note={n} className="w-full text-[12.5px]" />
</li>
))}
</ul>
</>
)}
</div>
</div>
</section>
);
}Frequent questions
How do I install Zoom reveal?
With aiw add @ai-website/zoom-reveal. The command puts components/ui/zoom-reveal.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, frame and scroll-scene. It needs no further npm packages.
Which variants does Zoom reveal have?
5 variants, Photo to full window, Product screen, Film, Grid to one picture and App icon to phone. You pick one with the variant prop, for example variant="product".
Can I translate the texts of Zoom reveal?
Yes. Zoom reveal carries 2 texts of its own in English and German, such as “Play film” and “Pause film”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Zoom reveal?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Zoom reveal with aiw_components and install it with aiw_add @ai-website/zoom-reveal. Name the variant in your prompt, for example variant="image", and the assistant writes your content straight into the props.
Can I adjust Zoom reveal without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ZoomReveal code to copy.