# Icon morphs

> Line icons that act out what they mean: a house builds itself, an arrow drops into a tray, a screen turns into a phone, a battery charges.

Four grey cards, each with a pair of line icons that play a short, springy animation: one house goes up bar by bar while the other unfolds like a jointed ruler, a download arrow sinks into a tray that sags and springs back as its upload twin lifts off an arch, a screen tips over into a phone or narrows into one, and a battery fills through a cable or from a plug that knocks it sideways. They loop in the demo; hover, focus or press a card to play its pair again.

A React component by Bruno Delorence: https://brunodelorence.com/components/icon-morphs. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Stage**: The white panel the cards sit on, with a hairline edge. It is a size container: under 585 px wide the cards stack in one column.
2. **Grid**: A list of the four pairs, 263 px columns centred on the stage.
3. **Card**: A button named “Replay” and the pair’s name: hovering, focusing or pressing it plays the pair once.
4. **Tiles**: The pair side by side: two takes on the same idea, each with its own motion.
5. **Tile**: A white square that clips its drawing, so a plug or a cable can slide in from its edge.
6. **Drawing**: An SVG on a 290-unit grid, lines 21 units (7 px) thick with round caps; one animation loop rewrites its paths while the pair moves.
7. **Name**: What the pair shows, bold and slightly tracked. The button’s accessible name is “Replay” and this name.

## When to use

- A showcase of motion: a landing section, a style guide or a portfolio piece that shows how icons can act out what they mean.
- Teaching what an action does before anyone takes it: the arrow lands in the tray, the battery fills.
- A gallery the reader can poke at: hover, focus or press a card and its pair plays once more.
- A still poster of the same drawings: `pose` holds every icon on one frame, with no loop running.

## When not to use

- Icons inside buttons, menus or toolbars. Use a static icon set; a 2 s animation there only slows the reader down.
- Progress or status that must be true: the battery here charges on a loop, not from a real level. Use a progress bar or a meter.
- Any icon outside these four pairs: the drawings are keyed by hand in `icons.ts`. Draw a Lottie or an animated SVG for new ones.
- Pages that already move a lot: four looping cards compete with any other animation for the eye.

## Installation

Install it with the shadcn CLI, into components/icon-morphs of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/icon-morphs.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { IconMorphs } from './icon-morphs';

export function Showcase() {
  return <IconMorphs autoplay={false} />;
}
```

## States

- **At rest**: The finished pose: the house built, the arrows still, the battery charged. The server renders it and reduced motion keeps it.
- **Early in the loop**: `pose={0.18}`: the wind-up, as each drawing starts to move.
- **Mid-loop**: `pose={0.42}`: the big move, with the squash and the overshoot.
- **Settling**: `pose={0.7}`: on the way back to rest.
- **Dark**: With `colorScheme="dark"` or a dark page: every colour is a light-dark() token.
- **Narrow stage**: Under 585 px of stage the cards stack, one a row.

## How it works

- Each icon is a pure function of time: given a frame, it returns the SVG paths to draw. One requestAnimationFrame loop asks every moving icon for its frame and writes the paths straight into a small pool of <path> elements, so React renders the cards once and the loop sleeps whenever nothing moves.
- The motion lives in keyed curves: values at given frames (sixtieths of a second), joined by a monotone cubic so a curve never overshoots its own keys. Wind-ups, squashes, overshoots and settles are all written into the keys.
- Everything is drawn on a 290-unit tile with lines 21 units thick, 7 px on the 97 px tile. Lines have round caps and joins; the trays and bars that change shape are filled outlines, their corners drawn as cubic quarter circles so they stay true under a rotation.
- Home (2.2 s): the left house grows from a dot into a bar, the bar rises into walls, the ridge lifts into a roof and the door bounces in, tall and narrow then short and wide. The right house is one jointed line: each wall, rafter, post and lintel swings on its own spring from the part it hangs on.
- Download · Upload (1.43 s): the arrow drops 77 units; the tray under it deepens and rounds off, then flings it 43 units back up while turning shallow and narrow, and both settle. Upload mirrors it: the base curls into an arch under the rising arrow, then drops flat and wide.
- PC · Phone (2.4 s): the left screen winds back 5°, turns a quarter, overshoots by 4° and settles as a phone, its stand climbing in to become the speaker; the right one narrows and grows tall instead. Halfway through, both play the same curves mirrored back to the screen.
- Battery (2.2 s): on the left a cable fades in from above and the charge rises with a sloshing surface, darkening as it fills, before the cable sinks away. On the right the charge slides in from the edge as a plug and knocks the battery 11 units sideways and 2° over, its cap swinging after it. Both drain from the top.
- Each pair keeps its own clock and rests on its finished pose: the house built, the arrows still, the screen, the battery charged. While on screen and untouched every pair loops; on any real input a moving pair finishes its loop and rests, and hovering, focusing or pressing a card plays it once more.
- Every colour is a light-dark() token on the root, so the cards follow the page’s color-scheme, or the colorScheme prop.

## API reference

- **autoplay** (`boolean`, default `true`): Loop every pair while the cards are on screen and untouched. Off, each pair rests and plays once when hovered, focused or pressed.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **pose** (`number`): Holds every icon still at this point of its loop, from 0 (the finished, resting pose) to 1, for a poster or a screenshot. Turns the demo and the replays off.
- **className** (`string`): Extra classes for the root.

## Accessibility

- The cards are a list of buttons named “Replay Home”, “Replay Download · Upload” and so on, each containing its visible name; Tab reaches them, and focusing or pressing one plays its pair.
- The drawings are decorative and hidden from assistive technology; the names say what each pair shows.
- Reduced motion shows every icon in its finished pose and never animates it. The loop pauses offscreen and in a hidden tab, and stops when the reader takes over.

## Source

### icon-morphs/icon-morphs.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { LINE, PAIRS, type Icon, type Pair, type Shape } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './icon-morphs.module.css';

/*
 * Icon morphs: four pairs of line icons on grey cards, each icon a small, playful animation —
 * a house that builds itself and one that unfolds, an arrow that drops into a tray and one that
 * springs off an arch, a screen that turns into a phone, a battery that charges and drains.
 *
 * - Every icon is a pure function of time (icons.ts): given a frame, it returns the SVG paths to
 *   draw. One requestAnimationFrame loop asks each moving icon for its frame and writes the
 *   paths straight into its <path> elements, so React renders the cards once and never again.
 * - Each pair keeps its own clock. While the demo is on screen and untouched, every pair loops.
 *   Any real input hands the cards to the reader: a pair that is moving finishes its loop and
 *   rests, and hovering, focusing or pressing a card plays its pair once more.
 * - At rest an icon shows its finished pose (the house built, the battery charged), which is also
 *   what the server renders and what reduced motion keeps.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** Milliseconds per frame of the icons' clock (they are keyed at 60 frames a second). */
const FRAME = 1000 / 60;

type Clock = {
  /** When the current run started (performance.now()), and from which frame. */
  start: number;
  from: number;
  /** When it stops, back at rest: Infinity while it loops, 0 or -1 (painted) while still. */
  until: number;
};

/** The frame an icon is on, for its pair's clock at `now`. */
function frameOf(icon: Icon, period: number, clock: Clock, now: number) {
  if (now >= clock.until) return icon.rest;
  const t = clock.from + (now - clock.start) / FRAME;
  return ((t % period) + period) % period;
}

/** Writes an icon's frame into its pool of <path> elements. */
function paint(paths: SVGPathElement[], shapes: Shape[], clipUrl: string) {
  for (let i = 0; i < paths.length; i++) {
    const path = paths[i];
    const shape = shapes[i];
    if (!shape) {
      if (path.getAttribute('d')) path.setAttribute('d', '');
      continue;
    }
    path.setAttribute('d', shape.d);
    path.setAttribute('data-k', shape.kind);
    if (shape.opacity === undefined) path.removeAttribute('opacity');
    else path.setAttribute('opacity', String(Math.round(shape.opacity * 1000) / 1000));
    if (shape.clip) path.setAttribute('clip-path', clipUrl);
    else path.removeAttribute('clip-path');
  }
}

/**
 * The pairs' clocks and the one animation loop that paints them. Made once per component; the
 * cards hand it their <path> pools as they mount.
 */
function createEngine() {
  const clocks: Clock[] = PAIRS.map(() => ({ start: 0, from: 0, until: 0 }));
  const pools: SVGPathElement[][][] = PAIRS.map(() => [[], []]);
  const clips: string[][] = PAIRS.map(() => ['', '']);
  let frame = 0;
  const periodOf = (pair: Pair) => Math.max(pair.icons[0].period, pair.icons[1].period);

  /** Paints every pair that needs it at `now`, and keeps going while any of them moves. */
  const tick = (now: number) => {
    let moving = false;
    PAIRS.forEach((pair, p) => {
      const clock = clocks[p];
      const active = now < clock.until;
      if (!active && clock.until === -1) return; // painted at rest already
      pair.icons.forEach((icon, i) => {
        paint(pools[p][i], icon.draw(frameOf(icon, icon.period, clock, now)), clips[p][i]);
      });
      if (active) moving = true;
      else clock.until = -1;
    });
    frame = moving ? requestAnimationFrame(tick) : 0;
  };
  const wake = () => {
    if (!frame) frame = requestAnimationFrame(tick);
  };

  return {
    /** A card's drawing mounted: its paths, and the url() of its clip shape if it has one. */
    register(p: number, i: number, paths: SVGPathElement[], clip: string) {
      pools[p][i] = paths;
      clips[p][i] = clip;
    },
    /**
     * Looping on: every resting pair starts from its rest pose. Off: every looping pair finishes
     * the loop it is in, the next time it passes its rest frame, and rests.
     */
    loop(on: boolean) {
      const now = performance.now();
      PAIRS.forEach((pair, p) => {
        const clock = clocks[p];
        if (on) {
          if (now < clock.until) clock.until = Infinity;
          else Object.assign(clock, { start: now, from: pair.icons[0].rest, until: Infinity });
        } else if (clock.until === Infinity) {
          const period = periodOf(pair);
          const run = clock.from + (now - clock.start) / FRAME - pair.icons[0].rest;
          clock.until = now + (period - (((run % period) + period) % period)) * FRAME;
        }
      });
      wake();
    },
    /** Plays a pair once, from rest to rest, unless it is moving already. */
    replay(p: number) {
      const now = performance.now();
      const clock = clocks[p];
      if (now < clock.until) return;
      const pair = PAIRS[p];
      Object.assign(clock, {
        start: now,
        from: pair.icons[0].rest,
        until: now + periodOf(pair) * FRAME,
      });
      wake();
    },
    stop() {
      cancelAnimationFrame(frame);
      frame = 0;
    },
  };
}

/** The frame a still `pose` (a share of the loop, counted from rest) stands for. */
function poseFrame(icon: Icon, pose: number | undefined) {
  if (pose === undefined) return icon.rest;
  const t = icon.rest + pose * icon.period;
  return ((t % icon.period) + icon.period) % icon.period;
}

/** One icon's drawing, rendered at rest (or at its pose); the loop repaints it after that. */
function Art({
  icon,
  pose,
  register,
}: {
  icon: Icon;
  pose?: number;
  register: (paths: SVGPathElement[], clip: string) => void;
}) {
  const clipId = useId();
  const still = icon.draw(poseFrame(icon, pose));
  return (
    <svg
      className={styles.art}
      viewBox="0 0 290 290"
      aria-hidden="true"
      focusable="false"
      data-part="art"
      ref={(svg) => {
        if (svg)
          register(
            [...svg.querySelectorAll<SVGPathElement>('path[data-pool]')],
            icon.clip ? `url(#${clipId})` : '',
          );
      }}
    >
      {icon.clip && (
        <defs>
          <clipPath id={clipId}>
            <path d={icon.clip} />
          </clipPath>
        </defs>
      )}
      <g strokeWidth={LINE}>
        {Array.from({ length: icon.shapes }, (_, i) => {
          const shape = still[i];
          return (
            <path
              key={i}
              data-pool=""
              data-k={shape?.kind ?? 's'}
              d={shape?.d ?? ''}
              opacity={shape?.opacity}
              clipPath={shape?.clip ? `url(#${clipId})` : undefined}
            />
          );
        })}
      </g>
    </svg>
  );
}

export function IconMorphs({
  autoplay = true,
  colorScheme,
  pose,
  className = '',
}: {
  /** Loop every pair while the demo is on screen and untouched. */
  autoplay?: boolean;
  /**
   * Holds every icon still at this point of its loop, from 0 (the rest pose) to 1; turns the
   * demo and the replays off.
   */
  pose?: number;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing: canPlay, take } = useAutoplay<HTMLDivElement>(6000);
  const still = pose !== undefined;
  const playing = autoplay && canPlay && !still;
  const [engine] = useState(createEngine);
  const reduced = useRef(true);

  useEffect(() => {
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => (reduced.current = motion.matches);
    read();
    motion.addEventListener('change', read);
    return () => {
      motion.removeEventListener('change', read);
      engine.stop();
    };
  }, [engine]);

  useEffect(() => {
    if (still) engine.stop();
    else engine.loop(playing);
  }, [engine, playing, still]);

  const replay = (p: number) => {
    if (!reduced.current && !still) engine.replay(p);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-part="root"
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <ul className={styles.grid} data-part="grid" aria-label="Animated icons">
        {PAIRS.map((pair, p) => (
          <li key={pair.id} className={styles.cell}>
            <button
              type="button"
              className={styles.card}
              data-part="card"
              data-pair={pair.id}
              aria-label={`Replay ${pair.label}`}
              onPointerEnter={(event) => {
                if (event.pointerType !== 'touch') {
                  take();
                  replay(p);
                }
              }}
              onFocus={() => replay(p)}
              onClick={() => replay(p)}
            >
              <span className={styles.tiles} data-part="tiles">
                {pair.icons.map((icon, i) => (
                  <span key={i} className={styles.tile} data-part="tile">
                    <Art
                      icon={icon}
                      pose={pose}
                      register={(paths, clip) => engine.register(p, i, paths, clip)}
                    />
                  </span>
                ))}
              </span>
              <span className={styles.label} data-part="label">
                {pair.label}
              </span>
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

### icon-morphs/icons.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The eight drawings of the icon morphs, and how each one moves.
 *
 * Every icon is a pure function of time: `draw(t)` returns the shapes to paint at frame `t` (the
 * clock counts in sixtieths of a second, so a 2.2 s loop is 132 frames). The shapes are plain
 * SVG path strings, stroked with the line weight or filled, so one generic painter can draw any
 * frame without React.
 *
 * Everything is drawn on a 290-unit tile with lines 21 units thick (7 px on the 97 px tile).
 * Motion comes from keyed curves: values at given frames, joined by a monotone cubic so a curve
 * never overshoots its own keys; anticipation, squash, overshoot and settle are all in the keys.
 */

/** The line weight, in tile units. */
export const LINE = 21;
const HALF = LINE / 2;

export type Shape = {
  /** 's' strokes the path with the line weight, 'f' fills it. */
  kind: 's' | 'f';
  d: string;
  opacity?: number;
  /** Clip to the icon's own clip shape (only the charging battery has one). */
  clip?: boolean;
};

export type Icon = {
  /** Frames (at 60 per second) in one loop. */
  period: number;
  /** The frame the icon rests on: still, it shows this pose, and a replay starts and ends here. */
  rest: number;
  /** The most shapes it ever draws at once. */
  shapes: number;
  /** A clip shape in tile units, if any shape uses one. */
  clip?: string;
  draw: (t: number) => Shape[];
};

/* ----- Curves ------------------------------------------------------------------------------- */

type Curve = (t: number) => number;

/**
 * A curve through keys given as [frame, value, frame, value, …]: flat before the first key and
 * after the last, a monotone cubic between (Fritsch–Carlson), so it never rings between keys.
 */
function curve(keys: number[]): Curve {
  const xs: number[] = [];
  const ys: number[] = [];
  for (let i = 0; i < keys.length; i += 2) {
    xs.push(keys[i]);
    ys.push(keys[i + 1]);
  }
  const n = xs.length;
  if (n === 1) return () => ys[0];
  const slopes: number[] = [];
  for (let i = 0; i < n - 1; i++) slopes.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]));
  const tangents = xs.map((_, i) => {
    if (i === 0) return slopes[0];
    if (i === n - 1) return slopes[n - 2];
    return slopes[i - 1] * slopes[i] <= 0 ? 0 : (slopes[i - 1] + slopes[i]) / 2;
  });
  for (let i = 0; i < n - 1; i++) {
    if (slopes[i] === 0) {
      tangents[i] = tangents[i + 1] = 0;
      continue;
    }
    const a = tangents[i] / slopes[i];
    const b = tangents[i + 1] / slopes[i];
    const s = a * a + b * b;
    if (s > 9) {
      const k = 3 / Math.sqrt(s);
      tangents[i] = k * a * slopes[i];
      tangents[i + 1] = k * b * slopes[i];
    }
  }
  return (t) => {
    if (t <= xs[0]) return ys[0];
    if (t >= xs[n - 1]) return ys[n - 1];
    let i = 0;
    while (t > xs[i + 1]) i++;
    const h = xs[i + 1] - xs[i];
    const u = (t - xs[i]) / h;
    const u2 = u * u;
    const u3 = u2 * u;
    return (
      (2 * u3 - 3 * u2 + 1) * ys[i] +
      (u3 - 2 * u2 + u) * h * tangents[i] +
      (-2 * u3 + 3 * u2) * ys[i + 1] +
      (u3 - u2) * h * tangents[i + 1]
    );
  };
}

/* ----- Geometry ----------------------------------------------------------------------------- */

type Point = [number, number];
type Transform = (p: Point) => Point;

const round = (v: number) => Math.round(v * 100) / 100;
const pt = ([x, y]: Point) => `${round(x)} ${round(y)}`;
const rad = (deg: number) => (deg * Math.PI) / 180;

/** Turns by `deg` (clockwise on screen) about `c`, then moves by `dx`, `dy`. */
function turn(c: Point, deg: number, dx = 0, dy = 0): Transform {
  const cos = Math.cos(rad(deg));
  const sin = Math.sin(rad(deg));
  return ([x, y]) => [
    c[0] + (x - c[0]) * cos - (y - c[1]) * sin + dx,
    c[1] + (x - c[0]) * sin + (y - c[1]) * cos + dy,
  ];
}

const same: Transform = (p) => p;

/** An open or closed polyline. */
function poly(points: Point[], closed = false, tf: Transform = same) {
  return points.map((p, i) => `${i ? 'L' : 'M'}${pt(tf(p))}`).join('') + (closed ? 'Z' : '');
}

/** A segment from `from`, `length` long, heading `deg` (0 is right, 90 is down). */
function arm(from: Point, deg: number, length: number): Point {
  return [from[0] + Math.cos(rad(deg)) * length, from[1] + Math.sin(rad(deg)) * length];
}

/**
 * A rounded corner from `a` to `b` around the sharp corner `c`, as the cubic that matches a
 * quarter circle when the corner is square. Corners stay right even under a rotation.
 */
function corner(a: Point, c: Point, b: Point, tf: Transform) {
  const k = 0.5523;
  const c1: Point = [a[0] + (c[0] - a[0]) * k, a[1] + (c[1] - a[1]) * k];
  const c2: Point = [b[0] + (c[0] - b[0]) * k, b[1] + (c[1] - b[1]) * k];
  return `C${pt(tf(c1))} ${pt(tf(c2))} ${pt(tf(b))}`;
}

/** A rectangle with rounded corners, centred on `cx`, `cy`. */
function rect(cx: number, cy: number, w: number, h: number, r: number, tf: Transform = same) {
  const x0 = cx - w / 2;
  const x1 = cx + w / 2;
  const y0 = cy - h / 2;
  const y1 = cy + h / 2;
  r = Math.max(0, Math.min(r, w / 2, h / 2));
  return (
    `M${pt(tf([x0 + r, y0]))}L${pt(tf([x1 - r, y0]))}` +
    corner([x1 - r, y0], [x1, y0], [x1, y0 + r], tf) +
    `L${pt(tf([x1, y1 - r]))}` +
    corner([x1, y1 - r], [x1, y1], [x1 - r, y1], tf) +
    `L${pt(tf([x0 + r, y1]))}` +
    corner([x0 + r, y1], [x0, y1], [x0, y1 - r], tf) +
    `L${pt(tf([x0, y0 + r]))}` +
    corner([x0, y0 + r], [x0, y0], [x0 + r, y0], tf) +
    'Z'
  );
}

/**
 * A tray: a box with a wall on three sides, open along the edge at `open` and closed along the
 * one at `shut` (above or below it), from `x0` to `x1` (outer edges), filled. `r` rounds its
 * closed corners outside (and `r` less the wall inside); `tip` rounds the ends of its walls. A
 * tray no deeper than its wall is a solid bar.
 */
function tray(x0: number, x1: number, open: number, shut: number, r: number, tip: number) {
  const s = Math.sign(shut - open) || 1;
  const depth = Math.abs(shut - open);
  r = Math.max(0, Math.min(r, depth, (x1 - x0) / 2));
  tip = Math.max(0, Math.min(tip, HALF, depth / 2));
  // Points along the tray: x across, v from the open edge (0) to the closed one (depth).
  const P = (x: number, v: number): Point => [x, open + s * v];
  const c = (a: Point, k: Point, b: Point) => corner(P(...a), P(...k), P(...b), same);
  let d =
    `M${pt(P(x0, tip))}L${pt(P(x0, depth - r))}` +
    c([x0, depth - r], [x0, depth], [x0 + r, depth]) +
    `L${pt(P(x1 - r, depth))}` +
    c([x1 - r, depth], [x1, depth], [x1, depth - r]) +
    `L${pt(P(x1, tip))}` +
    c([x1, tip], [x1, 0], [x1 - tip, 0]);
  if (depth > LINE + 3) {
    const inner = Math.max(0, Math.min(r - LINE, depth - LINE));
    const floor = depth - LINE;
    d +=
      `L${pt(P(x1 - LINE + tip, 0))}` +
      c([x1 - LINE + tip, 0], [x1 - LINE, 0], [x1 - LINE, tip]) +
      `L${pt(P(x1 - LINE, floor - inner))}` +
      c([x1 - LINE, floor - inner], [x1 - LINE, floor], [x1 - LINE - inner, floor]) +
      `L${pt(P(x0 + LINE + inner, floor))}` +
      c([x0 + LINE + inner, floor], [x0 + LINE, floor], [x0 + LINE, floor - inner]) +
      `L${pt(P(x0 + LINE, tip))}` +
      c([x0 + LINE, tip], [x0 + LINE, 0], [x0 + LINE - tip, 0]);
  }
  d += `L${pt(P(x0 + tip, 0))}` + c([x0 + tip, 0], [x0, 0], [x0, tip]) + 'Z';
  return d;
}

const stroke = (d: string, opacity?: number): Shape => ({ kind: 's', d, opacity });
const fill = (d: string, opacity?: number, clip?: boolean): Shape => ({
  kind: 'f',
  d,
  opacity,
  clip,
});

/* ----- Home: the house builds up ------------------------------------------------------------ */

// The finished house, centrelines in tile units: walls, floor, eaves, ridge, and the door.
const WALL_L = 76.5;
const WALL_R = 218.5;
const MID = 147.5;
const FLOOR = 218.5;
const EAVE = 131;
const RIDGE = 77.5;
const DOOR_L = 122.5;
const DOOR_R = 172.5;
const LINTEL = 167.5;

/** The floor, as a bar growing out from a point, with a little overshoot. Outer width. */
const floorWidth = curve([
  3.5, 0, 4, 2, 5, 7, 6, 17, 7, 29, 8, 46, 9, 64, 10, 86, 11, 107, 12, 127, 13, 145, 14, 161, 15,
  169, 16, 176, 17, 180, 19, 180, 20, 179, 21, 175, 23, 169, 24, 167, 26, 164,
]);
/** The eaves: the walls rise from the floor, overshoot and settle. */
const eave = curve([
  28, 216.5, 29, 212.5, 30, 208.5, 31, 201.5, 32, 190.5, 33, 181.5, 34, 168.5, 35, 155.5, 36, 145,
  37, 134.5, 38, 126.5, 39, 121.5, 40, 117.5, 41, 115.5, 43, 115.5, 44, 118, 45, 120.5, 46, 123, 47,
  126, 48, 128.5, 49, 130, 51, 131,
]);
/** The ridge lifts out of the flat top into a roof, overshoots and settles. */
const ridge = curve([
  41,
  115.5,
  42,
  113.5,
  43,
  110.5,
  44,
  108.5,
  45,
  105.5,
  46,
  102.5,
  47,
  100.5,
  48,
  95.5,
  49,
  90.5,
  50,
  83.5,
  51,
  78.5,
  53,
  68.5,
  54,
  64.5,
  55,
  61.5,
  56.5,
  60.5,
  58,
  61.5,
  60,
  62.5,
  61,
  64.5,
  62,
  66.5,
  63,
  69.5,
  64,
  71.5,
  65,
  73.5,
  66,
  75.5,
  67,
  76.5,
  68,
  RIDGE,
]);
/** The door's lintel: up from the floor, stretched tall, squashed, settled. Its outer top. */
const doorTop = curve([
  52.5, 208, 53, 200, 54, 195, 55, 188, 56, 178, 57, 171, 58, 166, 59, 158, 60, 153, 61, 147, 62,
  144, 63, 142, 64.5, 138.5, 66, 139, 67, 139, 68, 140, 69, 142, 70, 146, 71, 149, 72, 153, 73, 157,
  74, 160, 75.5, 162, 80.5, 162, 82, 158.5, 84, 157,
]);
/** The door's outer width: narrow while it stretches, wide while it squashes. */
const doorWidth = curve([
  53, 50, 54, 54, 55, 66, 56, 66, 57, 64, 59, 62, 66, 62, 68, 63, 69, 64, 70, 66, 71, 69, 72, 71,
  73, 75, 74, 77, 75.5, 79, 80, 79, 81, 77, 82, 75, 83, 73, 85, 71,
]);

/** The house that grows: a bar, then walls, then a roof, then a door that bounces into place. */
const homeBuild: Icon = {
  period: 132,
  rest: 131.9,
  shapes: 2,
  draw(t) {
    if (t < 3.5) return [];
    if (t < 28) {
      const w = floorWidth(t);
      return [fill(rect(MID, FLOOR, w, LINE, HALF))];
    }
    const top = eave(t);
    const peak = t < 41 ? top : Math.min(top, ridge(t));
    const shapes = [
      stroke(
        poly(
          [
            [WALL_L, FLOOR],
            [WALL_L, top],
            [MID, peak],
            [WALL_R, top],
            [WALL_R, FLOOR],
          ],
          true,
        ),
      ),
    ];
    if (t >= 52.5) {
      const lintel = Math.min(FLOOR, doorTop(t) + HALF);
      const half = (doorWidth(t) - LINE) / 2;
      shapes.push(
        stroke(
          poly([
            [MID - half, FLOOR],
            [MID - half, lintel],
            [MID + half, lintel],
            [MID + half, FLOOR],
          ]),
        ),
      );
    }
    return shapes;
  },
};

// The folding house: one jointed line. Every joint swings on a spring of its own; angles are in
// degrees on screen (0 points right, 90 down). Roof and lintel angles are relative to the part
// they hang from, so they ride along with it.
const WALL = FLOOR - EAVE;
const RAFTER = Math.hypot(MID - WALL_L, EAVE - RIDGE);
const POST = FLOOR - LINTEL;
const LINTEL_SPAN = DOOR_R - DOOR_L + 1;
const ROOF_PITCH = (Math.atan2(EAVE - RIDGE, MID - WALL_L) * 180) / Math.PI;

const rightWall = curve([
  3, 180, 4, 182, 6, 188, 8, 200, 10, 214, 12, 231, 14, 248, 16, 262.5, 18, 272.5, 20, 277, 22, 278,
  24, 277, 26, 275.5, 28, 273, 30, 271, 32, 270.5, 34, 270,
]);
const leftWall = curve([
  9, 0, 10, -2, 12, -9, 14, -20, 16, -31, 18, -50.7, 20, -67.4, 22, -82, 24, -92, 26, -97, 28, -98,
  30, -97, 32, -95, 34, -93, 36, -92, 38, -91, 40, -90,
]);
const leftRoof = curve([
  34,
  180,
  36,
  172,
  38,
  153,
  40,
  136.6,
  42,
  111,
  44,
  85.6,
  46,
  64.3,
  48,
  50.4,
  50,
  43.4,
  52,
  42.4,
  54,
  44,
  56,
  46.4,
  58,
  51,
  60,
  52.4,
  62,
  90 - ROOF_PITCH,
]);
const rightRoof = curve([
  40,
  -180,
  42,
  -170,
  44,
  -158,
  46,
  -136.6,
  48,
  -111,
  50,
  -84.5,
  52,
  -74,
  54,
  -50.4,
  56,
  -43.4,
  58,
  -42.4,
  60,
  -44,
  62,
  -45.6,
  64,
  -49.2,
  66,
  -51,
  68,
  -52.4,
  70,
  ROOF_PITCH - 90,
]);
const leftPost = curve([
  56, 0, 58, -6, 60, -14, 62, -24, 64, -35.5, 66, -49.3, 68, -67.2, 70, -82, 72, -92, 74, -97, 76,
  -98, 78, -97, 80, -96, 82, -92, 84, -91, 86, -90,
]);
const rightPost = curve([
  66, 180, 68, 192, 70, 210, 72, 229.3, 74, 248, 76, 262, 78, 272, 80, 278, 82, 278, 84, 277, 86,
  275, 88, 273, 90, 271, 92, 270,
]);
const lintelSwing = curve([
  86, 180, 88, 176, 90, 167, 92, 156, 94, 144, 96, 132, 98, 119, 100, 100, 102, 88, 104, 84, 106,
  83, 108, 85, 110, 88, 112, 90,
]);

/** The house that unfolds: walls stand up, the roof swings over, the door hinges into place. */
const homeFold: Icon = {
  period: 132,
  rest: 131.9,
  shapes: 8,
  draw(t) {
    // This house sits 2.5 units left of the other, its door a touch narrower.
    const left: Point = [WALL_L - 2.5, FLOOR];
    const right: Point = [WALL_R - 2.5, FLOOR];
    const lw = leftWall(t);
    const rw = rightWall(t);
    const leftTop = arm(left, lw, WALL);
    const rightTop = arm(right, rw, WALL);
    const post: Point = [DOOR_L - 3, FLOOR];
    const lp = leftPost(t);
    const postTop = arm(post, lp, POST);
    const line = (a: Point, b: Point) => stroke(poly([a, b]));
    return [
      line(left, right),
      line(left, leftTop),
      line(right, rightTop),
      line(leftTop, arm(leftTop, lw + leftRoof(t), RAFTER)),
      line(rightTop, arm(rightTop, rw + rightRoof(t), RAFTER)),
      line(post, postTop),
      line(postTop, arm(postTop, lp + lintelSwing(t), LINTEL_SPAN)),
      line([DOOR_R - 2, FLOOR], arm([DOOR_R - 2, FLOOR], rightPost(t), POST)),
    ];
  },
};

/* ----- Download and upload: the arrow drops into a tray, the tray bounces it back ------------ */

/** How far the arrow travels, down for download; upload mirrors it. */
const drop = curve([
  5, 0, 6, 2, 7, 4, 8, 6, 9, 8, 10, 11, 11, 15, 12, 18, 13, 23, 14, 27, 15, 32, 16, 37, 17, 42, 18,
  47, 19, 52, 20, 56, 21, 60, 22, 64, 23, 67, 24, 70, 26, 74, 27, 75, 28, 76, 29, 77, 30, 76, 31,
  73, 32, 67, 33, 59, 34, 46, 35, 32, 36, 16, 37, 1, 38, -13, 39, -25, 40, -34, 41, -40, 42, -43,
  43, -43.5, 44, -42, 45, -40, 46, -37, 47, -32, 48, -26, 49, -20, 50, -13, 51, -7, 52, -2, 53, 3,
  54, 5, 55, 7, 58, 7, 59, 6.5, 60, 6, 61, 5, 62, 4, 63, 3, 64, 2, 65, 1, 68, 0,
]);
/** The arrow leans a little in flight. */
const lean = curve([33, 0, 36, 2, 38, 2.6, 44, 2.6, 50, 1.6, 56, 0.5, 62, 0]);

/** The download tray: sags under the arrow, springs back shallow, settles. Outer bottom. */
const sag = curve([
  11, 224, 12, 226, 13, 228, 14, 229.5, 15, 231.5, 16, 235, 17, 238, 18, 242, 19, 246, 20, 250.5,
  21, 254, 22, 258, 23, 262, 24, 265, 26, 269, 27, 270.5, 28, 272, 31, 272, 32, 269, 33, 262, 34,
  253, 35, 240, 36, 229, 37, 218, 38, 209, 39, 204, 40, 202, 42, 202, 43, 204, 44, 206, 45, 209, 46,
  213, 47, 216.5, 48, 218.5, 49, 221.5, 50, 222.5, 51, 224,
]);
/** The download tray's left edge: it spreads as it sags, narrows as it springs. */
const spread = curve([
  10, 63, 11, 62.5, 13, 61, 15, 60, 16, 59, 17, 58, 18, 57, 19, 55, 20, 54, 21, 53, 22, 51, 23, 50,
  24, 49, 26, 48, 27, 47, 31, 47, 32, 48, 33, 50, 34, 53, 35, 56, 36, 59, 37, 62, 38, 65, 39, 66,
  45, 66, 46, 65, 47, 64, 48, 63,
]);

const DOWN_X = 143.5;

const download: Icon = {
  period: 86,
  rest: 0,
  shapes: 3,
  draw(t) {
    const tf = turn([DOWN_X, 122], lean(t), 0, drop(t));
    const bottom = sag(t);
    const x0 = spread(t);
    const depth = bottom - 166;
    const r = 12 + Math.max(0, depth - 57.5) * 0.8;
    return [
      fill(tray(x0, 289 - x0, 166, bottom, r, 4)),
      stroke(
        poly(
          [
            [DOWN_X, 81],
            [DOWN_X, 162],
          ],
          false,
          tf,
        ),
      ),
      stroke(
        poly(
          [
            [DOWN_X - 29, 133],
            [DOWN_X, 162],
            [DOWN_X + 29, 133],
          ],
          false,
          tf,
        ),
      ),
    ];
  },
};

/** The upload base: curls up into an arch under the rising arrow, then drops flat and wide. */
const archTop = curve([
  9, 202, 10, 201, 11, 200, 12, 198, 13, 196.5, 14, 193, 15, 189.5, 16, 186, 17, 182, 18, 177, 19,
  171.5, 20, 166, 21, 160.5, 22, 155, 23, 150, 24, 145.5, 26, 139, 27, 136.5, 28, 134.5, 29, 133,
  31, 133, 32, 135, 33, 142, 34, 153, 35, 168, 36, 184, 37, 202, 38, 215, 39, 228, 40, 235, 41, 237,
  42, 237, 43, 235, 44, 232.5, 45, 228, 46, 222, 47, 215, 48, 208, 49, 202, 50, 198.5, 51, 196, 55,
  196, 56, 197, 58, 199, 60, 200, 61, 201, 63, 202,
]);
const archFoot = curve([
  32, 224, 33, 226, 34, 231, 35, 235, 36, 240, 37, 246.5, 38, 251, 39, 255, 40, 258, 41, 259, 42,
  259, 43, 257, 44, 255, 45, 249, 46, 244, 47, 237, 48, 229, 49, 224, 50, 220.5, 51, 217.5, 55,
  217.5, 56, 218.5, 58, 220.5, 60, 222, 63, 224,
]);
const archEdge = curve([
  12, 62, 13, 63, 16, 64, 18, 65, 20, 66, 22, 67, 24, 68, 29, 69, 31, 69, 32, 68, 33, 67, 34, 64,
  35, 61, 36, 57, 37, 54, 38, 51, 39, 48, 40, 47, 41, 46, 42, 46, 43, 47, 44, 48, 45, 50, 46, 53,
  47, 56, 48, 60, 49, 62, 50, 64.5, 51, 66, 55, 66, 56, 65, 57, 64, 59, 63, 61, 62,
]);

const UP_X = 142;

const upload: Icon = {
  period: 86,
  rest: 0,
  shapes: 3,
  draw(t) {
    const tf = turn([UP_X, 126], -lean(t), 0, -drop(t));
    const top = archTop(t);
    const foot = archFoot(t);
    const x0 = archEdge(t);
    const depth = foot - top;
    const r = 10.5 + Math.max(0, depth - LINE) * 0.85;
    const tip = depth <= LINE + 3 ? HALF : Math.max(3, HALF - (depth - LINE) * 0.6);
    return [
      fill(tray(x0, 288 - x0, foot, top, r, tip)),
      stroke(
        poly(
          [
            [UP_X, 86.5],
            [UP_X, 167.5],
          ],
          false,
          tf,
        ),
      ),
      stroke(
        poly(
          [
            [UP_X - 30, 116.5],
            [UP_X, 86.5],
            [UP_X + 30, 116.5],
          ],
          false,
          tf,
        ),
      ),
    ];
  },
};

/* ----- Computer and phone: the screen turns into a phone and back ---------------------------- */

// One change takes 72 frames; the way back plays the same curves mirrored (A + B − value).
const SWAP = 72;

/** The left screen's turn, in degrees: a small wind-up, a quarter turn, an overshoot, a settle. */
const spin = curve([
  0, 0, 2, -1.5, 4, -3.5, 6, -5, 8, -4.5, 10, -2, 11, 0, 12, 3, 13, 7, 14, 11, 15, 14.5, 16, 19, 17,
  26, 18, 33.5, 19, 40.5, 20, 49, 21, 57.5, 22, 65, 23, 71.5, 24, 77, 26, 85, 27, 88, 28, 90.2, 29,
  92, 30, 93, 32, 94, 34, 93.8, 36, 93, 38, 91.6, 40, 90.6, 42, 90,
]);
/** The stand climbs into the screen and becomes the phone's speaker. Its centre line. */
const standY = curve([
  0, 209.5, 4, 212.5, 7, 214.5, 10, 211.5, 13, 202.5, 16, 184, 19, 158.5, 22, 127.5, 25, 112.5, 28,
  96, 31, 91.5, 34, 91, 37, 93.5, 40, 95.5, 43, 96.5,
]);
/** The right screen narrows and grows tall instead of turning: its size and middle. */
const screenW = curve([
  0, 174.5, 4, 176.5, 6, 178, 8, 178, 10, 176.5, 12, 173, 14, 169.5, 16, 162, 18, 153, 20, 140, 22,
  129.5, 24, 122, 26, 117, 28, 113, 30, 111, 32, 109.5, 34, 109.5, 36, 111, 40, 113,
]);
const screenH = curve([
  0, 92.5, 4, 91, 6, 89, 8, 91, 10, 91, 12, 94.5, 14, 101, 16, 111, 18, 122, 20, 138.5, 22, 151, 24,
  162, 26, 169, 28, 174.5, 30, 176, 32, 178.5, 34, 178.5, 36, 176, 38, 174.5, 40, 175, 42, 174.5,
]);
const screenY = curve([
  0, 124.75, 4, 124, 6, 123.5, 8, 124.5, 10, 124.25, 12, 124.75, 14, 127, 16, 130, 18, 132.5, 20,
  137.25, 22, 140.5, 24, 143.5, 26, 145.5, 28, 147.25, 30, 147.5, 32, 148.25, 34, 148.25, 42,
  147.25,
]);

/** A curve played forwards for the first change, mirrored for the way back. */
function swap(c: Curve, from: number, to: number, t: number) {
  return t < SWAP ? c(t) : from + to - c(t - SWAP);
}

/** The stand: a bar under the screen; as the speaker it is shorter. */
function stand(t: number, x: number) {
  const y = swap(standY, 209.5, 96.5, t);
  const q = Math.max(-0.1, Math.min(1.1, (209.5 - y) / 113));
  const half = 35.5 - 23 * q;
  return stroke(
    poly([
      [x - half, y],
      [x + half, y],
    ]),
  );
}

const computerTurn: Icon = {
  period: 144,
  rest: 0,
  shapes: 2,
  draw(t) {
    const angle = swap(spin, 0, 90, t);
    const p = angle / 90;
    const cx = 148 - 0.5 * p;
    const cy = 125.5 + 20 * p;
    const w = 176 - 1 * p;
    const h = 94 + 19.5 * p;
    return [stroke(rect(cx, cy, w, h, 21.5, turn([cx, cy], angle))), stand(t, 147.5)];
  },
};

const computerMorph: Icon = {
  period: 144,
  rest: 0,
  shapes: 2,
  draw(t) {
    const w = swap(screenW, 174.5, 113, t);
    const h = swap(screenH, 92.5, 174.5, t);
    const cy = swap(screenY, 124.75, 147.25, t);
    return [stroke(rect(144.75, cy, w, h, 21.5)), stand(t, 144.75)];
  },
};

/* ----- Battery: plugged in, it fills; then it drains ----------------------------------------- */

const BODY_X = 137.5;
const BODY_Y = 143.5;
const BODY_W = 160;
const BODY_H = 109;
const CAP_X = 250.5;
// The charge inside a full battery: outer edges.
const CHARGE_L = 80;
const CHARGE_R = 196;
const CHARGE_T = 111;
const CHARGE_B = 177;
const CHARGE_RADIUS = 8;
const CHARGE_SHAPE = rect(
  (CHARGE_L + CHARGE_R) / 2,
  (CHARGE_T + CHARGE_B) / 2,
  CHARGE_R - CHARGE_L,
  CHARGE_B - CHARGE_T,
  CHARGE_RADIUS,
);

/** The charge, with its top at `top`, rounded all round (it drains this way). */
function charge(top: number, dx = 0, opacity?: number, tf: Transform = same) {
  const h = CHARGE_B - top;
  if (h <= 0.5) return [];
  const w = CHARGE_R - CHARGE_L;
  return [fill(rect(CHARGE_L + dx + w / 2, top + h / 2, w, h, CHARGE_RADIUS, tf), opacity)];
}

/** The cable comes down from above into the battery; later it leaves, sinking in. Its end. */
const cableEnd = curve([
  1, 0.5, 2, 14.5, 3, 31, 4, 48.5, 5, 69.5, 6, 89.5, 7, 108.5, 8, 125.5, 9, 138.5, 10, 150.5, 11,
  158.5, 12, 161.5,
]);
const cableTop = curve([76.5, -40, 77, 14.5, 78, 36.5, 79, 65.5, 80, 88.5, 81, 105.5, 82, 125]);
const cableFade = curve([1, 0.2, 2, 0.27, 4, 0.72, 6, 0.82, 8, 0.89, 10, 0.96, 12, 1]);
/** The level, at the left and right walls of the charge: it fills with a slosh. */
const levelLeft = curve([
  7.5, 177, 8, 176.5, 9, 175.5, 10, 174.5, 11, 172, 12, 170, 13, 168, 14, 165, 15, 162, 16, 159, 17,
  155, 18, 152, 19, 148, 20, 144, 21, 141, 22, 137, 23, 134, 24, 131, 26, 127, 27, 125, 28, 124, 29,
  123, 30, 122, 33, 122, 34, 123, 36, 124, 38, 126, 40, 129, 42, 133, 44, 137, 46, 141, 48, 144, 50,
  147, 52, 148, 54, 150, 56, 151, 58, 150, 60, 149, 62, 147, 64, 143, 66, 138, 68, 133, 70, 126, 72,
  119, 74, 112, 75, 111,
]);
const levelRight = curve([
  7.5, 177, 10, 176.5, 11, 174, 12, 172, 13, 170, 14, 168, 15, 165, 16, 163, 17, 160, 18, 157, 19,
  154, 20, 151, 21, 148, 22, 146, 23, 143, 24, 141, 26, 138, 27, 136, 28, 135, 29, 134, 36, 134, 38,
  135, 40, 136, 42, 137, 44, 138, 46, 140, 48, 140, 50, 141, 52, 142, 56, 142, 58, 142, 60, 141, 62,
  139, 64, 136, 66, 132, 68, 126, 70, 120, 72, 114, 74, 112, 75, 111,
]);
/** The charge darkens as it fills. */
const chargeTone = curve([
  8, 0.5, 13, 0.53, 20, 0.54, 24, 0.56, 30, 0.6, 36, 0.64, 40, 0.67, 44, 0.7, 50, 0.77, 54, 0.81,
  60, 0.84, 66, 0.89, 70, 0.9, 74, 0.94, 80, 0.95, 90, 0.97,
]);
const drainLeft = curve([
  101, 111, 102, 115, 103, 123, 104, 131, 105, 139, 106, 147, 107, 154, 108, 160, 109.5, 165, 111,
  172, 112, 175, 113, 177,
]);

/** Probe positions of the level (in from each wall of the charge). */
const PROBE_L = 95;
const PROBE_R = 190;

const batteryCable: Icon = {
  period: 132,
  rest: 90,
  shapes: 4,
  clip: CHARGE_SHAPE,
  draw(t) {
    const shapes: Shape[] = [];
    const tone = chargeTone(t);
    if (t >= 7.5 && t < 75) {
      const l = levelLeft(t);
      const r = levelRight(t);
      const slope = (r - l) / (PROBE_R - PROBE_L);
      const atL = l - (PROBE_L - CHARGE_L) * slope;
      const atR = r + (CHARGE_R - PROBE_R) * slope;
      shapes.push(
        fill(
          poly(
            [
              [CHARGE_L - 2, atL - 2 * slope],
              [CHARGE_R + 2, atR + 2 * slope],
              [CHARGE_R + 2, CHARGE_B + 2],
              [CHARGE_L - 2, CHARGE_B + 2],
            ],
            true,
          ),
          tone,
          true,
        ),
      );
    } else if (t >= 75 && t < 101) shapes.push(...charge(CHARGE_T, 0, tone));
    else if (t >= 101) shapes.push(...charge(drainLeft(t), 0, tone));
    shapes.push(stroke(rect(BODY_X, BODY_Y, BODY_W, BODY_H, 21.5)));
    shapes.push(
      stroke(
        poly([
          [CAP_X, 120.5],
          [CAP_X, 170.5],
        ]),
      ),
    );
    if (t >= 1 && t < 82) {
      const end = cableEnd(t);
      const top = cableTop(t);
      if (end - top > 1) {
        shapes.push(
          stroke(
            poly([
              [145, top],
              [145, end],
            ]),
            cableFade(t),
          ),
        );
      }
    }
    return shapes;
  },
};

/** The plug: the charge itself, slid in from the left. Its left edge. */
const plug = curve([
  4.5, -116, 5, -113, 6, -106, 7, -96, 8, -84, 9, -70, 10, -48, 11, -33, 12, -13, 13, 8, 14, 28, 15,
  46, 16, 62, 17, 75, 18, 86, 19, 93, 20, 98, 21, 100, 22, 101, 24, 101, 26, 99, 27, 97, 28, 95, 29,
  92, 30, 89, 31, 85, 32, 81, 33, 78, 34, 75, 35, 74, 37, 74, 38, 74.5, 39, 75.5, 40, 76.5, 41, 78,
  42, 79, 43, 79.5, 44, 80,
]);
/** The knock: the battery is shoved right, rebounds, settles; its cap swings after it. */
const shove = curve([
  12, 0, 13, 0.5, 14, 1.5, 15, 2.5, 16, 4, 17, 5, 18, 6, 19, 7, 20, 8, 21, 8.5, 22, 9, 23, 10, 24,
  10.5, 26, 11, 27, 11, 28, 10.5, 29, 9, 30, 7, 31, 4, 32, 1, 33, -2, 34, -4.5, 35, -6, 36, -6.5,
  37, -6, 38, -5.5, 39, -5, 40, -3.5, 41, -2, 42, -1, 43, -0.5, 44, 0,
]);
const tilt = curve([
  14, 0, 18, 0.6, 22, 1.4, 24, 1.8, 26, 2, 27, 2.1, 28, 1.9, 29, 1.5, 30, 1, 31, 0.6, 32, 0.2, 34,
  -0.6, 37, -1.2, 40, -0.8, 43, -0.5, 46, -0.1, 48, 0,
]);
const capShove = curve([
  10, 0, 11, 0.5, 12, 1, 14, 2, 15, 2.5, 16, 3.5, 17, 5, 18, 6, 19, 7.5, 20, 9, 21, 11, 22, 13, 23,
  14, 24, 16, 26, 19, 27, 20, 28, 20.5, 29, 19.5, 30, 18, 31, 15.5, 32, 12, 33, 9.5, 34, 6, 35, 3,
  36, 0, 37, -2, 38, -4.5, 39, -6.5, 40, -7.5, 41, -7.5, 42, -7, 43, -6, 44, -5, 45, -4, 46, -3, 47,
  -2, 48, -1, 49, 0,
]);
const capTilt = curve([
  14, 0, 20, 3, 24, 4, 28, 5, 30, 5, 32, 3, 34, 2, 37, 0, 40, -2.5, 43, -2, 46, -1, 49, 0,
]);
const drainRight = curve([
  93, 111, 94, 112, 95, 113, 96, 115, 97, 118, 98, 122, 99, 126, 100, 132, 101, 138, 102, 144, 103,
  150, 104, 156, 105, 162, 106, 166, 107, 170, 108, 173, 109, 175, 110, 177,
]);

const batteryPlug: Icon = {
  period: 132,
  rest: 90,
  shapes: 3,
  draw(t) {
    const shapes: Shape[] = [];
    // Once in, the charge leans with the battery.
    const lean = turn([BODY_X, BODY_Y], tilt(t));
    if (t >= 4.5 && t < 93) {
      const left = plug(t);
      shapes.push(...charge(CHARGE_T, left - CHARGE_L, undefined, t >= 17 ? lean : same));
    } else if (t >= 93) shapes.push(...charge(drainRight(t)));
    const dx = shove(t);
    shapes.push(
      stroke(rect(BODY_X, BODY_Y, BODY_W, BODY_H, 21.5, turn([BODY_X, BODY_Y], tilt(t), dx))),
    );
    shapes.push(
      stroke(
        poly(
          [
            [CAP_X, 120.5],
            [CAP_X, 170.5],
          ],
          false,
          turn([CAP_X, BODY_Y], capTilt(t), capShove(t)),
        ),
      ),
    );
    return shapes;
  },
};

/* ----- The pairs ---------------------------------------------------------------------------- */

export type Pair = { id: string; label: string; icons: [Icon, Icon] };

export const PAIRS: Pair[] = [
  { id: 'home', label: 'Home', icons: [homeBuild, homeFold] },
  { id: 'transfer', label: 'Download · Upload', icons: [download, upload] },
  { id: 'devices', label: 'PC · Phone', icons: [computerTurn, computerMorph] },
  { id: 'battery', label: 'Battery', icons: [batteryCable, batteryPlug] },
];
```

### icon-morphs/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### icon-morphs/icon-morphs.module.css

```css
/* Icon morphs (icon-morphs.tsx): four grey cards on a white stage, two white tiles on each with
   an animated line icon, and the pair's name under them. Two cards a row, or one on a narrow
   stage (a container query).

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Memory. The drawings are SVG paths repainted in place by one animation loop, and only while a
   pair moves; each tile clips its own drawing, so a repaint stays inside its 97 px square. */

.root {
  /* The stage, the cards, and the tiles on them. */
  --surface: #fdfdfd;
  --card: #f2f2f2;
  --tile: #fefefe;
  /* The icons and the names. */
  --ink: #2b2b2b;
  /* The stage's edge. */
  --line: rgb(0 0 0 / 0.06);
  /* The same on both schemes: the focus ring. */
  --focus: #387bff;
  container-type: inline-size;
  box-sizing: border-box;
  width: 100%;
  padding: 22px 16px;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fdfdfd, #121214);
    --card: light-dark(#f2f2f2, #1f1f22);
    --tile: light-dark(#fefefe, #2b2b2f);
    --ink: light-dark(#2b2b2b, #ececef);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* Two cards a row, 27 px apart, 23 px between rows; one a row when two do not fit. */
.grid {
  display: grid;
  grid-template-columns: 263px;
  gap: 23px 27px;
  justify-content: center;
  margin: 0;
  padding: 0;
  list-style: none;
}
@container (min-width: 585px) {
  .grid {
    grid-template-columns: repeat(2, 263px);
  }
}

.cell {
  display: block;
  margin: 0;
}

/* A card: 263 × 197 px, very round corners, the tiles 42 px from its top and the name in the
   space under them. */
.card {
  display: flex;
  flex-direction: column;
  align-items: center;
  box-sizing: border-box;
  width: 263px;
  height: 197px;
  margin: 0;
  padding: 42px 0 0;
  border: 0;
  border-radius: 47px;
  background: var(--card);
  color: inherit;
  font: inherit;
  text-align: center;
  cursor: pointer;
  outline: none;
  -webkit-user-select: none;
  user-select: none;
}
.card:focus-visible {
  box-shadow:
    0 0 0 2px var(--surface),
    0 0 0 4px var(--focus);
}

.tiles {
  display: flex;
  gap: 20px;
}

/* A tile: 97 px square, its corners rounded 22 px; it clips what slides in from its edge. */
.tile {
  display: block;
  width: 97px;
  height: 97px;
  overflow: hidden;
  border-radius: 22px;
  background: var(--tile);
}

/* The drawing fills the tile: 290 units across, lines 21 units (7 px) thick. */
.art {
  display: block;
  width: 97px;
  height: 97px;
}
.art [data-k='s'] {
  fill: none;
  stroke: var(--ink);
  stroke-linecap: round;
  stroke-linejoin: round;
}
.art [data-k='f'] {
  fill: var(--ink);
  stroke: none;
}

/* The name: 15 px bold, its middle 35 px under the tiles. */
.label {
  display: block;
  margin-top: 25px;
  font:
    700 15px/20px -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    'Helvetica Neue',
    Arial,
    sans-serif;
  letter-spacing: 0.06em;
  white-space: nowrap;
}
```