Skip to content
UI Kit
Accent color

Image sequence

A drawn picture that changes frame by frame while the page scrolls, like a flipbook. A mug turns, a lamp comes together, a house goes from sunrise to night, a plant grows. Drawn in SVG, no image files, the steps change beside it.

One of 3 components in Scroll journeys

4 variants

Variants

Turning product

A glazed mug turns once around its axis while you scroll. Handle and stamp travel around it, the light stays where it is.

Open in Studio

Parts come together

A desk lamp starts in its parts, each with a label. While you scroll the parts come home one by one, from the base up, and the lamp switches on.

Open in Studio

From sunrise to night

A house with solar panels through one day. The sun crosses the sky, the yield counts up, then the windows light up and the stars come out.

Open in Studio

Something grows

A plant grows out of its pot while you scroll, the stem draws itself, leaf by leaf, until it flowers. For growth of any kind.

Open in Studio

Details

What Image sequence brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/image-sequence.tsx
Brings along
utilskitrevealsectionscroll-scene
npm packages
None
Languages
English and German, 4 texts of its own

Props

The ImageSequenceProps interface from components/ui/image-sequence.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    ImageSequenceVariant

    spinexplodeddaylightgrow

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • stepsrequired
    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

Built-in texts

Image sequence writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 4 texts
kwhEnglish kWh todayGerman kWh heute
stepEnglish StepGerman Schritt
ofEnglish ofGerman von
drawingEnglish DrawingGerman Zeichnung

Accessibility

Read from the source of the component.

  • Sets aria-current, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the role img.
  • Announces changes through a live region without moving focus.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 482 lines
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>
  );
}

Frequent questions

How do I install Image sequence?

With aiw add @ai-website/image-sequence. The command puts components/ui/image-sequence.tsx into your project, the code is yours from then on. It brings along reveal, section and scroll-scene. It needs no further npm packages.

Which variants does Image sequence have?

4 variants, Turning product, Parts come together, From sunrise to night and Something grows. You pick one with the variant prop, for example variant="exploded".

Can I translate the texts of Image sequence?

Yes. Image sequence carries 4 texts of its own in English and German, such as “kWh today”, “Step” and “of”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Image sequence?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Image sequence with aiw_components and install it with aiw_add @ai-website/image-sequence. Name the variant in your prompt, for example variant="spin", and the assistant writes your content straight into the props.

Can I adjust Image sequence without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ImageSequence code to copy.

Often searched

Jump to

Copied to the clipboard