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
Variants
Details
Was Zoom beim Scrollen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Geschichten
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/zoom-reveal.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
variantoptionalZoomRevealVariantimageproductvideogridapp-icon
eyebrowoptionalstringtitlePflichtReact.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
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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| play | Englisch Play film | Deutsch Film abspielen |
| pause | Englisch Pause film | Deutsch 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
'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.