# Chromatic goo

> Drag a colour onto a card: it lands like a drop of liquid and rolls across, repainting each element as the wave passes.

A theme switcher you play by hand. Grab a colour from the palette and drop it on a card: a drop bursts where it lands, opens into a wobbly ring and rolls across the card shedding bubbles, and each element takes the new colour the moment the wavefront reaches it.

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

Category: Inputs.

## Anatomy

1. **Palette label**: Names the palette. The group of dots is labelled by it, so a screen reader hears “Theme, group”.
2. **Colour dot**: A button per colour. Drag it onto a card, or press it and then a card; pressed again, it goes back.
3. **Held colour**: The copy that follows the pointer while its slot dims to a ghost; let go off a card and it floats home.
4. **Card**: A button and the place a colour drops. The card under the held colour takes a ring in that colour.
5. **Icon tile**: Tinted with 13% of the theme’s accent; the icon takes the accent itself, deepened on light.
6. **Figure**: Each figure reads `--accent`, so it is one of the first things the wave repaints.
7. **Primary action**: Filled with `--accent-fill`, the theme at full strength, with white text on every colour.

## When to use

- Showing off a theme or brand colour picker where the change itself is the moment: onboarding, a landing page, a design-tool demo.
- Applying an accent to one surface at a time, so people compare cards side by side before they commit.
- A playful settings screen seen once or twice, where a one-second wave is a reward, not a wait.
- Teaching that a theme is a set of tokens: every element on the card changes as the wavefront reaches it.

## When not to use

- A theme setting changed often or in a hurry. A plain swatch row or a `select` applies it at once.
- Light, dark or system appearance. Use a segmented control: that choice is not a colour to drag.
- More than a handful of colours, or a custom one. Use a colour picker with a hex field.
- Recolouring a whole page: the wave is drawn per card, on a canvas the size of the card.
- Where drag and drop is the only way people would find it. Keep the press-a-colour, press-a-card path visible in the copy.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/chromatic-goo.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 { ChromaticGoo } from './chromatic-goo';

// The palette and two cards, with a demo that plays while it is on screen.
// The colours are in themes.ts and the cards' content in cards.tsx: change them there.
export default function ThemeLab() {
  return <ChromaticGoo />;
}
```

## States

- **Untouched**: Both cards start in a neutral grey theme, waiting for a colour.
- **Themed**: Each card keeps the last colour dropped on it, independently of the other.
- **Colour picked**: Pressed rather than dragged: a ring round the dot, and the next card pressed takes it.
- **Dragging over a card**: The slot is a ghost, the dot follows the pointer and the card under it takes a ring.
- **Wave landing**: Stopped halfway for this still. Live, the ring rolls across in 0.8 to 1 s; under reduced motion the colour cross-fades in instead.
- **Dark**: With `colorScheme="dark"` the stage and cards go dark; the palette and themes keep their colours.

## How it works

- Each card is themed by CSS custom properties: the icon, the figures and the primary button read an accent and a tint (themes.ts).
- When a colour lands, the card holds two faces. Underneath, the old look, drained to grey at once; on top, the new look, clipped by a clip-path polygon that follows the ring’s edge frame by frame, so each element recolours as the wavefront crosses it.
- The wave is drawn on a canvas over the card, only while it plays: a closed curve whose radius grows with time and wobbles on a few slow sine waves, with a white crest over a fringe of the new colour. The white fades as it travels, so the ring takes on the colour.
- A soft glow trails the ring, drawn at a quarter of its size and stretched, which blurs it for almost nothing. Bubbles bulge out of the crest, pinch off as small rings, drift and fade.
- Every frame depends only on the time since the drop, so the canvas and the clip always agree. Nothing runs at rest: the canvas gives its pixels back when the wave is done.
- The stage, cards, text and controls are light-dark() tokens, so it follows the page’s color-scheme, or the colorScheme prop. The palette and the themes keep their colours exactly; on light only the figures and icons deepen the theme colour, so they stay legible. The wave reads its foam from the canvas’s CSS colour as it draws each frame.

## API reference

- **defaultState** (`GooState`): Opens in this state instead of grey, with the demo off: each card’s colour, a colour picked and waiting for a card, a colour held over a card mid-drag, or a wave stopped part-way across one.
- **onChange** (`(card: string, color: ColorId) => void`): Called when the reader puts a colour on a card, with the card’s id (analytics or team) and the colour.
- **autoplay** (`boolean`, default `true`): Let a ghost cursor drag colours onto the cards while the lab is visible and untouched.
- **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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- Colours and cards are labelled buttons: press a colour (Enter, Space or a tap), then a card; pressing the colour again puts it back, and Escape cancels a pick or a drag.
- Theme changes are announced to screen readers: “Analytics is now orange.”
- Only the colour dots take touch-action: none, so swiping anywhere else still scrolls the page.
- Under reduced motion there is no wave and no demo: the new colour cross-fades in.

## Source

### chromatic-goo/chromatic-goo.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
} from 'react';
import { CARDS, CardFace } from './cards';
import { SWATCHES, swatchOf, toRgb, type ColorId } from './themes';
import { createWave, playWave, stillWave } from './wave';
import { useAutoplay } from './use-autoplay';
import styles from './chromatic-goo.module.css';

/*
 * Chromatic goo: a theme switcher you play by hand. Grab a colour from the palette and drop it on
 * a card: it lands like a drop of liquid, a white blob that opens into a ring and rolls across the
 * card, and each thing on the card takes the new colour as the ring passes it.
 *
 * - A theme is a set of CSS custom properties on a card's face (themes.ts).
 * - The reveal: while a colour lands, the card holds two faces. Underneath, the old one, drained to
 *   grey at once; on top, the new one, clipped to the inside of the ring by a clip-path polygon
 *   updated every frame. So the card recolours exactly where the wavefront is.
 * - The wave: a <canvas> over the card draws the ring, its colour and its bubbles (wave.ts), only
 *   while it rolls, and gives its pixels back when it is done.
 * - Input: drag with any pointer (pointer capture; `touch-action: none` on the dots only, so the
 *   page still scrolls on a phone), or press a colour, then a card (keyboard, or two taps).
 *   Escape cancels. Changes are announced to screen readers.
 * - While on screen and untouched, a ghost cursor drags colours onto the cards on its own.
 * - Under reduced motion there is no wave: the new colour cross-fades in.
 * - Light or dark: the stage, the cards and their text are light-dark() tokens in the CSS, so they
 *   follow the page's color-scheme, or the one the colorScheme prop sets. The palette's colours
 *   and the themes stay as they are; the wave's foam is a token too, read as it draws.
 */

type Point = { x: number; y: number };

/**
 * A state to open in, for a still or a playground that starts part-way (it turns the demo off).
 * Card numbers are their places in cards.tsx: 0 for Analytics, 1 for Team.
 */
export type GooState = {
  /** Each card's colour, in order; null (or left out) for the grey it starts with. */
  colors?: readonly (ColorId | null)[];
  /** A colour picked with a press, waiting for a card. */
  picked?: ColorId;
  /** A colour held out of the palette over a card, as mid-drag: its slot a ghost, the card marked. */
  dragging?: { color: ColorId; card: number };
  /** A wave stopped part-way across a card: `progress` from 0 (the drop) to 1 (all of it). */
  landing?: { color: ColorId; card: number; progress: number };
};

/** A colour on its way onto a card: by a wave, or a cross-fade under reduced motion. */
type Landing = { color: ColorId; id: number; wave: boolean };

type CardState = { color: ColorId | null; landing: Landing | null };

/** A colour out of the palette, dragged by the reader's pointer or by the demo. */
type Drag = {
  color: ColorId;
  /** The reader's pointer; null for the demo. */
  pointerId: number | null;
  /** Where the press started, in the stage's px. */
  start: Point;
  /** From the pointer to the dot's centre: the dot stays held where it was grabbed. */
  offset: Point;
  /** The dot's diameter, in px. */
  size: number;
  /** Out of its slot: a press turns into a drag after a few pixels. */
  lifted: boolean;
  /** The dot's centre, in the stage's px. */
  at: Point;
};

/** The demo's drops, in turn: a colour and the card it lands on. */
const DEMO_DROPS: readonly (readonly [ColorId, number])[] = [
  ['red', 0],
  ['orange', 1],
  ['purple', 0],
  ['green', 1],
  ['blue', 1],
  ['pink', 0],
];
/** Where on a card the demo lets go, as shares of its width and height. */
const DEMO_SPOTS: readonly Point[] = [
  { x: 0.68, y: 0.3 },
  { x: 0.4, y: 0.4 },
  { x: 0.72, y: 0.52 },
  { x: 0.34, y: 0.26 },
];

/** How far a press travels before it lifts the dot, in px. */
const LIFT_DISTANCE = 4;
/** The cross-fade under reduced motion, in ms. */
const FADE_DURATION = 320;

const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const colorName = (id: ColorId) => swatchOf(id).name.toLowerCase();
/** The colour after `id` in the palette (after the last, the first). */
const nextColor = (id: ColorId) =>
  SWATCHES[(SWATCHES.findIndex((swatch) => swatch.id === id) + 1) % SWATCHES.length].id;

export function ChromaticGoo({
  className = '',
  autoplay = true,
  colorScheme,
  defaultState,
  onChange,
}: {
  className?: string;
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Open in this state instead of grey (see GooState); the demo then stays off. */
  defaultState?: GooState;
  /** The reader put a colour on a card: the card's id in cards.tsx, and the colour. */
  onChange?: (card: string, color: ColorId) => void;
}) {
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched.
  const playing = autoplay && !defaultState && unattended;
  const [cards, setCards] = useState<CardState[]>(() =>
    CARDS.map((_, index) => ({
      color: defaultState?.colors?.[index] ?? null,
      landing:
        defaultState?.landing?.card === index
          ? { color: defaultState.landing.color, id: 0, wave: true }
          : null,
    })),
  );
  /** The colour out of its slot: dragged, or floating back. */
  const [lifted, setLifted] = useState<ColorId | null>(defaultState?.dragging?.color ?? null);
  /** The card under the dragged colour, or -1. */
  const [target, setTarget] = useState(defaultState?.dragging?.card ?? -1);
  /** A colour picked with a press, waiting for a card. */
  const [picked, setPicked] = useState<ColorId | null>(defaultState?.picked ?? null);
  const [message, setMessage] = useState('');
  const labelId = useId();
  const hintId = useId();

  const dots = useRef(new Map<ColorId, HTMLButtonElement>());
  const cardElements = useRef<(HTMLButtonElement | null)[]>([]);
  const canvases = useRef<(HTMLCanvasElement | null)[]>([]);
  const newFaces = useRef<(HTMLSpanElement | null)[]>([]);
  const floater = useRef<HTMLSpanElement>(null);
  const cursor = useRef<SVGSVGElement>(null);

  const drag = useRef<Drag | null>(null);
  const targetIndex = useRef(-1);
  /** Each card's settled colour, for the demo (which never drops a card's own colour on it). */
  const settled = useRef<(ColorId | null)[]>(
    CARDS.map((_, index) => defaultState?.colors?.[index] ?? null),
  );
  /** Stops the landing in progress on each card. */
  const stopLanding = useRef<((() => void) | undefined)[]>([]);
  const landingCount = useRef(0);
  const homeward = useRef<Animation | null>(null);
  /** The click that follows a drag's pointerup is not a press. */
  const clickAfterDrag = useRef(false);
  const stopDemo = useRef<(() => void) | null>(null);

  /*
   * The helpers below read refs and set state, nothing else, so the demo (an effect) can hold on
   * to the ones of the render it started in.
   */

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** A point on the screen, in the stage's px. */
  const toStage = (clientX: number, clientY: number): Point => {
    const box = root.current?.getBoundingClientRect();
    return { x: clientX - (box?.left ?? 0), y: clientY - (box?.top ?? 0) };
  };

  const centreOf = (element: Element) => {
    const box = element.getBoundingClientRect();
    return toStage(box.left + box.width / 2, box.top + box.height / 2);
  };

  /** The card under a point in the stage's px, or -1. */
  const cardAt = (point: Point) => {
    const stage = root.current?.getBoundingClientRect();
    if (!stage) return -1;
    const x = point.x + stage.left;
    const y = point.y + stage.top;
    return cardElements.current.findIndex((card) => {
      const box = card?.getBoundingClientRect();
      return !!box && x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
    });
  };

  /** Lands `color` on card `index` from `at`, in the card's px (its middle by default). */
  const land = (index: number, color: ColorId, at?: Point) => {
    const canvas = canvases.current[index];
    const face = newFaces.current[index];
    if (!canvas || !face) return;
    stopLanding.current[index]?.();
    const id = ++landingCount.current;
    const withWave = !prefersReducedMotion();
    setCards((now) =>
      now.map((card, i) =>
        i === index ? { ...card, landing: { color, id, wave: withWave } } : card,
      ),
    );
    const settle = () => {
      settled.current[index] = color;
      setCards((now) =>
        now.map((card, i) =>
          i === index && card.landing?.id === id ? { color, landing: null } : card,
        ),
      );
    };

    if (!withWave) {
      face.style.clipPath = '';
      const fade = face.animate([{ opacity: 0 }, { opacity: 1 }], {
        duration: FADE_DURATION,
        easing: 'ease',
      });
      // A cancelled fade (another colour landed first) never settles.
      fade.finished.then(settle, () => {});
      stopLanding.current[index] = () => fade.cancel();
      return;
    }

    const width = canvas.offsetWidth;
    const height = canvas.offsetHeight;
    const from = at ?? { x: width / 2, y: height / 2 };
    const wave = createWave({ ...from, width, height, color: toRgb(swatchOf(color).hex) });
    stopLanding.current[index] = playWave(wave, canvas, face, settle);
  };

  /** Takes the dragged colour out of its slot: the slot dims to a ghost, and a copy follows. */
  const lift = (current: Drag) => {
    homeward.current?.cancel();
    current.lifted = true;
    const dot = floater.current;
    if (dot) {
      dot.style.width = `${current.size}px`;
      dot.style.height = `${current.size}px`;
    }
    if (current.pointerId !== null) root.current?.setAttribute('data-grabbing', '');
    setLifted(current.color);
    setPicked(null);
    follow(current, current.at);
  };

  /** Moves the dragged dot's centre to `point`, and marks the card under it. */
  const follow = (current: Drag, point: Point) => {
    if (drag.current !== current) return;
    current.at = point;
    const half = current.size / 2;
    floater.current?.style.setProperty(
      'transform',
      `translate3d(${point.x - half}px, ${point.y - half}px, 0) scale(1.08)`,
    );
    const over = cardAt(point);
    if (over !== targetIndex.current) {
      targetIndex.current = over;
      setTarget(over);
    }
  };

  const endDrag = () => {
    drag.current = null;
    targetIndex.current = -1;
    setTarget(-1);
    root.current?.removeAttribute('data-grabbing');
  };

  /** Lets go of the dot: onto the card under it, or back to the palette. */
  const release = (current: Drag) => {
    if (drag.current !== current) return;
    endDrag();
    const over = cardAt(current.at);
    if (over < 0) {
      floatHome(current);
      return;
    }
    setLifted(null);
    const card = canvases.current[over]?.getBoundingClientRect();
    const stage = root.current?.getBoundingClientRect();
    const at =
      card && stage
        ? { x: current.at.x + stage.left - card.left, y: current.at.y + stage.top - card.top }
        : undefined;
    land(over, current.color, at);
    if (current.pointerId !== null) {
      announce(`${CARDS[over].title} is now ${colorName(current.color)}.`);
      onChange?.(CARDS[over].id, current.color);
    }
  };

  /** Drops the drag without landing anything: the dot floats back to its slot. */
  const cancel = (current: Drag) => {
    if (drag.current !== current) return;
    endDrag();
    if (current.lifted) floatHome(current);
  };

  /** Floats the dot back to its slot. */
  const floatHome = (current: Drag) => {
    const dot = floater.current;
    const slot = dots.current.get(current.color);
    if (!dot || !slot || prefersReducedMotion()) {
      setLifted(null);
      return;
    }
    const home = centreOf(slot);
    const half = current.size / 2;
    const from = dot.style.transform;
    const to = `translate3d(${home.x - half}px, ${home.y - half}px, 0)`;
    dot.style.transform = to;
    const flight = dot.animate([{ transform: from }, { transform: to }], {
      duration: 420,
      easing: 'cubic-bezier(0.3, 1.3, 0.5, 1)',
    });
    homeward.current = flight;
    // Cancelled when another drag takes the dot first.
    flight.finished.then(
      () => setLifted(null),
      () => {},
    );
  };

  /** A press on a card with no colour picked: the palette bounces, to show where to start. */
  const nudgePalette = () => {
    if (prefersReducedMotion()) return;
    SWATCHES.forEach((swatch, i) => {
      dots.current
        .get(swatch.id)
        ?.animate([{ transform: 'none' }, { transform: 'scale(1.18)' }, { transform: 'none' }], {
          duration: 380,
          delay: i * 45,
          easing: 'ease-in-out',
        });
    });
  };

  /** The reader takes over: the demo stops at once, and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    take();
  };

  const pick = (color: ColorId) => {
    const next = picked === color ? null : color;
    setPicked(next);
    announce(
      next
        ? `${swatchOf(next).name} picked. Press a card to apply it, or Escape to cancel.`
        : `${swatchOf(color).name} put back.`,
    );
  };

  const pressCard = (event: MouseEvent<HTMLButtonElement>, index: number) => {
    if (!picked) {
      announce('Pick a colour first, then press a card.');
      nudgePalette();
      return;
    }
    // A click or a tap lands where it pressed; a key, in the middle of the card.
    const box = canvases.current[index]?.getBoundingClientRect();
    const at =
      event.detail > 0 && box
        ? { x: event.clientX - box.left, y: event.clientY - box.top }
        : undefined;
    land(index, picked, at);
    setPicked(null);
    announce(`${CARDS[index].title} is now ${colorName(picked)}.`);
    onChange?.(CARDS[index].id, picked);
  };

  const onDotPointerDown = (event: PointerEvent<HTMLButtonElement>, color: ColorId) => {
    clickAfterDrag.current = false;
    if (event.button !== 0 || drag.current) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    const start = toStage(event.clientX, event.clientY);
    const centre = centreOf(event.currentTarget);
    drag.current = {
      color,
      pointerId: event.pointerId,
      start,
      offset: { x: centre.x - start.x, y: centre.y - start.y },
      size: event.currentTarget.offsetWidth,
      lifted: false,
      at: centre,
    };
  };

  const onDotPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
    const current = drag.current;
    if (!current || current.pointerId !== event.pointerId) return;
    const point = toStage(event.clientX, event.clientY);
    if (!current.lifted) {
      const moved = Math.hypot(point.x - current.start.x, point.y - current.start.y);
      if (moved < LIFT_DISTANCE) return;
      lift(current);
    }
    take();
    follow(current, { x: point.x + current.offset.x, y: point.y + current.offset.y });
  };

  const onDotPointerUp = (event: PointerEvent<HTMLButtonElement>) => {
    const current = drag.current;
    if (!current || current.pointerId !== event.pointerId) return;
    if (current.lifted) {
      clickAfterDrag.current = true;
      release(current);
    } else {
      // A press without a drag: the click that follows picks the colour.
      endDrag();
    }
  };

  /** The pointer was taken away (a touch turned into a gesture, the window lost focus...). */
  const onDotPointerCancel = (event: PointerEvent<HTMLButtonElement>) => {
    const current = drag.current;
    if (current?.pointerId === event.pointerId) cancel(current);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'Escape') return;
    const current = drag.current;
    if (current) {
      if (current.pointerId !== null) clickAfterDrag.current = true;
      cancel(current);
    }
    if (picked) {
      setPicked(null);
      announce('Cancelled.');
    }
  };

  // The demo: a ghost cursor picks a colour, drags it along a gentle curve and drops it on a
  // card, then another colour onto the other card, and so on, while the playground is on screen
  // and untouched (and no colour is waiting for a card).
  const demo = playing && !picked;
  useEffect(() => {
    const stage = root.current;
    const pointer = cursor.current;
    if (!demo || !stage || !pointer) return;
    let alive = true;
    let frame = 0;
    let timer = 0;
    let current: Drag | null = null;
    let at: Point = { x: stage.clientWidth * 0.56, y: stage.clientHeight * 0.86 };

    const place = (point: Point) => {
      at = point;
      // The arrow's tip is at (3, 2) in its 22px box.
      pointer.style.transform = `translate3d(${point.x - 3}px, ${point.y - 2}px, 0)`;
    };
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    /** Moves the cursor to `to` on a gentle curve, passing each position to `each`. */
    const glide = (to: Point, each?: (point: Point) => void) =>
      new Promise<void>((resolve) => {
        const from = at;
        const dx = to.x - from.x;
        const dy = to.y - from.y;
        const duration = Math.min(1300, Math.max(600, Math.hypot(dx, dy) * 2));
        // A quadratic curve whose control point sits off the straight line by a fifth of it.
        const bend = { x: (from.x + to.x) / 2 - dy * 0.2, y: (from.y + to.y) / 2 + dx * 0.2 };
        let start = -1;
        const step = (now: number) => {
          if (!alive) return;
          if (start < 0) start = now;
          const t = Math.min(1, Math.max(0, (now - start) / duration));
          const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
          const point = {
            x: (1 - e) ** 2 * from.x + 2 * (1 - e) * e * bend.x + e ** 2 * to.x,
            y: (1 - e) ** 2 * from.y + 2 * (1 - e) * e * bend.y + e ** 2 * to.y,
          };
          place(point);
          each?.(point);
          if (t < 1) frame = requestAnimationFrame(step);
          else resolve();
        };
        frame = requestAnimationFrame(step);
      });

    const dropOne = async (turn: number) => {
      const [planned, index] = DEMO_DROPS[turn % DEMO_DROPS.length];
      // Never the colour the card already has: then the next one along.
      const color = settled.current[index] === planned ? nextColor(planned) : planned;
      const slot = dots.current.get(color);
      const card = cardElements.current[index];
      if (!slot || !card) return;
      const home = centreOf(slot);
      await glide(home);
      // A drag in progress can only be the reader's: no turn then.
      if (!alive || drag.current) return;
      // Press, take the colour, carry it over, let go.
      pointer.dataset.state = 'pressed';
      await wait(150);
      if (!alive) return;
      const held: Drag = {
        color,
        pointerId: null,
        start: home,
        offset: { x: 0, y: 0 },
        size: slot.offsetWidth,
        lifted: false,
        at: home,
      };
      current = held;
      drag.current = held;
      lift(held);
      const box = card.getBoundingClientRect();
      const corner = toStage(box.left, box.top);
      const spot = DEMO_SPOTS[turn % DEMO_SPOTS.length];
      await glide(
        { x: corner.x + box.width * spot.x, y: corner.y + box.height * spot.y },
        (point) => follow(held, point),
      );
      if (!alive) return;
      await wait(180);
      if (!alive) return;
      release(held);
      current = null;
      pointer.dataset.state = 'shown';
    };

    (async () => {
      place(at);
      await wait(500);
      if (!alive) return;
      pointer.dataset.state = 'shown';
      for (let turn = 0; alive; turn += 1) {
        await dropOne(turn);
        if (!alive) return;
        await wait(turn % 2 ? 2600 : 1400);
      }
    })();

    const stop = () => {
      if (!alive) return;
      alive = false;
      cancelAnimationFrame(frame);
      window.clearTimeout(timer);
      pointer.dataset.state = 'hidden';
      if (current) cancel(current);
    };
    stopDemo.current = stop;
    return stop;
    // The helpers it calls read refs only (see above), so `demo` is all it depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo]);

  // Opened mid-drag or mid-wave (defaultState): the held dot over its card, the wave's still.
  useLayoutEffect(() => {
    const dragging = defaultState?.dragging;
    const dot = floater.current;
    const slot = dragging && dots.current.get(dragging.color);
    const card = dragging && cardElements.current[dragging.card];
    if (dot && slot && card) {
      const size = slot.offsetWidth;
      const at = {
        x: card.offsetLeft + card.offsetWidth * 0.62,
        y: card.offsetTop + card.offsetHeight * 0.34,
      };
      dot.style.width = `${size}px`;
      dot.style.height = `${size}px`;
      dot.style.transform = `translate3d(${at.x - size / 2}px, ${at.y - size / 2}px, 0) scale(1.08)`;
    }
    const landing = defaultState?.landing;
    const canvas = landing && canvases.current[landing.card];
    const face = landing && newFaces.current[landing.card];
    if (!landing || !canvas || !face) return;
    const width = canvas.offsetWidth;
    const height = canvas.offsetHeight;
    const wave = createWave({
      x: width * 0.38,
      y: height * 0.32,
      width,
      height,
      color: toRgb(swatchOf(landing.color).hex),
      seed: 7,
    });
    const still = stillWave(wave, canvas, face, wave.duration * landing.progress);
    stopLanding.current[landing.card] = still;
    return still;
    // Read once, on opening: the state is where it starts, not something it follows.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Nothing keeps running once the playground is gone.
  useEffect(
    () => () => {
      stopLanding.current.forEach((stop) => stop?.());
      homeward.current?.cancel();
    },
    [],
  );

  /** The colour on its way to a card: dragged, or picked and waiting. */
  const carried = lifted ?? picked;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      role="group"
      aria-label="Theme switcher"
      aria-describedby={hintId}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onKeyDown={onKeyDown}
    >
      <div className={styles.stage}>
        <div className={styles.palette} data-part="palette" role="group" aria-labelledby={labelId}>
          <span id={labelId} className={styles.paletteLabel} data-part="label">
            Theme
          </span>
          <div className={styles.dots}>
            {SWATCHES.map((swatch) => (
              <button
                key={swatch.id}
                ref={(element) => {
                  if (!element) return;
                  dots.current.set(swatch.id, element);
                  return () => {
                    dots.current.delete(swatch.id);
                  };
                }}
                type="button"
                className={styles.dot}
                data-part="swatch"
                style={{ '--dot': swatch.hex } as CSSProperties}
                aria-label={swatch.name}
                aria-pressed={picked === swatch.id}
                data-ghost={lifted === swatch.id || undefined}
                onPointerDown={(event) => onDotPointerDown(event, swatch.id)}
                onPointerMove={onDotPointerMove}
                onPointerUp={onDotPointerUp}
                onPointerCancel={onDotPointerCancel}
                onLostPointerCapture={onDotPointerCancel}
                onClick={(event) => {
                  // Keyboard clicks have no detail: they are always presses.
                  if (event.detail > 0 && clickAfterDrag.current) return;
                  pick(swatch.id);
                }}
              />
            ))}
          </div>
        </div>

        <div className={styles.cards}>
          {CARDS.map((card, index) => {
            const { color, landing } = cards[index];
            return (
              <button
                key={card.id}
                ref={(element) => {
                  cardElements.current[index] = element;
                }}
                type="button"
                className={styles.card}
                data-part="card"
                style={carried ? ({ '--drop': swatchOf(carried).hex } as CSSProperties) : undefined}
                data-target={target === index || undefined}
                data-armed={picked ? '' : undefined}
                aria-label={
                  picked
                    ? `Apply ${colorName(picked)} to ${card.title}`
                    : `${card.title}, ${color ? colorName(color) : 'grey'} theme`
                }
                onClick={(event) => pressCard(event, index)}
              >
                {/* Underneath: the card's look, drained while a new colour rolls in. */}
                <CardFace card={card} theme={landing?.wave ? 'drained' : (color ?? 'neutral')} />
                {/* On top: the new look, revealed inside the ring (or faded in). */}
                <CardFace
                  ref={(element) => {
                    newFaces.current[index] = element;
                  }}
                  card={card}
                  theme={landing?.color ?? color ?? 'neutral'}
                  className={styles.newFace}
                  active={!!landing}
                />
                <canvas
                  ref={(element) => {
                    canvases.current[index] = element;
                  }}
                  className={styles.canvas}
                  data-part="wave"
                  width={0}
                  height={0}
                  aria-hidden="true"
                />
              </button>
            );
          })}
        </div>
      </div>

      <span
        ref={floater}
        className={styles.floater}
        data-part="held"
        style={lifted ? ({ '--dot': swatchOf(lifted).hex } as CSSProperties) : undefined}
        data-shown={lifted ? '' : undefined}
        aria-hidden="true"
      />
      <svg
        ref={cursor}
        className={styles.cursor}
        viewBox="0 0 20 20"
        data-state="hidden"
        aria-hidden="true"
      >
        <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
      </svg>

      <p id={hintId} className={styles.hidden}>
        Drag a colour from the palette onto a card, or press a colour and then a card.
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### chromatic-goo/chromatic-goo.module.css

```css
/* Chromatic goo (chromatic-goo.tsx): a stage with a palette of colours and two cards. The
   layout follows the component's own width (a container query): from 700px the palette stands on
   the left of the cards side by side; narrower, it is a row above the cards, stacked.

   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). The palette's colours and the cards' themes
   (themes.ts) are the same on both. */

.root {
  /* The stage and its edge (none on dark), and the cards on it; the cards' edge. */
  --surface: #f2f2f5;
  --edge: rgb(0 0 0 / 0.05);
  --surface-2: #fff;
  --line: rgb(0 0 0 / 0.08);
  /* The titles; the cards' text, their figures' labels, and the palette's label. */
  --ink: #141416;
  --ink-muted: #66666e;
  --ink-faint: #6c6c74;
  --label: #6c6c74;
  /* The secondary button, and its label; the primary's label, on the theme's colour. */
  --secondary: #f0f0f3;
  --secondary-ink: #4a4a51;
  --accent-ink: #fff;
  /* Keyboard focus, and the ring round a picked colour. */
  --focus: #141416;
  --ring: rgb(20 20 22 / 0.85);
  /* The dragged colour's shadow. */
  --shadow: rgb(0 0 0 / 0.35);
  /* The wave's foam, the same on both (wave.ts reads it): the drop, the crest's line and haze,
     the bubbles' rings. */
  --foam: #fff;
  --radius: 22px;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  border-radius: 18px;
  background: var(--surface);
  color: var(--ink);
  font:
    400 14px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f2f2f5, #0c0c0e);
    --edge: light-dark(rgb(0 0 0 / 0.05), transparent);
    --surface-2: light-dark(#fff, #141416);
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));
    --ink: light-dark(#141416, #fff);
    --ink-muted: light-dark(#66666e, #7a7a81);
    --ink-faint: light-dark(#6c6c74, #66666d);
    --label: light-dark(#6c6c74, #5d5d64);
    --secondary: light-dark(#f0f0f3, #1e1e21);
    --secondary-ink: light-dark(#4a4a51, #b3b3b9);
    --focus: light-dark(#141416, #fff);
    --ring: light-dark(rgb(20 20 22 / 0.85), rgb(255 255 255 / 0.9));
    --shadow: light-dark(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.8));
  }
}
/* 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;
}
/* The stage's hairline edge, over everything in it. */
.root::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--edge);
  pointer-events: none;
}
/* While the reader drags a colour, wherever the pointer is. */
.root[data-grabbing],
.root[data-grabbing] * {
  cursor: grabbing;
}

.stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 22px;
  padding: 24px 16px 28px;
}

/* The palette: a row (its label wraps above it when there is no room), or a column. */
.palette {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px 14px;
}
.paletteLabel {
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--label);
}
.dots {
  display: flex;
  gap: 8px;
}
.dot {
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--dot);
  cursor: grab;
  /* The dots alone: a drag on a dot never scrolls the page, a swipe anywhere else does. */
  touch-action: none;
  transition:
    transform 0.2s var(--ease-out),
    opacity 0.2s ease,
    box-shadow 0.2s ease;
}
@media (hover: hover) {
  .dot:hover {
    transform: scale(1.08);
  }
}
/* Its slot while the colour is out: a ghost of it. */
.dot[data-ghost] {
  opacity: 0.22;
  transform: none;
}
/* Picked with a press, waiting for a card. */
.dot[aria-pressed='true'] {
  transform: scale(1.1);
  box-shadow:
    0 0 0 3px var(--surface),
    0 0 0 5px var(--ring);
}
.dot:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}

/* The cards. */
.cards {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: 100%;
  max-width: 400px;
}
.card {
  position: relative;
  display: block;
  width: 100%;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-2);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: default;
  transition:
    border-color 0.2s ease,
    box-shadow 0.2s ease;
}
/* A colour is on its way: the card under it, or (once picked) the card under the mouse or the
   keyboard focus. */
.card[data-target],
.card[data-armed]:focus-visible {
  border-color: color-mix(in srgb, var(--drop) 60%, transparent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--drop) 14%, transparent);
}
@media (hover: hover) {
  .card[data-armed]:hover {
    border-color: color-mix(in srgb, var(--drop) 60%, transparent);
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--drop) 14%, transparent);
  }
}
.card[data-armed] {
  cursor: pointer;
}
.card:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* A card's content in a theme (cards.tsx): --accent and --accent-fill come from the theme.
   --accent-text is the accent as the icon and the figures show it: as it is on dark; on light,
   deepened with the ink, where the palette's lighter colours would be too faint on white. It is
   declared here, on the face, which holds the theme. */
.face {
  --accent-text: color-mix(in oklab, var(--accent) 75%, #141416);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: 26px;
  border-radius: calc(var(--radius) - 1px);
}
@supports (color: light-dark(#fff, #000)) {
  /* A card's content in a theme (cards.tsx): --accent and --accent-fill come from the theme.
   --accent-text is the accent as the icon and the figures show it: as it is on dark; on light,
   deepened with the ink, where the palette's lighter colours would be too faint on white. It is
   declared here, on the face, which holds the theme. */
  .face {
    --accent-text: light-dark(color-mix(in oklab, var(--accent) 75%, #141416), var(--accent));
  }
}
.face[data-theme='drained'] {
  opacity: 0.5;
}
/* The new look, over the old one: hidden until a colour lands. */
.newFace {
  position: absolute;
  inset: 0;
  background: var(--surface-2);
  visibility: hidden;
}
.newFace[data-active] {
  visibility: visible;
}
.tile {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: color-mix(in srgb, var(--accent) 13%, var(--surface-2));
  color: var(--accent-text);
}
.tile svg {
  width: 22px;
  height: 22px;
}
.title {
  margin-top: 22px;
  font-size: 18px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.01em;
}
.text {
  margin-top: 8px;
  font-size: 14.5px;
  line-height: 1.55;
  color: var(--ink-muted);
}
.stats {
  display: flex;
  gap: 26px;
  margin-top: 22px;
}
.stat {
  display: flex;
  flex-direction: column;
}
.value {
  font-size: 28px;
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--accent-text);
}
.label {
  margin-top: 5px;
  font-size: 12.5px;
  color: var(--ink-faint);
}
.actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  align-self: stretch;
  margin-top: 26px;
}
.primary,
.secondary {
  display: grid;
  place-items: center;
  height: 40px;
  border-radius: 10px;
  font-size: 14px;
  font-weight: 600;
}
.primary {
  background: var(--accent-fill);
  color: var(--accent-ink);
}
.secondary {
  background: var(--secondary);
  color: var(--secondary-ink);
  font-weight: 500;
}

/* The wave's canvas, over the card (wave.ts). It holds no pixels (0 × 0) unless a wave plays.
   Its colour is the foam the wave paints, read as it draws. */
.canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: calc(var(--radius) - 1px);
  color: var(--foam);
  pointer-events: none;
}

/* The dragged colour, following the pointer (or floating back to its slot). */
.floater {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--dot);
  box-shadow: 0 12px 28px -10px var(--shadow);
  pointer-events: none;
  visibility: hidden;
}
.floater[data-shown] {
  visibility: visible;
}

/* The demo's ghost cursor. */
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  width: 22px;
  height: 22px;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.cursor path {
  fill: #fff;
  stroke: #111;
  stroke-width: 1.2;
  stroke-linejoin: round;
  transform-origin: 3px 2px;
  transition: transform 0.12s ease;
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.cursor[data-state='pressed'] path {
  transform: scale(0.86);
}

/* For screen readers only: the instructions and the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (min-width: 700px) {
  .stage {
    flex-direction: row;
    justify-content: center;
    gap: clamp(28px, 4.5cqi, 48px);
    min-height: 560px;
    padding: 40px 32px;
  }
  .palette {
    flex-direction: column;
    flex-wrap: nowrap;
    gap: 16px;
  }
  .dots {
    flex-direction: column;
    gap: 12px;
  }
  .dot {
    width: 36px;
    height: 36px;
  }
  .cards {
    flex: 0 1 660px;
    flex-direction: row;
    gap: 20px;
    width: auto;
    max-width: none;
    min-width: 0;
  }
  .card {
    flex: 1 1 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dot,
  .card,
  .cursor,
  .cursor path {
    transition: none;
  }
}
```

### chromatic-goo/wave.ts

```tsx
/*
 * The wave a colour makes as it lands on a card, drawn on a <canvas> laid over the card.
 *
 * The drop is a white blob that opens into a ring and rolls out across the card. The ring is a
 * closed curve round the drop point: a radius that grows with time (`ringRadius`), wobbled by a
 * few slow sines so its edge looks liquid, and bulging wherever a bubble forms (`edgeDistances`).
 * It is drawn as a white crest over a fringe of the new colour, with a band of the colour behind
 * it; the white fades as the ring travels, so the ring takes on the colour. The bubbles pinch off
 * the crest as small white rings, drift on and fade.
 *
 * A frame is a function of the time since the drop alone: nothing carries over from one frame to
 * the next, so a slow frame costs nothing, and the card's new face can be clipped to the very same
 * edge (`revealClip`): the colours change exactly where the wavefront is.
 *
 * The white is the foam: a CSS token (--foam), the canvas's own colour, read as each frame is
 * drawn, so it follows the stylesheet and its colour scheme.
 */

export type Rgb = readonly [red: number, green: number, blue: number];

/** One of the sines that wobble the ring's edge. */
type Ripple = { lobes: number; phase: number; drift: number; weight: number };

type Bubble = {
  /** Where on the ring it forms, in radians. */
  angle: number;
  /** When it starts to bulge out of the crest, in ms after the drop. */
  born: number;
  /** Its radius, in px. */
  size: number;
  /** Its speed once free, as a share of the ring's: below 1, the ring soon overtakes it. */
  push: number;
  /** Its sideways drift, in radians per second. */
  spin: number;
  /** How long it lasts, in ms after it is born. */
  life: number;
  /** A drop of the colour rather than a white ring. */
  droplet: boolean;
};

export type Wave = {
  /** Where the colour landed, in the card's px. */
  x: number;
  y: number;
  /** The card's size, in px. */
  width: number;
  height: number;
  /** How far the ring rolls: far enough to have left the card. */
  reach: number;
  /** How long that takes, in ms. */
  duration: number;
  color: Rgb;
  ripples: Ripple[];
  bubbles: Bubble[];
};

/** The ring opens from the size of the dropped dot, in px. */
const START = 14;
/** The share of the ring's travel made in the first splash. */
const SPLASH = 0.2;
const BUBBLES = 48;
/** A bubble bulges out of the crest for FORM ms, then snaps free as the bulge settles (SNAP). */
const FORM = 90;
const SNAP = 140;
/** How quickly a free bubble slows down, in ms (as if held back by the liquid). */
const DRAG = 320;
/** The glow is drawn at this scale, then stretched: a free blur. */
const GLOW_SCALE = 0.25;

const TAU = Math.PI * 2;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const easeOut = (t: number) => 1 - (1 - t) ** 3;
const smoothstep = (from: number, to: number, value: number) => {
  const t = clamp((value - from) / (to - from), 0, 1);
  return t * t * (3 - 2 * t);
};
/** The gap between two angles, in radians (0 to π). */
const angleBetween = (a: number, b: number) => {
  const gap = Math.abs(a - b) % TAU;
  return gap > Math.PI ? TAU - gap : gap;
};
const rgba = ([red, green, blue]: Rgb, alpha: number) =>
  `rgba(${red}, ${green}, ${blue}, ${clamp(alpha, 0, 1).toFixed(3)})`;
/** A computed sRGB colour's red, green and blue, from 0 to 255; null for other colour spaces. */
const channels = (colour: string): Rgb | null => {
  const legacy = colour.startsWith('rgb');
  if (!legacy && !colour.startsWith('color(srgb ')) return null;
  const [red, green, blue] = (colour.match(/[\d.]+/g) ?? []).map(
    (value) => Number(value) * (legacy ? 1 : 255),
  );
  return blue === undefined ? null : [red, green, blue];
};

/** A small seeded random generator (mulberry32), so a wave can be replayed exactly. */
function seeded(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function createWave({
  x,
  y,
  width,
  height,
  color,
  seed = Math.floor(Math.random() * 2 ** 32),
}: {
  x: number;
  y: number;
  width: number;
  height: number;
  color: Rgb;
  seed?: number;
}): Wave {
  const random = seeded(seed);
  const corner = Math.max(
    Math.hypot(x, y),
    Math.hypot(width - x, y),
    Math.hypot(x, height - y),
    Math.hypot(width - x, height - y),
  );
  const reach = corner + 48;
  // About 0.8 s from the middle of a card, 1 s from a corner.
  const duration = clamp(560 + reach * 0.85, 800, 1050);
  const ripples = [2, 3, 4, 6].map((lobes, i) => ({
    lobes,
    phase: random() * TAU,
    drift: (random() - 0.5) * 4,
    weight: [0.45, 0.3, 0.17, 0.08][i],
  }));
  const wave: Wave = { x, y, width, height, reach, duration, color, ripples, bubbles: [] };

  for (let i = 0; i < BUBBLES; i++) {
    // Most bubbles form early, while the ring is small and fast.
    const born = 20 + random() ** 1.5 * duration * 0.5;
    // Somewhere the crest is on the card when it snaps free: elsewhere it would never be seen.
    let angle = random() * TAU;
    for (let tries = 0; tries < 8 && !onCard(wave, born + FORM, angle); tries++) {
      angle = random() * TAU;
    }
    wave.bubbles.push({
      angle,
      born,
      size: 3 + 8 * random() ** 1.6,
      push: 0.7 + random(),
      spin: (random() - 0.5) * 0.6,
      life: Math.min(420 + random() * 360, duration - born),
      droplet: random() < 0.12,
    });
  }
  return wave;
}

/**
 * The ring's mean radius `elapsed` ms after the drop: it opens from the dot's size in a quick
 * splash, then rolls on steadily.
 */
export function ringRadius(wave: Wave, elapsed: number) {
  const t = clamp(elapsed / wave.duration, 0, 1);
  const splash = easeOut(Math.min(1, t / 0.15));
  return START + (wave.reach - START) * (SPLASH * splash + (1 - SPLASH) * t);
}

/** The ring's radius at `angle` before any bubble bulges: the mean plus the wobble. */
function wobbled(wave: Wave, elapsed: number, radius: number, angle: number) {
  const seconds = elapsed / 1000;
  let wobble = 0;
  for (const { lobes, phase, drift, weight } of wave.ripples) {
    wobble += weight * Math.sin(lobes * angle + phase + drift * seconds);
  }
  // All lobes while it is a blob, a little uneven once it rolls.
  const settling = smoothstep(0, 0.35, elapsed / wave.duration);
  return radius + Math.min(radius * 0.3, 10 + 16 * (1 - settling)) * wobble;
}

/** Whether the crest at `angle` is on the card `elapsed` ms after the drop. */
function onCard(wave: Wave, elapsed: number, angle: number) {
  const distance = wobbled(wave, elapsed, ringRadius(wave, elapsed), angle);
  const x = wave.x + Math.cos(angle) * distance;
  const y = wave.y + Math.sin(angle) * distance;
  return x > 8 && x < wave.width - 8 && y > 8 && y < wave.height - 8;
}

/** How far a bubble's bulge stands out of the crest: it grows, then settles once it snaps free. */
function bulge(bubble: Bubble, elapsed: number) {
  const age = elapsed - bubble.born;
  if (age <= 0 || age >= FORM + SNAP) return 0;
  const tallest = bubble.size * 1.2;
  return age < FORM ? tallest * easeOut(age / FORM) : tallest * (1 - easeOut((age - FORM) / SNAP));
}

/** The distance from the drop point to the ring's edge, at `count` angles evenly round it. */
function edgeDistances(wave: Wave, elapsed: number, count: number) {
  const radius = ringRadius(wave, elapsed);
  const forming = wave.bubbles
    .map((bubble) => ({ bubble, height: bulge(bubble, elapsed) }))
    .filter(({ height }) => height > 0);
  const distances = new Float64Array(count);
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * TAU;
    let distance = wobbled(wave, elapsed, radius, angle);
    for (const { bubble, height } of forming) {
      // A bump about as wide as the bubble, measured along the ring.
      const along = angleBetween(angle, bubble.angle) * radius;
      distance += height * Math.exp(-((along / (bubble.size * 1.8 + 3)) ** 2));
    }
    distances[i] = distance;
  }
  return distances;
}

/** The point at `distance` from the drop point, at the i-th of `count` angles. */
function pointAt(wave: Wave, i: number, count: number, distance: number) {
  const angle = (i / count) * TAU;
  return [wave.x + Math.cos(angle) * distance, wave.y + Math.sin(angle) * distance] as const;
}

/** The CSS clip-path that shows only what is inside the ring. */
export function revealClip(wave: Wave, elapsed: number) {
  // Fewer points than the canvas draws: under the crest's glow, a slightly coarser edge never shows.
  const count = 72;
  const distances = edgeDistances(wave, elapsed, count);
  const points = Array.from(distances, (distance, i) => {
    const [x, y] = pointAt(wave, i, count, distance);
    return `${x.toFixed(1)}px ${y.toFixed(1)}px`;
  });
  return `polygon(${points.join(', ')})`;
}

/**
 * Traces the ring's edge moved `offset` px outwards (inwards when negative), as a smooth closed
 * curve: quadratic curves from midpoint to midpoint between the points.
 */
function trace(
  context: CanvasRenderingContext2D,
  wave: Wave,
  distances: Float64Array,
  offset: number,
) {
  const count = distances.length;
  const point = (i: number) =>
    pointAt(wave, i % count, count, Math.max(0, distances[i % count] + offset));
  context.beginPath();
  const [lastX, lastY] = point(count - 1);
  const [firstX, firstY] = point(0);
  context.moveTo((lastX + firstX) / 2, (lastY + firstY) / 2);
  for (let i = 0; i < count; i++) {
    const [x, y] = point(i);
    const [nextX, nextY] = point(i + 1);
    context.quadraticCurveTo(x, y, (x + nextX) / 2, (y + nextY) / 2);
  }
  context.closePath();
}

/**
 * Draws the wave as it is `elapsed` ms after the drop, its foam in `foam`. `context` covers the
 * card, in its px; the soft glow is drawn on `glow`, a canvas a few times smaller, then stretched
 * over the card: the stretching blurs it, at almost no cost.
 */
export function drawWave(
  context: CanvasRenderingContext2D,
  glow: CanvasRenderingContext2D,
  wave: Wave,
  elapsed: number,
  foam: Rgb,
) {
  const t = clamp(elapsed / wave.duration, 0, 1);
  const radius = ringRadius(wave, elapsed);
  // The wave dissolves as it leaves the card; the crest starts white and takes on the colour.
  const fade = 1 - smoothstep(0.8, 1, t);
  const whiteness = 1 - smoothstep(0.05, 0.6, t);
  const color = (alpha: number) => rgba(wave.color, alpha * fade);
  const white = (alpha: number) => rgba(foam, alpha * fade);
  const stroke = (layer: CanvasRenderingContext2D, width: number, style: string) => {
    layer.lineWidth = width;
    layer.strokeStyle = style;
    layer.stroke();
  };
  // About one point every 5px along the ring, so the bulges stay round.
  const distances = edgeDistances(wave, elapsed, Math.round(clamp((radius * TAU) / 5, 64, 360)));
  const band = 8 + 34 * smoothstep(0, 0.55, t);
  const crest = 5 - 2.5 * t;

  // The glow: a band of the colour behind the crest, and a white haze on the crest. The band is
  // nested strokes that all end at the crest, so it is densest there and fades inwards.
  glow.clearRect(0, 0, wave.width, wave.height);
  glow.lineJoin = 'round';
  const layers = 6;
  for (let layer = 1; layer <= layers; layer++) {
    const width = (band * layer) / layers;
    trace(glow, wave, distances, -width / 2);
    stroke(glow, width, color(0.12));
  }
  trace(glow, wave, distances, 2);
  stroke(glow, crest + 10, white(0.45 * whiteness));
  stroke(glow, crest + 16, color(0.25));
  context.clearRect(0, 0, wave.width, wave.height);
  context.imageSmoothingQuality = 'high';
  context.drawImage(glow.canvas, 0, 0, wave.width, wave.height);

  // The crest: the blob's white at first, then the colour's fringe and the white line on it.
  context.lineJoin = 'round';
  trace(context, wave, distances, 0);
  const splash = 1 - smoothstep(0, 0.07, t);
  if (splash > 0) {
    context.fillStyle = white(splash);
    context.fill();
  }
  stroke(context, crest + 4, color(0.8));
  stroke(context, crest, white(whiteness));

  for (const bubble of wave.bubbles) drawBubble(context, wave, bubble, elapsed, foam);
}

function drawBubble(
  context: CanvasRenderingContext2D,
  wave: Wave,
  bubble: Bubble,
  elapsed: number,
  foam: Rgb,
) {
  const age = elapsed - bubble.born;
  if (age < FORM || age > bubble.life) return;
  const free = age - FORM;
  // It snaps free at the tip of its bulge, then rides on its push, slowing down.
  const snappedAt = bubble.born + FORM;
  const tip = wobbled(wave, snappedAt, ringRadius(wave, snappedAt), bubble.angle);
  // The ring's steady speed after the splash, in px per ms.
  const ringSpeed = (wave.reach * (1 - SPLASH)) / wave.duration;
  const distance =
    tip + bubble.size * 1.8 + bubble.push * ringSpeed * DRAG * (1 - Math.exp(-free / DRAG));
  const angle = bubble.angle + (bubble.spin * free) / 1000;
  // It pops out a little smaller, then swells as it drifts. A droplet stays small.
  const size =
    bubble.size *
    (bubble.droplet ? 0.5 : 1) *
    (0.7 + 0.3 * easeOut(Math.min(1, free / 80)) + (0.2 * free) / bubble.life);
  const alpha = 1 - smoothstep(bubble.life * 0.55, bubble.life, age);

  context.beginPath();
  context.arc(
    wave.x + Math.cos(angle) * distance,
    wave.y + Math.sin(angle) * distance,
    size,
    0,
    TAU,
  );
  if (bubble.droplet) {
    context.fillStyle = rgba(wave.color, alpha);
    context.fill();
    return;
  }
  const line = clamp(size * 0.32, 1.6, 2.6);
  context.lineWidth = line + 3;
  context.strokeStyle = rgba(wave.color, 0.55 * alpha);
  context.stroke();
  context.lineWidth = line;
  context.strokeStyle = rgba(foam, alpha);
  context.stroke();
}

/**
 * Plays `wave` on `canvas` and reveals `face` (the card's new look) inside the ring. Calls `onEnd`
 * once the ring has left the card, and returns a function that stops it sooner. The canvas holds
 * pixels only while a wave plays.
 */
export function playWave(
  wave: Wave,
  canvas: HTMLCanvasElement,
  face: HTMLElement,
  onEnd: () => void,
) {
  // Sharp enough at 2×, without the memory of a 3× canvas.
  const ratio = Math.min(2, window.devicePixelRatio || 1);
  canvas.width = Math.round(wave.width * ratio);
  canvas.height = Math.round(wave.height * ratio);
  const context = canvas.getContext('2d');
  // The glow's canvas: a quarter of the card's size, never on the page.
  const small = document.createElement('canvas');
  small.width = Math.ceil(wave.width * GLOW_SCALE);
  small.height = Math.ceil(wave.height * GLOW_SCALE);
  const glow = small.getContext('2d');
  glow?.setTransform(GLOW_SCALE, 0, 0, GLOW_SCALE, 0, 0);
  face.style.clipPath = revealClip(wave, 0);
  // The foam: the canvas's colour, as the CSS resolves it (white unless it cannot be read).
  const style = getComputedStyle(canvas);
  let foam: Rgb = [255, 255, 255];

  let frame = 0;
  let start = -1;
  const stop = () => {
    cancelAnimationFrame(frame);
    canvas.width = 0;
    canvas.height = 0;
    small.width = 0;
    small.height = 0;
  };
  const step = (now: number) => {
    if (start < 0) start = now;
    const elapsed = now - start;
    if (elapsed >= wave.duration) {
      stop();
      face.style.clipPath = '';
      onEnd();
      return;
    }
    if (context && glow) {
      foam = channels(style.color) ?? foam;
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      drawWave(context, glow, wave, elapsed, foam);
    }
    face.style.clipPath = revealClip(wave, elapsed);
    frame = requestAnimationFrame(step);
  };
  frame = requestAnimationFrame(step);
  return stop;
}

/**
 * Draws the wave once, as it is `elapsed` ms after the drop, and clips `face` to it: a still of a
 * landing, for a playground opened mid-wave (defaultState). Returns what gives the pixels back.
 */
export function stillWave(
  wave: Wave,
  canvas: HTMLCanvasElement,
  face: HTMLElement,
  elapsed: number,
) {
  const ratio = Math.min(2, window.devicePixelRatio || 1);
  canvas.width = Math.round(wave.width * ratio);
  canvas.height = Math.round(wave.height * ratio);
  const context = canvas.getContext('2d');
  const small = document.createElement('canvas');
  small.width = Math.ceil(wave.width * GLOW_SCALE);
  small.height = Math.ceil(wave.height * GLOW_SCALE);
  const glow = small.getContext('2d');
  glow?.setTransform(GLOW_SCALE, 0, 0, GLOW_SCALE, 0, 0);
  if (context && glow) {
    const foam = channels(getComputedStyle(canvas).color) ?? [255, 255, 255];
    context.setTransform(ratio, 0, 0, ratio, 0, 0);
    drawWave(context, glow, wave, elapsed, foam);
  }
  small.width = 0;
  small.height = 0;
  face.style.clipPath = revealClip(wave, elapsed);
  return () => {
    canvas.width = 0;
    canvas.height = 0;
    face.style.clipPath = '';
  };
}
```

### chromatic-goo/cards.tsx

```tsx
import type { ReactNode, Ref } from 'react';
import { themeStyle, type Theme } from './themes';
import styles from './chromatic-goo.module.css';

/*
 * The playground's two cards and what is on them. `CardFace` draws a card's content in a theme;
 * while a colour lands, the playground stacks two faces in the card: the old look underneath and
 * the new one on top, which the wave reveals.
 *
 * The card itself is a button (the place a colour drops), so a face holds phrasing content only:
 * spans, laid out as blocks by the CSS.
 */

export type Card = {
  id: string;
  title: string;
  text: string;
  icon: ReactNode;
  stats: readonly { value: string; label: string }[];
  actions: readonly [primary: string, secondary: string];
};

function ChartIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
      <rect x="4" y="12" width="4" height="8" rx="1" />
      <rect x="10" y="8" width="4" height="12" rx="1" />
      <rect x="16" y="4" width="4" height="16" rx="1" />
    </svg>
  );
}

function PeopleIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="9" cy="8" r="3.25" />
      <path d="M3.5 19.5c0-3.1 2.46-5.5 5.5-5.5s5.5 2.4 5.5 5.5" />
      <path d="M15.25 4.9a3.25 3.25 0 0 1 0 6.2M17.5 14.4c1.86.73 3 2.67 3 5.1" />
    </svg>
  );
}

export const CARDS: readonly Card[] = [
  {
    id: 'analytics',
    title: 'Analytics',
    text: 'Real-time metrics and insights across your entire platform.',
    icon: <ChartIcon />,
    stats: [
      { value: '2.4M', label: 'Events' },
      { value: '99.8%', label: 'Uptime' },
    ],
    actions: ['Open', 'Settings'],
  },
  {
    id: 'team',
    title: 'Team',
    text: 'Manage roles and permissions for your workspace.',
    icon: <PeopleIcon />,
    stats: [
      { value: '48', label: 'Members' },
      { value: '12', label: 'Projects' },
    ],
    actions: ['Manage', 'Invite'],
  },
];

export function CardFace({
  card,
  theme,
  className = '',
  ref,
  active,
}: {
  card: Card;
  theme: Theme;
  className?: string;
  ref?: Ref<HTMLSpanElement>;
  /** For the face on top: shown while a colour lands. */
  active?: boolean;
}) {
  return (
    <span
      ref={ref}
      className={`${styles.face} ${className}`}
      style={themeStyle(theme)}
      data-theme={theme}
      data-active={active || undefined}
    >
      <span className={styles.tile} data-part="icon">
        {card.icon}
      </span>
      <span className={styles.title}>{card.title}</span>
      <span className={styles.text}>{card.text}</span>
      <span className={styles.stats} data-part="figures">
        {card.stats.map((stat) => (
          <span key={stat.label} className={styles.stat}>
            <span className={styles.value} data-part="figure">
              {stat.value}
            </span>
            <span className={styles.label}>{stat.label}</span>
          </span>
        ))}
      </span>
      <span className={styles.actions} data-part="actions">
        <span className={styles.primary} data-part="primary">
          {card.actions[0]}
        </span>
        <span className={styles.secondary} data-part="secondary">
          {card.actions[1]}
        </span>
      </span>
    </span>
  );
}
```

### chromatic-goo/themes.ts

```tsx
import type { CSSProperties } from 'react';

/*
 * The palette's six colours, and how a theme reaches a card: as CSS custom properties on the
 * card's face. `--accent` colours the icon and the figures, `--accent-fill` the primary button;
 * the CSS mixes the icon tile's tint from `--accent`.
 */

export type ColorId = 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'pink';

export type Swatch = { id: ColorId; name: string; hex: string };

export const SWATCHES: readonly Swatch[] = [
  { id: 'red', name: 'Red', hex: '#e5533d' },
  { id: 'orange', name: 'Orange', hex: '#ef923a' },
  { id: 'green', name: 'Green', hex: '#5dbb6e' },
  { id: 'blue', name: 'Blue', hex: '#5c9bf0' },
  { id: 'purple', name: 'Purple', hex: '#7655ee' },
  { id: 'pink', name: 'Pink', hex: '#d24fa6' },
];

export function swatchOf(id: ColorId): Swatch {
  return SWATCHES.find((swatch) => swatch.id === id) ?? SWATCHES[0];
}

/**
 * How a card looks: one of the colours; `neutral`, the grey it starts with; or `drained`, the
 * dimmed grey its old colour drains to while a new one lands (the CSS also fades it).
 */
export type Theme = ColorId | 'neutral' | 'drained';

const GREYS = {
  neutral: { accent: '#80808c', fill: '#6b7081' },
  drained: { accent: '#9e9ea6', fill: '#8a8a92' },
};

export function themeStyle(theme: Theme) {
  const { accent, fill } =
    theme === 'neutral' || theme === 'drained'
      ? GREYS[theme]
      : { accent: swatchOf(theme).hex, fill: swatchOf(theme).hex };
  return { '--accent': accent, '--accent-fill': fill } as CSSProperties;
}

/** '#e5533d' → [229, 83, 61], for the canvas. */
export function toRgb(hex: string): [number, number, number] {
  const value = parseInt(hex.slice(1), 16);
  return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
```

### chromatic-goo/use-autoplay.ts

```tsx
'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 };
}
```