Bildfolge
Ein gezeichnetes Bild, das sich beim Scrollen Bild für Bild verändert, wie ein Daumenkino. Ein Becher dreht sich, eine Lampe setzt sich zusammen, ein Haus geht vom Morgen in die Nacht, eine Pflanze wächst. In SVG gezeichnet, ohne Bilddateien, daneben wechseln die Schritte.
Eine von 3 Components in Scroll-Reisen
Variants
Details
Was Bildfolge mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Reisen
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/image-sequence.tsx
- Bringt mit
- utilskitrevealsectionscroll-scene
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ImageSequenceProps aus components/ui/image-sequence.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalImageSequenceVariantspinexplodeddaylightgrow
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringstepsPflichtSequenceStep[]The steps beside the drawing, spread evenly over the scroll
partsoptionalstring[]exploded: names of the parts, from the top (shade) to the bottom (base)
yieldKwhoptionalnumberdaylight: the yield of a sunny day in kWh, counted up while the sun is up
Eingebaute Texte
Diese Texte schreibt Bildfolge selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 4 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| kwh | Englisch kWh today | Deutsch kWh heute |
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| drawing | Englisch Drawing | Deutsch Zeichnung |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-current, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle img.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- 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).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 482 Zeilen
'use client';
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { lerp, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A drawn picture that changes frame by frame while the page scrolls, like a flipbook. The section
* stands still, the drawing follows the scroll exactly and the steps beside it change with it. The
* drawings are SVG, every frame is computed from the progress and written straight onto a few
* attributes and transforms, so there are no image files to load and the motion stays at 60 fps.
*
* Without JavaScript and with reduced motion the drawing shows its finished frame and every step is
* listed.
*
* spin a glazed mug turns once around its axis, handle and stamp travel around
* exploded a desk lamp in parts with labels that comes together into one piece
* daylight a house with solar panels from sunrise to night, the yield counts up
* grow a plant grows from the pot, leaf by leaf, until it flowers
*/
export type ImageSequenceVariant = 'spin' | 'exploded' | 'daylight' | 'grow';
export interface SequenceStep {
title: string;
text?: string;
}
export interface ImageSequenceProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: ImageSequenceVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
/** The steps beside the drawing, spread evenly over the scroll */
steps: SequenceStep[];
/** exploded: names of the parts, from the top (shade) to the bottom (base) */
parts?: string[];
/** daylight: the yield of a sunny day in kWh, counted up while the sun is up */
yieldKwh?: number;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { kwh: 'kWh today', step: 'Step', of: 'of', drawing: 'Drawing' },
de: { kwh: 'kWh heute', step: 'Schritt', of: 'von', drawing: 'Zeichnung' },
};
type Els = Record<string, Element | null>;
const at = (el: Element | null | undefined, k: string, v: number | string) => el?.setAttribute(k, String(v));
const tf = (el: Element | null | undefined, t: string) => {
if (el) (el as SVGElement).style.transform = t;
};
const op = (el: Element | null | undefined, o: number) => {
if (el) (el as SVGElement).style.opacity = String(round(o, 3));
};
/* ------------------------------------------------------------------ */
/* spin: a mug */
/* ------------------------------------------------------------------ */
const MUG = { clay: '#c46b48', light: '#e7a17f', dark: '#8d4630', glaze: '#efe4d3', inner: '#5d2c1d' };
function applySpin(p: number, e: Els) {
const deg = 40 + p * 360;
const th = (deg * Math.PI) / 180;
const s = Math.sin(th);
const c = Math.cos(th);
// The handle sits on the body edge and is seen from the side at 90 degrees, head on at 0
const t = `translate(${round(200 + 84 * s, 2)}px, 0) scale(${round(s, 4)}, 1)`;
tf(e.hFront, t);
tf(e.hBack, t);
op(e.hFront, c >= 0 ? 1 : 0);
op(e.hBack, c < 0 ? 1 : 0);
// The stamp sits a hundred degrees before the handle
const ph = th - (100 * Math.PI) / 180;
tf(e.stamp, `translate(${round(200 + 62 * Math.sin(ph), 2)}px, 0) scale(${round(Math.max(0.0001, Math.cos(ph)), 4)}, 1)`);
op(e.stamp, Math.cos(ph) > 0 ? 1 : 0);
if (e.deg) e.deg.textContent = `${Math.round(((deg - 40) % 360 + 360) % 360)}°`;
}
function Spin({ set }: { set: (k: string) => (n: Element | null) => void }) {
const id = `seq${useId().replace(/:/g, '')}`;
return (
<svg viewBox="0 0 400 400" className="size-full" aria-hidden>
<defs>
<linearGradient id={`${id}b`} x1="0" x2="1">
<stop offset="0" stopColor={MUG.dark} />
<stop offset="0.28" stopColor={MUG.light} />
<stop offset="0.55" stopColor={MUG.clay} />
<stop offset="1" stopColor={MUG.dark} />
</linearGradient>
<linearGradient id={`${id}g`} x1="0" x2="1">
<stop offset="0" stopColor="#cdbfa9" />
<stop offset="0.3" stopColor={MUG.glaze} />
<stop offset="1" stopColor="#bfae95" />
</linearGradient>
</defs>
<ellipse cx="200" cy="318" rx="118" ry="16" fill="currentColor" opacity="0.12" />
{/* handle behind the body */}
<g ref={set('hBack')} style={{ transform: 'translate(254px,0) scale(0.643,1)', opacity: 0 }}>
<path d="M0 160C58 160 66 252 0 262" fill="none" stroke={MUG.dark} strokeWidth="20" strokeLinecap="round" />
</g>
{/* body: wall, glaze dipped over the top third, rim */}
<path d="M116 130V296Q116 314 200 314Q284 314 284 296V130Z" fill={`url(#${id}b)`} />
<path d="M116 130V182Q140 196 168 188Q200 178 232 192Q262 202 284 186V130Z" fill={`url(#${id}g)`} />
<ellipse cx="200" cy="130" rx="84" ry="16" fill={MUG.glaze} />
<ellipse cx="200" cy="132" rx="72" ry="11" fill={MUG.inner} />
<g ref={set('stamp')} style={{ transform: 'translate(146px,0) scale(0.5,1)' }}>
<circle cx="0" cy="250" r="16" fill="none" stroke={MUG.glaze} strokeWidth="2.5" opacity="0.9" />
<path d="M-7 254L0 242L7 254" fill="none" stroke={MUG.glaze} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.9" />
</g>
{/* handle in front */}
<g ref={set('hFront')} style={{ transform: 'translate(254px,0) scale(0.643,1)' }}>
<path d="M0 160C58 160 66 252 0 262" fill="none" stroke={MUG.clay} strokeWidth="20" strokeLinecap="round" />
<path d="M0 160C58 160 66 252 0 262" fill="none" stroke={MUG.light} strokeWidth="5" strokeLinecap="round" opacity="0.55" transform="translate(-3 -2)" />
</g>
<text ref={set('deg')} x="372" y="380" textAnchor="end" className="fill-muted-foreground text-[13px] tabular-nums">
0°
</text>
</svg>
);
}
/* ------------------------------------------------------------------ */
/* exploded: a desk lamp */
/* ------------------------------------------------------------------ */
const LAMP = { metal: '#2f3b45', metalLight: '#53626e', brass: '#c9a25b', shade: '#e3dccf', shadeDark: '#b9ae9b', bulb: '#ffe9a8' };
/** Each part: offset in the exploded state, where its label points, the order it comes home */
const PARTS = [
{ key: 'shade', dx: -6, dy: -84, ly: 70 },
{ key: 'bulb', dx: 16, dy: -34, ly: 128 },
{ key: 'arm', dx: 0, dy: -4, ly: 178 },
{ key: 'joint', dx: -40, dy: 14, ly: 226 },
{ key: 'stem', dx: -60, dy: 40, ly: 272 },
{ key: 'base', dx: -20, dy: 74, ly: 330 },
];
function applyExploded(p: number, e: Els) {
PARTS.forEach((part, i) => {
// The base comes home first, the shade last
const order = PARTS.length - 1 - i;
const k = smooth(span(p, 0.1 + order * 0.08, 0.5 + order * 0.08));
tf(e[part.key], `translate(${round(part.dx * (1 - k), 2)}px, ${round(part.dy * (1 - k), 2)}px)`);
op(e[`l${i}`], 1 - smooth(span(p, 0.45 + order * 0.06, 0.6 + order * 0.06)));
});
op(e.glow, smooth(span(p, 0.88, 1)));
}
function Exploded({ set, parts }: { set: (k: string) => (n: Element | null) => void; parts: string[] }) {
return (
<svg viewBox="0 0 400 400" className="size-full overflow-visible" aria-hidden>
<ellipse cx="170" cy="352" rx="110" ry="12" fill="currentColor" opacity="0.12" />
{/* light on the table, once the lamp is whole */}
<ellipse ref={set('glow')} cx="246" cy="350" rx="90" ry="14" fill={LAMP.bulb} style={{ opacity: 1 }} />
<g ref={set('base')}>
<ellipse cx="170" cy="340" rx="74" ry="14" fill={LAMP.metal} />
<rect x="96" y="326" width="148" height="14" fill={LAMP.metal} />
<ellipse cx="170" cy="326" rx="74" ry="14" fill={LAMP.metalLight} />
</g>
<g ref={set('stem')}>
<rect x="163" y="214" width="14" height="114" rx="4" fill={LAMP.metal} />
<rect x="165" y="214" width="4" height="114" rx="2" fill={LAMP.metalLight} />
</g>
<g ref={set('joint')}>
<circle cx="170" cy="208" r="13" fill={LAMP.brass} />
<circle cx="170" cy="208" r="4" fill={LAMP.metal} />
</g>
<g ref={set('arm')}>
<path d="M170 208L246 136" stroke={LAMP.metal} strokeWidth="13" strokeLinecap="round" />
<circle cx="246" cy="136" r="9" fill={LAMP.brass} />
</g>
<g ref={set('bulb')}>
<circle cx="262" cy="176" r="15" fill={LAMP.bulb} />
</g>
<g ref={set('shade')}>
<path d="M214 128L290 128L318 188L186 188Z" fill={LAMP.shade} />
<path d="M290 128L318 188L292 188L272 128Z" fill={LAMP.shadeDark} />
<rect x="236" y="118" width="32" height="12" rx="4" fill={LAMP.metal} />
</g>
{/* labels with leader lines, they fade as the parts come home */}
{PARTS.map((part, i) => (
<g key={part.key} ref={set(`l${i}`)} style={{ opacity: 0 }}>
<path d={`M${330} ${part.ly}H${352}`} stroke="currentColor" strokeWidth="1" opacity="0.45" />
<circle cx="330" cy={part.ly} r="2.5" fill="var(--primary)" />
<text x="358" y={part.ly + 5} className="fill-foreground text-[12px] font-medium max-sm:text-[19px]">
{parts[i] ?? ''}
</text>
</g>
))}
</svg>
);
}
/* ------------------------------------------------------------------ */
/* daylight: a house with solar panels */
/* ------------------------------------------------------------------ */
const SKY = [
['#f6c9a8', '#9ec3e6'],
['#7fb6ec', '#cfe6fa'],
['#f19a6b', '#5b4a8c'],
['#0f1a33', '#1d2b52'],
];
/** Time of day as the language writes it: 6:15 am, 18:15 */
function clock(minutes: number, locale: string) {
const h = Math.floor(minutes / 60) % 24;
const m = String(minutes % 60).padStart(2, '0');
if (locale === 'de') return `${h}:${m}`;
return `${h % 12 || 12}:${m} ${h < 12 ? 'am' : 'pm'}`;
}
function applyDaylight(p: number, e: Els, kwh: number, locale: string, nf: Intl.NumberFormat) {
// Four skies, each fades over the one before
const c = p * 3;
for (let i = 1; i < 4; i++) op(e[`sky${i}`], smooth(span(c, i - 0.7, i - 0.1)));
// The sun travels on an arc from sunrise (p 0) to sunset (p 0.66), the moon follows
const sunT = span(p, 0, 0.7);
const a = Math.PI * (1 - sunT);
tf(e.sun, `translate(${round(200 + 168 * Math.cos(a), 1)}px, ${round(250 - 170 * Math.sin(a), 1)}px)`);
op(e.sun, 1 - span(p, 0.62, 0.72));
const moonT = span(p, 0.7, 1);
tf(e.moon, `translate(${round(110 + 120 * moonT, 1)}px, ${round(120 - 40 * moonT, 1)}px)`);
// Moon and stars only once the sky is dark, the moon is cut by a disc in the night color
op(e.moon, span(p, 0.86, 0.97));
op(e.stars, span(p, 0.84, 0.98));
op(e.lights, smooth(span(p, 0.62, 0.8)));
op(e.sheen, Math.sin(Math.PI * sunT) * (1 - span(p, 0.6, 0.7)));
// Yield follows the sun, it stops at sunset
const y = kwh * smooth(sunT);
if (e.kwh) e.kwh.textContent = nf.format(y);
const minutes = Math.round(lerp(6 * 60, 22 * 60, p) / 15) * 15;
if (e.time) e.time.textContent = clock(minutes, locale);
}
function Daylight({ set, kwhLabel, kwh, locale, nf }: { set: (k: string) => (n: Element | null) => void; kwhLabel: string; kwh: number; locale: string; nf: Intl.NumberFormat }) {
const id = `seq${useId().replace(/:/g, '')}`;
const fmt = nf.format(kwh);
return (
<svg viewBox="0 0 400 400" className="size-full" aria-hidden>
<defs>
{SKY.map(([top, bottom], i) => (
<linearGradient key={i} id={`${id}s${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={bottom} />
<stop offset="1" stopColor={top} />
</linearGradient>
))}
</defs>
{SKY.map((_, i) => (
<rect key={i} ref={set(`sky${i}`)} width="400" height="400" fill={`url(#${id}s${i})`} style={i ? { opacity: i === 3 ? 1 : 0 } : undefined} />
))}
<g ref={set('stars')} fill="#fff" style={{ opacity: 1 }}>
{[[40, 40], [90, 80], [150, 30], [260, 50], [320, 90], [360, 30], [210, 100], [60, 130]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={i % 3 ? 1.2 : 1.8} />
))}
</g>
<g ref={set('sun')} style={{ transform: 'translate(32px,250px)', opacity: 0 }}>
<circle r="26" fill="#ffd27a" />
<circle r="40" fill="#ffd27a" opacity="0.25" />
</g>
<g ref={set('moon')} style={{ transform: 'translate(230px,80px)' }}>
<circle r="18" fill="#f1ecd8" />
<circle cx="8" cy="-5" r="16" fill="#1d2b52" />
</g>
<path d="M0 290C70 262 130 270 200 282S330 262 400 276V400H0Z" fill="#3f5a3c" />
<path d="M0 318C90 300 170 312 250 306S360 296 400 302V400H0Z" fill="#2f472d" />
{/* house */}
<rect x="128" y="226" width="150" height="104" fill="#d9cbb3" />
<path d="M116 230L203 168L290 230Z" fill="#5a3f33" />
{/* panels on the roof */}
<g>
<path d="M150 214L203 176L214 184L161 222Z" fill="#20314a" />
<path d="M214 184L252 211L241 219L203 192Z" fill="#20314a" />
<path ref={set('sheen')} d="M152 213L203 177L208 181L157 217Z" fill="#9fc3ff" style={{ opacity: 0 }} />
</g>
<g ref={set('lights')} style={{ opacity: 1 }}>
<rect x="148" y="250" width="28" height="26" fill="#ffd27a" opacity="0.95" />
<rect x="228" y="250" width="28" height="26" fill="#ffd27a" opacity="0.95" />
<rect x="190" y="282" width="26" height="48" fill="#ffc35a" opacity="0.9" />
<ellipse cx="203" cy="338" rx="46" ry="6" fill="#ffd27a" opacity="0.25" />
</g>
<g fill="none" stroke="#5a3f33" strokeWidth="3">
<rect x="148" y="250" width="28" height="26" />
<rect x="228" y="250" width="28" height="26" />
<rect x="190" y="282" width="26" height="48" />
</g>
{/* readout */}
<g>
<rect x="16" y="16" width="132" height="54" rx="10" fill="#0b1220" opacity="0.55" />
<text ref={set('time')} x="28" y="38" fill="#fff" opacity="0.75" className="text-[12px] tabular-nums">
{clock(22 * 60, locale)}
</text>
<text x="28" y="60" fill="#fff" className="text-[17px] font-semibold tabular-nums">
<tspan ref={set('kwh')}>{fmt}</tspan>
<tspan className="text-[11px] font-normal" fill="#fff" opacity="0.75">
{' '}
{kwhLabel}
</tspan>
</text>
</g>
</svg>
);
}
/* ------------------------------------------------------------------ */
/* grow: a plant */
/* ------------------------------------------------------------------ */
/** Leaves: where they sit on the stem (0 to 1 of its length), position, angle */
const LEAVES = [
{ t: 0.18, x: 200, y: 268, r: -40, s: 0.9 },
{ t: 0.3, x: 198, y: 238, r: 220, s: 1 },
{ t: 0.45, x: 204, y: 204, r: -30, s: 1.05 },
{ t: 0.58, x: 202, y: 176, r: 210, s: 1 },
{ t: 0.7, x: 206, y: 150, r: -24, s: 0.9 },
{ t: 0.8, x: 204, y: 128, r: 200, s: 0.8 },
];
function applyGrow(p: number, e: Els) {
const stem = span(p, 0.05, 0.75);
const path = e.stem as SVGPathElement | null;
if (path) path.style.strokeDashoffset = String(round(1 - stem, 4));
LEAVES.forEach((l, i) => {
const k = smooth(span(stem, l.t, l.t + 0.16));
tf(e[`leaf${i}`], `translate(${l.x}px, ${l.y}px) rotate(${l.r}deg) scale(${round(k * l.s, 4)})`);
});
const f = smooth(span(p, 0.72, 0.95));
tf(e.flower, `translate(214px, 98px) rotate(${round(lerp(-60, 0, f), 2)}deg) scale(${round(f, 4)})`);
tf(e.bud, `translate(178px, 118px) scale(${round(smooth(span(p, 0.8, 1)), 4)})`);
}
function Leaf() {
return (
<g>
<path d="M0 0C14 -14 44 -16 62 0C44 16 14 14 0 0Z" fill="#5f9a5b" />
<path d="M0 0C20 -2 40 -2 58 0" stroke="#3f7a3f" strokeWidth="1.6" fill="none" />
</g>
);
}
function Grow({ set }: { set: (k: string) => (n: Element | null) => void }) {
return (
<svg viewBox="0 0 400 400" className="size-full" aria-hidden>
<circle cx="200" cy="190" r="150" fill="var(--primary)" opacity="0.08" />
<path ref={set('stem')} d="M200 300C196 260 208 230 202 196S206 140 204 112C204 104 208 100 212 98" fill="none" stroke="#3f7a3f" strokeWidth="5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" />
{LEAVES.map((l, i) => (
<g key={i} ref={set(`leaf${i}`)} style={{ transform: `translate(${l.x}px, ${l.y}px) rotate(${l.r}deg) scale(${l.s})` }}>
<Leaf />
</g>
))}
<g ref={set('flower')} style={{ transform: 'translate(214px, 98px)' }}>
{[0, 72, 144, 216, 288].map((r) => (
<ellipse key={r} cx="0" cy="-16" rx="10" ry="17" fill="#f2a8b8" transform={`rotate(${r})`} />
))}
<circle r="8" fill="#f6c94a" />
</g>
<g ref={set('bud')} style={{ transform: 'translate(178px, 118px)' }}>
<path d="M22 -4C16 0 8 2 0 0" stroke="#3f7a3f" strokeWidth="3" fill="none" />
<ellipse cx="-4" cy="-2" rx="7" ry="9" fill="#e98fa3" />
</g>
{/* pot */}
<path d="M150 300H250L240 360Q238 368 230 368H170Q162 368 160 360Z" fill="#c46b48" />
<rect x="144" y="292" width="112" height="16" rx="4" fill="#d9825c" />
<ellipse cx="200" cy="296" rx="50" ry="4" fill="#5d3a25" />
<ellipse cx="200" cy="372" rx="64" ry="8" fill="currentColor" opacity="0.12" />
</svg>
);
}
const APPLY = { spin: applySpin, exploded: applyExploded, grow: applyGrow } as const;
/* ------------------------------------------------------------------ */
/** A drawn picture that changes frame by frame while the page scrolls. */
export function ImageSequence({ variant = 'spin', eyebrow, title, description, steps, parts = [], yieldKwh = 38.4, labels, className, ...rest }: ImageSequenceProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const live = useLive();
const n = steps.length;
const els = useRef<Els>({});
const [active, setActive] = useState(n - 1);
// One number format per language, not one per scroll frame
const nf = useMemo(() => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 }), [locale]);
const set = (k: string) => (node: Element | null) => {
els.current[k] = node;
};
const apply = useCallback(
(p: number) => {
setActive(Math.min(n - 1, Math.floor(p * n * 0.999)));
if (variant === 'daylight') applyDaylight(p, els.current, yieldKwh, locale, nf);
else APPLY[variant](p, els.current);
},
[variant, n, yieldKwh, locale, nf],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
const drawing = (
<div className={cn('relative aspect-square w-full overflow-hidden rounded-2xl border border-border text-foreground', variant === 'daylight' ? 'bg-[#0f1a33]' : 'bg-surface')} data-clip-ok role="img" aria-label={`${t.drawing}, ${steps[n - 1]?.title ?? ''}`}>
{variant === 'spin' && <Spin set={set} />}
{variant === 'exploded' && (
<div className="absolute inset-0 p-[6%] pr-[22%]">
<Exploded set={set} parts={parts} />
</div>
)}
{variant === 'daylight' && <Daylight set={set} kwhLabel={t.kwh} kwh={yieldKwh} locale={locale} nf={nf} />}
{variant === 'grow' && <Grow set={set} />}
</div>
);
const head = (
<div>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && <p className="mt-4 max-w-[30rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
);
const list = (
<ol className="relative mt-8 grid gap-1 border-l border-border pl-5">
{steps.map((s, i) => {
const on = live ? i === active : true;
return (
<li key={i} aria-current={live && i === active ? 'step' : undefined} className={cn('relative py-2 transition-opacity duration-500', live && !on && 'opacity-45')}>
<span aria-hidden className={cn('absolute -left-[calc(1.25rem+1px)] top-3 h-5 w-0.5 rounded-full transition-colors duration-500', live && on ? 'bg-primary' : 'bg-transparent')} />
<h3 className="text-[15.5px] font-semibold text-foreground">{s.title}</h3>
{s.text && (!live || on) && <p className={cn('mt-1 max-w-[28rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground', live && 'kit-rise')}>{s.text}</p>}
</li>
);
})}
</ol>
);
/* ---------- Finished layout: no JavaScript, reduced motion, server ---------- */
if (!live) {
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
<div className="kit-container grid grid-cols-1 items-center gap-10 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<Reveal>
{head}
{list}
</Reveal>
<div className="mx-auto w-full max-w-[34rem]">{drawing}</div>
</div>
</section>
);
}
/* ---------- Live scene ---------- */
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: '300svh' }}>
<div className="sticky top-0 flex h-svh flex-col justify-center overflow-clip py-6 sm:py-10">
<div className="kit-container grid w-full grid-cols-1 items-center gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<div className="order-2 lg:order-1">
<div className="hidden lg:block">
{head}
{list}
</div>
{/* Phones: one step at a time under the drawing */}
<div className="min-h-[7.5rem] text-center lg:hidden" aria-live="polite">
<p className="text-[12.5px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{t.step} {active + 1} {t.of} {n}
</p>
<div key={active} className="kit-rise">
<h3 className="mt-2 text-[18px] font-semibold text-foreground">{steps[active]?.title}</h3>
{steps[active]?.text && <p className="mx-auto mt-1.5 max-w-[24rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{steps[active].text}</p>}
</div>
</div>
</div>
<div className="order-1 lg:order-2">
{title && <h2 className="mb-4 text-balance text-center text-[1.375rem] font-semibold text-foreground lg:hidden">{title}</h2>}
<div className="mx-auto w-full max-w-[min(34rem,48svh)] lg:max-w-[min(36rem,74svh)]">{drawing}</div>
</div>
</div>
</div>
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Bildfolge?
Mit aiw add @ai-website/image-sequence. Der Befehl legt die Datei components/ui/image-sequence.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind reveal, section und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Bildfolge?
4 Varianten, Drehendes Produkt, Teile fügen sich, Vom Morgen in die Nacht und Etwas wächst. Du wählst sie mit der Prop variant, zum Beispiel variant="exploded".
Kann ich die Texte von Bildfolge übersetzen?
Ja. Bildfolge bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „kWh heute“, „Schritt“ und „von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Bildfolge einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bildfolge mit aiw_components und installieren sie mit aiw_add @ai-website/image-sequence. Nenn im Prompt die Variante, etwa variant="spin", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Bildfolge ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ImageSequence-Code zum Kopieren.