Zum Inhalt springen
UI Kit
Accent

Zoom beim Scrollen

Ein Bild oder Produkt, das beim Scrollen von klein auf das ganze Fenster wächst. Die Überschrift tritt zurück, das Bild wächst, am Ende kommt sein eigener Text. Ohne JavaScript steht das fertige Layout da.

Eine von 4 Components in Scroll-Geschichten

5 Variants

Variants

Foto bis zum Fensterrand

Ein Foto wächst von einer Karte unter der Überschrift bis ins ganze Fenster und beruhigt sich dabei aus einem leichten Zoom. Am Ende kommen Ort und Angebot darauf.

Im Studio öffnen

Produktbildschirm

Ein Screenshot steigt unter der Überschrift auf und wächst auf Seitenbreite, dann erscheinen nacheinander vier Hinweise darauf. Auf dem Telefon reihen sie sich darunter.

Im Studio öffnen

Film

Das Standbild eines Films wächst bis ins Fenster, der Abspielknopf landet in der Mitte und startet den Film. Für Imagefilm oder Produktvideo.

Im Studio öffnen

Raster zu einem Bild

Neun Bilder füllen das Fenster. Das mittlere wächst, bis es alles bedeckt, die anderen gleiten mit hinaus, und der Schlusstext kommt darauf.

Im Studio öffnen

App-Icon zum Telefon

Ein App-Icon wächst zum Telefon mit der App, dann kommen ihre Funktionen von beiden Seiten. Auf dem Telefon reihen sie sich darunter.

Im Studio öffnen

Details

Was Zoom beim Scrollen mitbringt, aus Registry und Quellcode gelesen.

Bereich
Marketing
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/zoom-reveal.tsx
Bringt mit
utilskitbuttoniconrevealsectionframescroll-scene
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 2 eigene Texte

Props

Die Schnittstelle ZoomRevealProps aus components/ui/zoom-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.

  • variantoptional
    ZoomRevealVariant

    imageproductvideogridapp-icon

  • eyebrowoptional
    string
  • titlePflicht
    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

Eingebaute Texte

Diese Texte schreibt Zoom beim Scrollen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 2 Texte anzeigen
playEnglisch Play filmDeutsch Film abspielen
pauseEnglisch Pause filmDeutsch Film anhalten

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 487 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Zoom beim Scrollen?

Mit aiw add @ai-website/zoom-reveal. Der Befehl legt die Datei components/ui/zoom-reveal.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, frame und scroll-scene. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Zoom beim Scrollen?

5 Varianten, Foto bis zum Fensterrand, Produktbildschirm, Film, Raster zu einem Bild und App-Icon zum Telefon. Du wählst sie mit der Prop variant, zum Beispiel variant="product".

Kann ich die Texte von Zoom beim Scrollen übersetzen?

Ja. Zoom beim Scrollen bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Film abspielen“ und „Film anhalten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zoom beim Scrollen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zoom beim Scrollen mit aiw_components und installieren sie mit aiw_add @ai-website/zoom-reveal. Nenn im Prompt die Variante, etwa variant="image", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Zoom beim Scrollen ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ZoomReveal-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert