Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Drehendes Produkt

Ein glasierter Becher dreht sich beim Scrollen einmal um seine Achse. Henkel und Stempel wandern herum, das Licht bleibt, wo es ist.

Im Studio öffnen

Teile fügen sich

Eine Schreibtischlampe beginnt in ihren Teilen, jedes beschriftet. Beim Scrollen kommen die Teile nacheinander an ihren Platz, vom Fuß aufwärts, und die Lampe geht an.

Im Studio öffnen

Vom Morgen in die Nacht

Ein Haus mit Solardach durch einen Tag. Die Sonne zieht über den Himmel, der Ertrag zählt mit, dann gehen die Fenster an und die Sterne auf.

Im Studio öffnen

Etwas wächst

Eine Pflanze wächst beim Scrollen aus dem Topf, der Stiel zeichnet sich, Blatt für Blatt, bis sie blüht. Für Wachstum jeder Art.

Im Studio öffnen

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

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.

  • variantoptional
    ImageSequenceVariant

    spinexplodeddaylightgrow

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • stepsPflicht
    SequenceStep[]

    The steps beside the drawing, spread evenly over the scroll

  • partsoptional
    string[]

    exploded: names of the parts, from the top (shade) to the bottom (base)

  • yieldKwhoptional
    number

    daylight: 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
kwhEnglisch kWh todayDeutsch kWh heute
stepEnglisch StepDeutsch Schritt
ofEnglisch ofDeutsch von
drawingEnglisch DrawingDeutsch 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
components/ui/image-sequence.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert