Skip to content
UI Kit
Accent color

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

5 variants

Variants

Photo to full window

A photo grows from a card below the heading to the whole window, and settles from a slight zoom inside. The place and an offer arrive on it at the end.

Open in Studio

Product screen

A screenshot rises from below the heading and grows to the width of the page, then four notes appear on it one after another. On phones the notes line up underneath.

Open in Studio

Film

The poster of a film grows to the window, the play button lands in the middle and starts the film. Use it for an image film or a product video.

Open in Studio

Grid to one picture

Nine pictures fill the window. The middle one grows until it covers everything, the others drift out with it, and the closing text arrives on it.

Open in Studio

App icon to phone

An app icon grows into the phone that shows the app, then its features arrive on both sides. On phones they line up below.

Open in Studio

Details

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

Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/zoom-reveal.tsx
Brings along
utilskitbuttoniconrevealsectionframescroll-scene
npm packages
None
Languages
English and German, 2 texts of its own

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

  • variantoptional
    ZoomRevealVariant

    imageproductvideogridapp-icon

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode
  • descriptionoptional
    string
  • mediaoptional
    React.ReactNode

    The picture that zooms: a photo, a screenshot in a BrowserFrame, a video poster, the phone screen

  • imagesoptional
    React.ReactNode[]

    grid: nine pictures, the fifth one zooms

  • iconoptional
    React.ReactNode

    app-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)

  • notesoptional
    ZoomNote[]

    product, app-icon: short notes that appear around the screen

  • actionoptional
    { label: string; href: string }
  • durationoptional
    string

    video: 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
playEnglish Play filmGerman Film abspielen
pauseEnglish Pause filmGerman 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
components/ui/zoom-reveal.tsx
'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.

Often searched

Jump to

Copied to the clipboard