# Character picker

> Pick a 3D character, change its colour, and watch its eyes follow your pointer; the pick flies up into the avatar.

Choose an avatar from twenty softly lit 3D object characters, from a robot to a padlock, or the same objects as line icons. Change the selected avatar with seven visible colour swatches, or open a tile’s palette to compare the full character in every colour. The pupils follow your pointer, the bodies turn slightly, and individual characters blink. A pick flies up into the avatar and lands with a squash. The demo stops after your first interaction.

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

Category: Inputs.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/character-picker.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.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/character-picker/robot-3d-23d38062f340.webp
- https://brunodelorence.com/experiments/character-picker/gift-3d-c582bec17c61.webp
- https://brunodelorence.com/experiments/character-picker/heart-3d-6067f62af297.webp
- https://brunodelorence.com/experiments/character-picker/star-3d-3b6683298df7.webp
- https://brunodelorence.com/experiments/character-picker/bubble-3d-c0930c190908.webp
- https://brunodelorence.com/experiments/character-picker/calendar-3d-e99464209cbf.webp
- https://brunodelorence.com/experiments/character-picker/bulb-3d-d55f5fd4d1ea.webp
- https://brunodelorence.com/experiments/character-picker/envelope-3d-24b90a2a495f.webp
- https://brunodelorence.com/experiments/character-picker/gear-3d-2d3e7cda2aa4.webp
- https://brunodelorence.com/experiments/character-picker/folder-3d-449b82b9f073.webp
- https://brunodelorence.com/experiments/character-picker/book-3d-1f63dd5d23b9.webp
- https://brunodelorence.com/experiments/character-picker/chart-3d-122015d97523.webp
- https://brunodelorence.com/experiments/character-picker/pin-3d-ca69d6bcffe4.webp
- https://brunodelorence.com/experiments/character-picker/padlock-3d-0c76042a5ec9.webp
- https://brunodelorence.com/experiments/character-picker/flame-3d-4e0b9be3b98e.webp
- https://brunodelorence.com/experiments/character-picker/browser-3d-759d64556239.webp
- https://brunodelorence.com/experiments/character-picker/briefcase-3d-38f9b8e338fd.webp
- https://brunodelorence.com/experiments/character-picker/clipboard-3d-022a141e3fff.webp
- https://brunodelorence.com/experiments/character-picker/bolt-3d-e4c518eb54fa.webp
- https://brunodelorence.com/experiments/character-picker/ball-3d-39752beba86c.webp

## Usage

```tsx
'use client';

import { useState } from 'react';
import { CharacterPicker, type Avatar } from './character-picker';

export default function AssistantSettings() {
  const [avatar, setAvatar] = useState<Avatar | null>(null);
  return (
    <>
      <CharacterPicker name="Moss" onChange={setAvatar} autoplay={false} />
      <p>{avatar ? `${avatar.id}, ${avatar.colour}` : 'Pick an avatar'}</p>
    </>
  );
}
```

## How it works

- The pupils follow the pointer without a React render. One listener writes the pointer’s place to two registered custom properties; each pair of pupils computes its direction in CSS with atan2() and hypot(). The white eyeballs stay in place while the body turns gently towards the pointer.
- Each body is a generated transparent 3D render with consistent soft lighting and material. Calibrated hue, saturation and brightness filters supply the seven colours while preserving the render’s texture and shadows. The eyes, cheeks and smile are live SVG layers above the image, outside the colour filter.
- The selected avatar has a visible, keyboard-accessible palette of seven swatches. One additional palette chip and colour strip serve the whole grid; the strip opens from its tile and shows the complete character in each colour.
- A pick is FLIP: the new avatar is drawn in its slot and measured, then played from the picked drawing’s box along an arc with the Web Animations API, and it lands with a squash and a blink.
- The demo’s pointer moves the same two properties, so the pupils follow it like a real pointer. A real press or keyboard action stops the demo until the preview is restarted. Blinks pick one or two characters at a time; blinks and the avatar’s gentle bob pause off screen, in a hidden tab or under reduced motion.
- Every interface colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. The characters, the palette and the colour wheel are art and keep their colours; at night the avatar’s disc deepens to a dark tint of its colour, and line icons take the colour’s base tone.

## API reference

- **name** (`string`, default `'Moss'`): The assistant’s name, under its avatar.
- **tagline** (`string`, default `'Your helper for notes, mail and plans'`): A short line under the name.
- **defaultAvatar** (`Avatar`, default `{ kind: 'character', id: 'robot', colour: 'blue' }`): The avatar at first: a character or an icon (kind), which object (id) and its colour.
- **onChange** (`(avatar: Avatar) => void`): Called with the new avatar when one is picked.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer wanders over the grid and picks avatars. A real press or keyboard action stops it until the component is remounted.
- **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

- The grid is a listbox: the arrow keys move through the characters (up and down by rows), Home and End jump to the ends, and Enter or Space picks. Each option is named with its colour, as in “Folder, blue”.
- The palette chip is a button (“Colours for Folder”, with aria-expanded) that opens a labelled listbox of seven colours; Escape closes it and returns focus to the chip.
- The visible colour swatches are a radiogroup labelled for the selected avatar. Arrow keys change colour, Home and End jump to the ends, and the selected swatch is the group’s one tab stop.
- Each pick is announced politely (“Avatar set to Folder, blue.”). The avatar is an image named after it, the 3D characters and Icons switch is a radiogroup, and the renders, eyes and blinks are hidden from screen readers.
- Under reduced motion nothing flies or blinks, the eyes keep still, and the demo does not run.

## Source

### character-picker/character-picker.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { CHARACTERS, Character, CharacterDefs, DefsId, Icon, type CharacterId } from './characters';
import { GhostPointer, runDemo } from './ghost-pointer';
import { COLOURS, colourOf, type ColourId } from './palette';
import { spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './character-picker.module.css';

/*
 * Character picker: choose an assistant's avatar from a grid of rendered 3D object characters, or the
 * same objects as line icons, in one of seven colours.
 *
 * - Transparent 3D renders supply the bodies, their texture and lighting. A material filter changes
 *   their colour. Live white eyes, pupils, cheeks and a smile sit above the body, outside the filter.
 * - The pupils follow the pointer while the body turns a little towards it. One listener writes where the pointer is, in the stage's own px,
 *   to two registered custom properties (--cp-px, --cp-py); every pair of eyes works out its own
 *   direction in CSS, with atan2() and hypot(), from the centre of its character (--cx, --cy,
 *   measured once per layout). Nothing renders while the pointer moves.
 * - Now and then one or two characters blink: a short squash of their eyes with the Web Animations
 *   API, never the whole grid at once.
 * - Hovering or focusing a tile lifts it and puts a palette chip on its corner. The chip opens a
 *   strip of that character in every colour, which springs out of the tile; one chip and one strip
 *   serve the whole grid, placed on their tile by CSS from its index.
 * - A pick flies into the avatar slot (FLIP): the new avatar is drawn in its place, measured, then
 *   played from the picked drawing's box along an arc, and lands with a squash and a blink.
 * - While on screen and untouched, a ghost pointer wanders over the grid, opens a strip, picks a
 *   colour, then another character. It moves by the same two properties, so the eyes follow it.
 * - Light or dark: every colour of the stage, the panel and the tiles is a light-dark() token in
 *   the CSS, so the scheme is the page's color-scheme, or the one the colorScheme prop sets. The
 *   characters keep their colours; at night an avatar's disc deepens, and icons take the lighter
 *   tone of their colour.
 */

/** How the avatar is drawn: the character itself, or its line icon. */
export type AvatarKind = 'character' | 'icon';

/** An avatar: one of the objects, as a character or an icon, in one of the colours. */
export type Avatar = { kind: AvatarKind; id: CharacterId; colour: ColourId };

/** The colour strip: the tile it belongs to. Once opened it stays, closed, so it can leave. */
type Strip = { index: number; open: boolean };

/** The springy moves: the strip, the chip, the lifted tiles, the switch's thumb. */
const POP = spring(0.3, 420);

/** The avatar's flight, in ms. */
const FLIGHT = 640;

/** The landing: a squash, a stretch, and settled. */
const SQUASH: Keyframe[] = [
  { scale: '1 1' },
  { scale: '1.1 0.88', offset: 0.22 },
  { scale: '0.95 1.06', offset: 0.5 },
  { scale: '1.02 0.98', offset: 0.75 },
  { scale: '1 1' },
];

/** What the demo does, round after round: the tiles it passes, the strip it opens, the pick. */
const ROUNDS: {
  wander: CharacterId[];
  strip: CharacterId;
  colour: ColourId;
  next: CharacterId[];
  pick: CharacterId;
}[] = [
  { wander: ['star', 'envelope'], strip: 'gift', colour: 'green', next: ['padlock'], pick: 'bulb' },
  { wander: ['chart', 'book'], strip: 'folder', colour: 'pink', next: ['gear'], pick: 'flame' },
  { wander: ['ball', 'bolt'], strip: 'heart', colour: 'purple', next: ['bubble'], pick: 'robot' },
];

const DEFAULT_COLOURS = Object.fromEntries(
  CHARACTERS.map((character) => [character.id, character.colour]),
) as Record<CharacterId, ColourId>;

const indexOf = (id: CharacterId) => CHARACTERS.findIndex((character) => character.id === id);

/** "Folder, blue", or "Folder icon, blue". */
const describe = ({ kind, id, colour }: Avatar) =>
  `${CHARACTERS[indexOf(id)].name}${kind === 'icon' ? ' icon' : ''}, ${colourOf(colour).name}`;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Closes and opens a pair of eyes; one time in four, twice. */
function blink(eyes: Element) {
  const twice = Math.random() < 0.25;
  eyes.animate(
    twice
      ? [
          { scale: '1 1' },
          { scale: '1 0.1', offset: 0.2 },
          { scale: '1 1', offset: 0.42 },
          { scale: '1 0.1', offset: 0.62 },
          { scale: '1 1' },
        ]
      : [{ scale: '1 1' }, { scale: '1 0.1', offset: 0.45 }, { scale: '1 1' }],
    { duration: twice ? 420 : 190, easing: 'ease-in-out' },
  );
}

/** An avatar as drawn in the slot, in a tile or in the strip. */
function Drawing({ avatar, className }: { avatar: Avatar; className?: string }) {
  const { base, deep } = colourOf(avatar.colour);
  return avatar.kind === 'character' ? (
    <Character id={avatar.id} colour={avatar.colour} className={className} />
  ) : (
    // The CSS picks its tone for the scheme (.icon).
    <Icon
      id={avatar.id}
      className={`${styles.icon} ${className ?? ''}`}
      style={{ '--deep': deep, '--hue': base } as CSSProperties}
    />
  );
}

export function CharacterPicker({
  name = 'Moss',
  tagline = 'Your helper for notes, mail and plans',
  defaultAvatar = { kind: 'character', id: 'robot', colour: 'blue' },
  onChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The assistant's name, under its avatar. */
  name?: string;
  /** A short line under the name. */
  tagline?: string;
  /** The avatar at first: a character or an icon, and its colour. */
  defaultAvatar?: Avatar;
  /** Called with the new avatar when one is picked. */
  onChange?: (avatar: Avatar) => void;
  /** While on screen and untouched, a demo pointer wanders over the grid and picks avatars. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, awake, take } = useAutoplay<HTMLDivElement>(6000);
  // The shared gradients' ids: unique to this picker, and safe inside url(#…).
  const defs = `cp${useId().replace(/[^\w-]/g, '')}`;
  const stripId = useId();
  const [avatar, setAvatar] = useState(defaultAvatar);
  /** Counts the picks, to key the avatar's faces. */
  const [version, setVersion] = useState(0);
  /**
   * The avatar before the last pick. It stays in the slot, invisible: its exit animation plays as
   * it mounts and starts from visible, so nothing has to clear it. Hidden if it never landed.
   */
  const [previous, setPrevious] = useState<{
    avatar: Avatar;
    version: number;
    hidden: boolean;
  } | null>(null);
  /** Each object's colour: the one its tile shows, and the strip marks. */
  const [colours, setColours] = useState(() => ({
    ...DEFAULT_COLOURS,
    [defaultAvatar.id]: defaultAvatar.colour,
  }));
  const [view, setView] = useState<AvatarKind>(defaultAvatar.kind);
  /** The grid's content has been switched once: from then on it pops in. */
  const [switched, setSwitched] = useState(false);
  /** The tile under the pointer, the reader's or the demo's. */
  const [hovered, setHovered] = useState<number | null>(null);
  /** The tile with the focus, kept while the focus is on its chip or in its strip. */
  const [focused, setFocused] = useState<number | null>(null);
  /** The grid's one tab stop. */
  const [tabStop, setTabStop] = useState(() => Math.max(0, indexOf(defaultAvatar.id)));
  const [strip, setStrip] = useState<Strip | null>(null);
  /** The strip's one tab stop. */
  const [stripStop, setStripStop] = useState(0);
  /** The reader is on the keyboard in here: the demo waits until they leave. */
  const [keyboard, setKeyboard] = useState(false);
  const [message, setMessage] = useState('');
  /** A real choice owns the avatar until the enclosing preview is explicitly restarted. */
  const [used, setUsed] = useState(false);

  const badge = useRef<HTMLDivElement>(null);
  const face = useRef<HTMLSpanElement>(null);
  const segments = useRef<(HTMLButtonElement | null)[]>([]);
  const swatches = useRef<(HTMLButtonElement | null)[]>([]);
  const tiles = useRef<(HTMLButtonElement | null)[]>([]);
  const chip = useRef<HTMLButtonElement>(null);
  const variants = useRef<(HTMLButtonElement | null)[]>([]);
  const ghost = useRef<SVGSVGElement>(null);
  /** Where the pick's drawing was on screen, to fly from there once the slot has it. */
  const launch = useRef<DOMRect | null>(null);
  const flight = useRef<Animation | null>(null);
  /** The face in the slot has landed (or never flew). */
  const landed = useRef(true);
  /** The characters' centres are measured and current. */
  const measured = useRef(false);
  /** Where the focus goes once React has put a change on the page. */
  const focusNext = useRef<'chip' | 'strip' | number | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  /** What the demo left behind (a hovered tile, an open strip), to tidy when it stops. */
  const demoLeft = useRef({ hover: false, strip: false });

  const selected = avatar.kind === view ? indexOf(avatar.id) : -1;
  /** The tile the chip sits on: the open strip's, or the hovered or focused one. */
  const anchor = strip?.open ? strip.index : (hovered ?? focused);
  /** Where the chip rests when nothing is hovered: on the picked tile, for touch screens. */
  const chipAt = anchor ?? (selected >= 0 ? selected : 0);
  const chipFor = CHARACTERS[chipAt];
  const stripFor = strip ? CHARACTERS[strip.index] : null;

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

  /**
   * Every character's centre, in the stage's px, as --cx and --cy: its eyes look from there.
   * Offsets ignore transforms (a lifted tile, a scaled stage), so this is the layout's own place.
   */
  const measure = () => {
    const stage = root.current;
    if (!stage || measured.current) return;
    measured.current = true;
    stage.querySelectorAll<HTMLElement>('[data-looker]').forEach((element) => {
      let x = element.offsetWidth / 2;
      let y = element.offsetHeight / 2;
      for (let node: HTMLElement | null = element; node && node !== stage;) {
        x += node.offsetLeft;
        y += node.offsetTop;
        node = node.offsetParent as HTMLElement | null;
      }
      element.style.setProperty('--cx', `${x}px`);
      element.style.setProperty('--cy', `${y}px`);
    });
  };

  /** The eyes turn to the pointer: its place, in the stage's own px. */
  const look = (event: PointerEvent<HTMLDivElement>) => {
    const stage = event.currentTarget;
    measure();
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    stage.style.setProperty('--cp-px', `${(event.clientX - frame.left) / scale}px`);
    stage.style.setProperty('--cp-py', `${(event.clientY - frame.top) / scale}px`);
    stage.style.setProperty('--cp-look', '1');
  };

  /** Sets the avatar; the drawing in `source` flies into the slot. */
  const pick = (
    id: CharacterId,
    colour: ColourId,
    source: Element | null,
    byReader: boolean,
    kind: AvatarKind = view,
  ) => {
    const next: Avatar = { kind, id, colour };
    launch.current = source && !reducedMotion() ? source.getBoundingClientRect() : null;
    setPrevious({ avatar, version, hidden: !landed.current });
    landed.current = !launch.current;
    measured.current = false;
    setAvatar(next);
    setVersion(version + 1);
    setColours((now) => (now[id] === colour ? now : { ...now, [id]: colour }));
    setTabStop(indexOf(id));
    onChange?.(next);
    if (byReader) announce(`Avatar set to ${describe(next)}.`);
  };

  /** A tile's object, in its colour. */
  const choose = (index: number, byReader: boolean) => {
    const { id } = CHARACTERS[index];
    pick(id, colours[id], tiles.current[index]?.querySelector('svg') ?? null, byReader);
  };

  const openStrip = (index: number, focus: boolean) => {
    measured.current = false;
    setStrip({ index, open: true });
    setStripStop(
      Math.max(
        0,
        COLOURS.findIndex((c) => c.id === colours[CHARACTERS[index].id]),
      ),
    );
    if (focus) focusNext.current = 'strip';
  };

  const closeStrip = (focus: 'chip' | number | null) => {
    setStrip((now) => (now?.open ? { ...now, open: false } : now));
    focusNext.current = focus;
  };

  /** A colour from the strip: its object in that colour, flying from the strip. */
  const chooseColour = (k: number, byReader: boolean) => {
    if (!strip) return;
    const { id } = CHARACTERS[strip.index];
    pick(id, COLOURS[k].id, variants.current[k]?.querySelector('svg') ?? null, byReader);
    closeStrip(byReader ? strip.index : null);
  };

  const switchView = (next: AvatarKind) => {
    if (next === view) return;
    setView(next);
    setSwitched(true);
    closeStrip(null);
  };

  // The flight: the new face is in the slot; it plays from where the picked drawing was.
  useLayoutEffect(() => {
    const from = launch.current;
    launch.current = null;
    const stage = root.current;
    const slot = badge.current;
    const element = face.current;
    if (!from || !stage || !slot || !element) return;
    const to = element.querySelector('svg')?.getBoundingClientRect();
    if (!to?.width || !from.width) return;
    const scale = stage.getBoundingClientRect().width / stage.offsetWidth || 1;
    const dx = (from.left + from.width / 2 - (to.left + to.width / 2)) / scale;
    const dy = (from.top + from.height / 2 - (to.top + to.height / 2)) / scale;
    const size = from.width / to.width;
    // A toss: up from the tile first, then over, dropping into the slot from a little above.
    const bend = { x: dx * 0.9, y: Math.min(dy, 0) - 64 };
    const lean = dx > 0 ? -1 : 1;
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 24; i += 1) {
      const t = i / 24;
      const u = 1 - t;
      const x = u * u * dx + 2 * u * t * bend.x;
      const y = u * u * dy + 2 * u * t * bend.y;
      const grow = (size + (1 - size) * t) * (1 + 0.16 * Math.sin(Math.PI * t));
      const tilt = lean * 14 * Math.sin(Math.PI * t);
      keyframes.push({ transform: `translate(${x}px, ${y}px) rotate(${tilt}deg) scale(${grow})` });
    }
    flight.current?.cancel();
    const animation = element.animate(keyframes, {
      duration: FLIGHT,
      easing: 'cubic-bezier(0.3, 0, 0.25, 1)',
    });
    flight.current = animation;
    animation.finished.then(
      () => {
        landed.current = true;
        slot.animate(SQUASH, { duration: 560, easing: 'ease-out' });
        const eyes = element.querySelector('[data-eyes]');
        if (eyes) blink(eyes);
      },
      () => {},
    );
  }, [version, root]);

  // Focus follows the reader's action, once the change is on the page.
  useLayoutEffect(() => {
    const target = focusNext.current;
    focusNext.current = null;
    if (target === null) return;
    const element =
      target === 'chip'
        ? chip.current
        : target === 'strip'
          ? variants.current[stripStop]
          : tiles.current[target];
    element?.focus({ preventScroll: true });
  });

  // The centres move when the stage changes size.
  useEffect(() => {
    const stage = root.current;
    if (!stage) return;
    const observer = new ResizeObserver(() => {
      measured.current = false;
    });
    observer.observe(stage);
    return () => observer.disconnect();
  }, [root]);

  // Blinks: one or two pairs of eyes every second or two, while on screen in a visible tab.
  useEffect(() => {
    const stage = root.current;
    if (!stage) return;
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    let onScreen = false;
    let timer = 0;
    const schedule = () => {
      window.clearTimeout(timer);
      if (onScreen && !document.hidden && !motion.matches) {
        timer = window.setTimeout(tick, 700 + Math.random() * 1600);
      }
    };
    const tick = () => {
      const awake = [...stage.querySelectorAll('[data-looker] [data-eyes]')].filter(
        (eyes) => !eyes.closest('[inert]'),
      );
      const count = Math.random() < 0.3 ? 2 : 1;
      for (let i = 0; i < count && awake.length; i += 1) {
        blink(awake.splice(Math.floor(Math.random() * awake.length), 1)[0]);
      }
      schedule();
    };
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      schedule();
    });
    observer.observe(stage);
    document.addEventListener('visibilitychange', schedule);
    motion.addEventListener('change', schedule);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', schedule);
      motion.removeEventListener('change', schedule);
      window.clearTimeout(timer);
    };
  }, [root]);

  // Nothing keeps flying once the picker is gone.
  useEffect(() => () => flight.current?.cancel(), []);

  // The demo calls the handlers of the latest render.
  const actions = useRef({
    choose,
    chooseColour,
    openStrip,
    closeStrip,
    switchView,
    colours,
    view,
  });
  useLayoutEffect(() => {
    actions.current = { choose, chooseColour, openStrip, closeStrip, switchView, colours, view };
  });

  // The demo: the pointer wanders over the grid, opens a tile's colours, picks one, glances up at
  // the new avatar, then picks another character; again while on screen and untouched.
  const demo = autoplay && playing && !keyboard && !used;
  useEffect(() => {
    const stage = root.current;
    const arrow = ghost.current;
    if (!demo || !stage || !arrow) return;
    const left = demoLeft.current;
    const hover = (index: number | null) => {
      left.hover = index !== null;
      setHovered(index);
    };
    const stop = runDemo(stage, arrow, measure, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      const visit = async (id: CharacterId, ms: number) => {
        await moveTo(tiles.current[indexOf(id)], { x: 0.55, y: 0.6 });
        hover(indexOf(id));
        await wait(ms);
      };
      await wait(700);
      for (let round = 0; ; round += 1) {
        const plan = ROUNDS[round % ROUNDS.length];
        if (act().view !== 'character') {
          await moveTo(segments.current[0]);
          await press(segments.current[0], () => act().switchView('character'));
          await wait(500);
        }
        for (const id of plan.wander) await visit(id, 380);
        await visit(plan.strip, 460);
        await moveTo(chip.current);
        await press(chip.current, () => {
          left.strip = true;
          act().openStrip(indexOf(plan.strip), false);
        });
        await wait(560);
        // Along the colours, to one the object is not in yet.
        let k = COLOURS.findIndex((colour) => colour.id === plan.colour);
        if (act().colours[plan.strip] === plan.colour) k = (k + 2) % COLOURS.length;
        const passing = variants.current[k < 4 ? k + 2 : k - 2];
        await moveTo(passing);
        passing?.setAttribute('data-hover', '');
        await wait(260);
        passing?.removeAttribute('data-hover');
        const target = variants.current[k];
        await moveTo(target);
        target?.setAttribute('data-hover', '');
        await wait(320);
        target?.removeAttribute('data-hover');
        await press(target, () => {
          left.strip = false;
          act().chooseColour(k, false);
        });
        hover(null);
        await wait(900);
        // A glance up at the new avatar: every eye in the grid follows.
        await moveTo(badge.current, { x: 0.5, y: 1.3 });
        await wait(1100);
        for (const id of plan.next) await visit(id, 380);
        await visit(plan.pick, 420);
        await press(tiles.current[indexOf(plan.pick)], () =>
          act().choose(indexOf(plan.pick), false),
        );
        await wait(700);
        hover(null);
        await wait(1500);
      }
    });
    const end = () => {
      stop();
      if (left.hover) setHovered(null);
      if (left.strip) actions.current.closeStrip(null);
      left.hover = false;
      left.strip = false;
    };
    stopDemo.current = end;
    return () => {
      end();
      stopDemo.current = null;
    };
    // `measure` reads refs only; the demo restarts when it may play or not.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

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

  const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const columns = Number(getComputedStyle(event.currentTarget).getPropertyValue('--cols')) || 5;
    const last = CHARACTERS.length - 1;
    const moves: Record<string, number> = {
      ArrowRight: tabStop + 1,
      ArrowLeft: tabStop - 1,
      ArrowDown: tabStop + columns,
      ArrowUp: tabStop - columns,
      Home: 0,
      End: last,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    const to = moves[event.key];
    if (to >= 0 && to <= last) tiles.current[to]?.focus();
  };

  const onStripKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = COLOURS.length - 1;
    const moves: Record<string, number> = {
      ArrowRight: Math.min(last, stripStop + 1),
      ArrowLeft: Math.max(0, stripStop - 1),
      Home: 0,
      End: last,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    variants.current[moves[event.key]]?.focus();
  };

  const onSegmentKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
    event.preventDefault();
    const next = view === 'character' ? 'icon' : 'character';
    switchView(next);
    segments.current[next === 'character' ? 0 : 1]?.focus();
  };

  /** A visible palette also makes changing the selected avatar easy on a touch screen. */
  const recolour = (index: number) => {
    const colour = COLOURS[index];
    if (colour.id !== avatar.colour) pick(avatar.id, colour.id, null, true, avatar.kind);
  };

  const onColourKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const index = COLOURS.findIndex((colour) => colour.id === avatar.colour);
    const last = COLOURS.length - 1;
    const moves: Record<string, number> = {
      ArrowRight: (index + 1) % COLOURS.length,
      ArrowDown: (index + 1) % COLOURS.length,
      ArrowLeft: (index + last) % COLOURS.length,
      ArrowUp: (index + last) % COLOURS.length,
      Home: 0,
      End: last,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    const next = moves[event.key];
    recolour(next);
    swatches.current[next]?.focus();
  };

  /** Hover, for a mouse or a pen: a finger has none. */
  const onWrapPointerOver = (event: PointerEvent<HTMLDivElement>) => {
    if (event.pointerType === 'touch') return;
    const tile = (event.target as Element).closest<HTMLElement>('[data-index]');
    if (tile) setHovered(Number(tile.dataset.index));
  };

  const onWrapBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (event.currentTarget.contains(event.relatedTarget)) return;
    setFocused(null);
    if (strip?.open) closeStrip(null);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      style={{ '--pop': POP.easing, '--pop-ms': `${POP.duration}ms` } as CSSProperties}
      data-color-scheme={colorScheme}
      data-awake={awake || undefined}
      onPointerDownCapture={(event) => {
        readerActs();
        setUsed(true);
        // A press outside the strip closes it.
        const target = event.target as Element;
        if (strip?.open && !target.closest('[data-strip], [data-chip]')) closeStrip(null);
      }}
      onKeyDownCapture={() => {
        readerActs();
        setUsed(true);
      }}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') readerActs();
        look(event);
      }}
      onPointerLeave={(event) => event.currentTarget.style.setProperty('--cp-look', '0')}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && strip?.open) {
          event.preventDefault();
          // From inside the strip, the focus goes back to the chip that opened it.
          closeStrip((event.target as Element).closest('[data-strip]') ? 'chip' : null);
        }
      }}
      onFocus={(event) => {
        if ((event.target as Element).matches(':focus-visible')) setKeyboard(true);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
      }}
    >
      <DefsId value={defs}>
        <CharacterDefs id={defs} />
        <div className={styles.scene}>
          <div className={styles.profile}>
            <div
              ref={badge}
              role="img"
              aria-label={`${name}’s avatar: ${describe(avatar)}`}
              className={styles.badge}
              style={
                {
                  '--tint': colourOf(avatar.colour).soft,
                  '--hue': colourOf(avatar.colour).base,
                } as CSSProperties
              }
            >
              {previous && (
                <span
                  key={`out-${previous.version}`}
                  className={styles.faceOut}
                  data-kind={previous.avatar.kind}
                  data-hidden={previous.hidden || undefined}
                >
                  <Drawing avatar={previous.avatar} />
                </span>
              )}
              <span
                key={version}
                ref={face}
                className={styles.face}
                data-kind={avatar.kind}
                data-looker=""
              >
                <Drawing avatar={avatar} />
              </span>
            </div>
            <p className={styles.name}>{name}</p>
            <p className={styles.tagline}>{tagline}</p>
          </div>

          <div className={styles.panel}>
            <div
              role="radiogroup"
              aria-label="Avatar style"
              className={styles.segmented}
              data-view={view}
              onKeyDown={onSegmentKeyDown}
            >
              <span className={styles.thumb} aria-hidden="true" />
              {(['character', 'icon'] as const).map((kind, i) => (
                <button
                  key={kind}
                  ref={(element) => {
                    segments.current[i] = element;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={view === kind}
                  tabIndex={view === kind ? 0 : -1}
                  className={styles.segment}
                  onClick={() => switchView(kind)}
                >
                  {kind === 'character' ? '3D characters' : 'Icons'}
                </button>
              ))}
            </div>

            <div
              className={styles.wrap}
              onPointerOver={onWrapPointerOver}
              onPointerLeave={(event) => {
                if (event.pointerType !== 'touch') setHovered(null);
              }}
              onBlur={onWrapBlur}
            >
              <div
                role="listbox"
                aria-label={view === 'character' ? 'Characters' : 'Icons'}
                className={styles.grid}
                data-switched={switched || undefined}
                onKeyDown={onGridKeyDown}
              >
                {CHARACTERS.map((character, i) => {
                  const option: Avatar = {
                    kind: view,
                    id: character.id,
                    colour: colours[character.id],
                  };
                  return (
                    <button
                      key={character.id}
                      ref={(element) => {
                        tiles.current[i] = element;
                      }}
                      type="button"
                      role="option"
                      aria-selected={selected === i}
                      aria-label={`${character.name}, ${colourOf(option.colour).name}`}
                      tabIndex={tabStop === i ? 0 : -1}
                      className={styles.tile}
                      style={{ '--i': i } as CSSProperties}
                      data-index={i}
                      data-hover={anchor === i || undefined}
                      data-looker=""
                      onFocus={() => {
                        setTabStop(i);
                        setFocused(i);
                      }}
                      onClick={() => choose(i, true)}
                    >
                      <Drawing
                        key={view}
                        avatar={option}
                        className={view === 'character' ? styles.art : styles.glyph}
                      />
                    </button>
                  );
                })}
              </div>

              <button
                ref={chip}
                type="button"
                className={styles.chip}
                style={{ '--i': chipAt } as CSSProperties}
                aria-label={`Colours for ${chipFor.name}`}
                aria-haspopup="listbox"
                aria-expanded={strip?.open === true && strip.index === chipAt}
                aria-controls={strip ? stripId : undefined}
                data-chip=""
                data-shown={anchor !== null || undefined}
                data-rest={(anchor === null && selected >= 0) || undefined}
                onClick={(event) => {
                  if (strip?.open && strip.index === chipAt) closeStrip('chip');
                  // A click from the keyboard (Enter or Space) has no pointer: focus goes in.
                  else openStrip(chipAt, event.detail === 0);
                }}
              >
                <span className={styles.wheel} />
              </button>

              {strip && stripFor && (
                <div
                  id={stripId}
                  role="listbox"
                  aria-label={`Colours for ${stripFor.name}`}
                  aria-orientation="horizontal"
                  className={styles.strip}
                  style={{ '--i': strip.index } as CSSProperties}
                  data-open={strip.open || undefined}
                  data-strip=""
                  inert={!strip.open}
                  onKeyDown={onStripKeyDown}
                >
                  {COLOURS.map((colour, k) => {
                    const option: Avatar = { kind: view, id: stripFor.id, colour: colour.id };
                    return (
                      <button
                        key={colour.id}
                        ref={(element) => {
                          variants.current[k] = element;
                        }}
                        type="button"
                        role="option"
                        aria-selected={colours[stripFor.id] === colour.id}
                        aria-label={describe(option)}
                        tabIndex={stripStop === k ? 0 : -1}
                        className={styles.variant}
                        style={
                          { '--k': k, '--tint': colour.soft, '--hue': colour.base } as CSSProperties
                        }
                        data-kind={view}
                        data-looker=""
                        onFocus={() => setStripStop(k)}
                        onClick={() => chooseColour(k, true)}
                      >
                        <Drawing avatar={option} />
                      </button>
                    );
                  })}
                </div>
              )}
            </div>
            <div className={styles.colourControls}>
              <span className={styles.colourLabel}>Colour</span>
              <div
                role="radiogroup"
                aria-label={`Colour for ${CHARACTERS[indexOf(avatar.id)].name}`}
                className={styles.swatches}
                onKeyDown={onColourKeyDown}
              >
                {COLOURS.map((colour, index) => (
                  <button
                    key={colour.id}
                    ref={(element) => {
                      swatches.current[index] = element;
                    }}
                    type="button"
                    role="radio"
                    aria-label={colour.name}
                    aria-checked={avatar.colour === colour.id}
                    tabIndex={avatar.colour === colour.id ? 0 : -1}
                    className={styles.swatch}
                    style={{ '--swatch': colour.base } as CSSProperties}
                    onClick={() => recolour(index)}
                  >
                    <span aria-hidden="true" />
                  </button>
                ))}
              </div>
            </div>
          </div>
        </div>
      </DefsId>
      <GhostPointer ref={ghost} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### character-picker/character-picker.module.css

```css
/* Character picker (character-picker.tsx): a stage with the assistant's avatar on top and the
   picker under it. The pointer's place, in the stage's px, is in --cp-px and --cp-py, and every
   pair of eyes turns towards it. They are registered (prefixed, as @property names are global) so
   that the demo's pointer can glide them, and --cp-look so that the eyes ease in and out.

   Colours are the tokens below, light, then light-dark(light, dark) pairs: 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 pairs sit in @supports, which a build that
   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light
   colours stay. The characters keep their own colours in both. */

@property --cp-px {
  syntax: '<length>';
  inherits: true;
  initial-value: 0px;
}
@property --cp-py {
  syntax: '<length>';
  inherits: true;
  initial-value: 0px;
}
@property --cp-look {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

.root {
  /* The dotted stage and its edge; the panel on it, and a picked tile (as the panel, ringed). */
  --stage: #e8e8eb;
  --dots: rgb(0 0 0 / 0.05);
  --stage-edge: rgb(0 0 0 / 0.06);
  --surface: #fff;
  /* What is raised: the switch's thumb, a lifted tile, the chip and the strip. White by day; at
     night a step lighter than the tiles, as a lift reads in the dark. */
  --surface-2: #fff;
  /* The switch's track and the tiles; a colour in the strip under the pointer. */
  --well: #f2f2f5;
  --hover: #f2f2f5;
  /* The name and a chosen segment; the tagline and the other segment. */
  --ink: #1d1d1f;
  --ink-muted: #6e6e73;
  /* The ring round a picked tile and a picked colour. */
  --accent: #1d1d1f;
  --focus: #0a84ff;
  /* The hairline round what is raised; the panel's lift, near and far. */
  --line: rgb(0 0 0 / 0.06);
  --shadow-near: rgb(0 0 0 / 0.05);
  --shadow-far: rgb(0 0 0 / 0.22);
  --shadow:
    0 0 0 0.5px var(--line), 0 1px 2px var(--shadow-near), 0 12px 30px -14px var(--shadow-far);
  /* The avatar's edge and lift, near and far. */
  --badge-edge: rgb(0 0 0 / 0.04);
  --badge-near: rgb(0 0 0 / 0.06);
  --badge-far: rgb(0 0 0 / 0.32);
  /* The switch's thumb: its edge and lift, near and far; a lifted tile's lift. */
  --thumb-edge: rgb(0 0 0 / 0.05);
  --thumb-near: rgb(0 0 0 / 0.08);
  --thumb-far: rgb(0 0 0 / 0.14);
  --lift: rgb(0 0 0 / 0.3);
  /* The chip's edge and shade; the strip's edge and lift, near and far; the colour wheel's edge. */
  --chip-edge: rgb(0 0 0 / 0.1);
  --chip-shade: rgb(0 0 0 / 0.18);
  --strip-edge: rgb(0 0 0 / 0.08);
  --strip-near: rgb(0 0 0 / 0.06);
  --strip-far: rgb(0 0 0 / 0.3);
  --wheel-edge: rgb(0 0 0 / 0.08);
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --cp-px: 0px;
  --cp-py: 0px;
  --cp-look: 0;
  --move: 0ms;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
  transition: --cp-look 0.35s ease;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#e8e8eb, #0f0f11);
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface: light-dark(#fff, #1c1c1f);
    --surface-2: light-dark(#fff, #34343a);
    --well: light-dark(#f2f2f5, #29292d);
    --hover: light-dark(#f2f2f5, #46464d);
    --ink: light-dark(#1d1d1f, #ececef);
    --ink-muted: light-dark(#6e6e73, #a1a1a8);
    --accent: light-dark(#1d1d1f, #ececef);
    --focus: light-dark(#0a84ff, #3594ff);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow-far: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.6));
    --badge-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07));
    --badge-near: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.3));
    --badge-far: light-dark(rgb(0 0 0 / 0.32), rgb(0 0 0 / 0.7));
    --thumb-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --thumb-near: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.3));
    --thumb-far: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.45));
    --lift: light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.65));
    --chip-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.12));
    --chip-shade: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.5));
    --strip-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --strip-near: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.3));
    --strip-far: light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.65));
    --wheel-edge: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.3));
  }
  /* At night a colour's backdrop (the avatar's disc, an icon's disc in the strip) is a quarter of
     the colour in the panel's grey, and an icon takes its colour's base tone, which reads on the
     dark tiles. By day, the soft tint and the deep tone. */
  .badge,
  .variant[data-kind='icon'] {
    --backdrop: light-dark(var(--tint), color-mix(in srgb, var(--hue) 24%, #1c1c1f));
  }
  .icon {
    --tone: light-dark(var(--deep), var(--hue));
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.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 demo's glides: one easing per axis bends the path. */
.root[data-ghost] {
  transition:
    --cp-px var(--move) cubic-bezier(0.45, 0, 0.25, 1),
    --cp-py var(--move) cubic-bezier(0.3, 0.55, 0.25, 1),
    --cp-look 0.35s ease;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

/* The grid's measures, for the tiles and for the chip and the strip, placed by index. */
.scene {
  --tile: 64px;
  --gap: 8px;
  --cols: 5;
  --item: 40px;
  --strip-gap: 2px;
  --strip-pad: 5px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 22px;
  padding: 30px 16px;
}

/* The avatar, the assistant's name and its line. */
.profile {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
.badge {
  position: relative;
  display: grid;
  place-items: center;
  width: 124px;
  height: 124px;
  border-radius: 50%;
  background-color: var(--backdrop, var(--tint));
  box-shadow:
    inset 0 0 0 1px var(--badge-edge),
    0 1px 2px var(--badge-near),
    0 14px 28px -16px var(--badge-far);
  transform-origin: 50% 100%;
  transition: background-color 0.5s ease;
}
.face,
.faceOut {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  width: 106px;
  height: 106px;
}
.face svg,
.faceOut svg {
  width: 100%;
  height: 100%;
}
.face[data-kind='icon'] svg,
.faceOut[data-kind='icon'] svg {
  width: 50px;
  height: 50px;
}
.faceOut {
  opacity: 0;
  animation: leave 0.28s ease-in backwards;
}
.faceOut[data-hidden] {
  visibility: hidden;
}
.name {
  margin: 14px 0 0;
  font-size: 20px;
  font-weight: 650;
  line-height: 1.2;
  letter-spacing: -0.015em;
}
.tagline {
  margin: 3px 0 0;
  font-size: 13.5px;
  line-height: 1.35;
  color: var(--ink-muted);
}

/* The picker: the switch, then the grid with its chip and its strip. */
.panel {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 12px;
  border-radius: 26px;
  background: var(--surface);
  box-shadow: var(--shadow);
}

.segmented {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  padding: 3px;
  border-radius: 999px;
  background: var(--well);
}
.thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--thumb-edge),
    0 1px 2px var(--thumb-near),
    0 3px 8px -3px var(--thumb-far);
  transition: translate var(--pop-ms) var(--pop);
}
.segmented[data-view='icon'] .thumb {
  translate: 100% 0;
}
.segment {
  position: relative;
  height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: 999px;
  background: none;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-muted);
  cursor: pointer;
  transition: color 0.2s ease;
}
.segment[aria-checked='true'] {
  color: var(--ink);
}
.segment:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

.wrap {
  position: relative;
}
.grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), var(--tile));
  gap: var(--gap);
}
.tile {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--tile);
  height: var(--tile);
  padding: 0;
  border: 0;
  border-radius: 18px;
  background: var(--well);
  cursor: pointer;
  transition:
    translate var(--pop-ms) var(--pop),
    scale 0.2s ease,
    background-color 0.2s ease;
}
/* The lift's shadow comes at once, with no transition: a shadow that grew would change the size of
   the lifted tile's layer on every frame, and a phone would draw the layer again each time. */
.tile[data-hover],
.tile:focus-visible {
  translate: 0 -3px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--line),
    0 8px 18px -8px var(--lift);
}
.tile[aria-selected='true'] {
  background: var(--surface);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.tile[aria-selected='true'][data-hover],
.tile[aria-selected='true']:focus-visible {
  box-shadow:
    inset 0 0 0 2px var(--accent),
    0 8px 18px -8px var(--lift);
}
.tile:active,
.tile[data-pressed] {
  scale: 0.93;
}
.tile:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.art {
  width: 96%;
  height: 96%;
}
.glyph {
  width: 42%;
  height: 42%;
}
/* A line icon, in its colour's deep tone; at night its base tone (--tone, with the tokens). */
.icon {
  color: var(--tone, var(--deep));
}
/* After a switch, the new drawings pop in, one after another. */
.grid[data-switched] .art,
.grid[data-switched] .glyph {
  animation: pop 0.42s var(--pop) backwards;
  animation-delay: calc(var(--i) * 12ms);
}

/* The chip: on the corner of its tile (column and row from the tile's index). */
.chip {
  --x: calc(mod(var(--i), var(--cols)) * (var(--tile) + var(--gap)) + var(--tile));
  --y: calc(round(down, var(--i) / var(--cols), 1) * (var(--tile) + var(--gap)));
  position: absolute;
  z-index: 2;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--chip-edge),
    0 2px 6px var(--chip-shade);
  cursor: pointer;
  translate: calc(var(--x) - 20px) calc(var(--y) - 10px);
  transition:
    translate var(--pop-ms) var(--pop),
    scale var(--pop-ms) var(--pop),
    opacity 0.18s ease;
}
/* A bigger target for a finger. */
.chip::before {
  content: '';
  position: absolute;
  inset: -8px;
}
.chip:not([data-shown]) {
  visibility: hidden;
  opacity: 0;
  scale: 0.4;
  transition:
    translate var(--pop-ms) var(--pop),
    scale 0.2s ease,
    opacity 0.18s ease,
    visibility 0s 0.2s;
}
/* Without hover (touch screens), it rests on the picked tile. */
@media (hover: none) {
  .chip[data-rest] {
    visibility: visible;
    opacity: 1;
    scale: 1;
  }
}
.chip:is(:active, [data-pressed]) {
  scale: 0.86;
}
.chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.wheel {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: conic-gradient(
    #ff6a61,
    #ff9a3c,
    #ffca38,
    #55cb7c,
    #4c9bff,
    #9a7bff,
    #ff7cb5,
    #ff6a61
  );
  box-shadow: inset 0 0 0 1px var(--wheel-edge);
}

/* The strip: the object in every colour, over its tile. It grows from the tile's middle, and
   the colours fan out from there, one after another. */
.strip {
  --w: calc(7 * var(--item) + 6 * var(--strip-gap) + 2 * var(--strip-pad));
  --h: calc(var(--item) + 2 * var(--strip-pad));
  --span: calc(var(--cols) * var(--tile) + (var(--cols) - 1) * var(--gap));
  --x: calc(mod(var(--i), var(--cols)) * (var(--tile) + var(--gap)) + var(--tile) / 2);
  --y: calc(round(down, var(--i) / var(--cols), 1) * (var(--tile) + var(--gap)));
  --left: clamp(-8px, calc(var(--x) - var(--w) / 2), calc(var(--span) - var(--w) + 8px));
  position: absolute;
  z-index: 3;
  top: calc(var(--y) - var(--h) + 2px);
  left: var(--left);
  display: flex;
  gap: var(--strip-gap);
  box-sizing: border-box;
  width: var(--w);
  height: var(--h);
  padding: var(--strip-pad);
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--strip-edge),
    0 2px 4px var(--strip-near),
    0 14px 30px -10px var(--strip-far);
  transform-origin: calc(var(--x) - var(--left)) calc(100% - 2px + var(--tile) / 2);
  transition:
    opacity 0.16s ease,
    scale 0.22s ease-in,
    visibility 0s 0.22s;
}
.strip:not([data-open]) {
  visibility: hidden;
  opacity: 0;
  scale: 0.3;
}
.strip[data-open] {
  transition:
    opacity 0.18s ease,
    scale var(--pop-ms) var(--pop);
}
.variant {
  --from: calc(
    var(--x) - var(--left) - var(--strip-pad) - var(--k) * (var(--item) + var(--strip-gap)) -
      var(--item) / 2
  );
  display: grid;
  flex: none;
  place-items: center;
  width: var(--item);
  height: var(--item);
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  transition:
    translate 0.2s ease-in,
    opacity 0.14s ease,
    background-color 0.15s ease;
}
.strip:not([data-open]) .variant {
  translate: var(--from) 0;
  opacity: 0;
}
.strip[data-open] .variant {
  transition:
    translate var(--pop-ms) var(--pop) calc(var(--k) * 16ms),
    opacity 0.2s ease calc(var(--k) * 16ms),
    background-color 0.15s ease;
}
.variant:hover,
.variant[data-hover] {
  background: var(--hover);
}
.variant[aria-selected='true'] {
  box-shadow: inset 0 0 0 2px var(--accent);
}
.variant:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.variant svg {
  width: 84%;
  height: 84%;
}
/* An icon in the strip sits on a disc of its colour's tint. */
.variant[data-kind='icon'] {
  background: var(--backdrop, var(--tint));
}
.variant[data-kind='icon'] svg {
  width: 52%;
  height: 52%;
}
.variant[data-kind='icon']:is(:hover, [data-hover]) {
  scale: 1.08;
}

/* The selected avatar's palette is visible without a hover, including on touch screens. */
.colourControls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 10px 4px 1px;
  border-top: 1px solid var(--line);
}
.colourLabel {
  font-size: 12px;
  font-weight: 500;
  color: var(--ink-muted);
}
.swatches {
  display: flex;
  align-items: center;
  gap: 3px;
}
.swatch {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  transition:
    box-shadow 0.2s ease,
    scale 0.2s ease;
}
.swatch span {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--swatch);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.08);
}
.swatch[aria-checked='true'] {
  box-shadow: inset 0 0 0 1.5px var(--ink);
}
.swatch:hover {
  scale: 1.1;
}
.swatch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The demo's pointer, at (--cp-px, --cp-py): the arrow's tip is at (3, 2) of its drawing. */
.ghost {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  width: 22px;
  height: 22px;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  translate: calc(var(--cp-px) - 3.3px) calc(var(--cp-py) - 2.2px);
  filter: drop-shadow(0 1px 1.5px var(--pointer-shadow));
  transition: opacity 0.3s ease;
}
.root[data-ghost] .ghost {
  opacity: 1;
}
.ghost path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
  transform-box: fill-box;
  transform-origin: 0 0;
}

/* 3D cutouts and live faces (characters.tsx), with the same materials in both themes. */
.character {
  --dx: calc(var(--cp-px) - var(--cx, 0px));
  --dy: calc(var(--cp-py) - var(--cy, 0px));
  --turn-x: calc(clamp(-1, var(--dy) / 140px, 1) * var(--cp-look));
  --turn-y: calc(clamp(-1, var(--dx) / 140px, 1) * var(--cp-look));
  overflow: visible;
  transform: perspective(240px) rotateX(calc(var(--turn-x) * -7deg))
    rotateY(calc(var(--turn-y) * 9deg));
  transform-origin: 50% 65%;
}
.bodyRender {
  filter: var(--cp-material);
}
.face[data-kind='character'] .character {
  animation: breathe 4.2s ease-in-out infinite;
}
.root:not([data-awake]) .character {
  animation-play-state: paused;
}
.eyeLids {
  transform-box: fill-box;
  transform-origin: center;
}
.pupils {
  --reach: calc(min(1.55px, hypot(var(--dx), var(--dy)) * 0.035) * var(--cp-look));
  translate: calc(cos(atan2(var(--dy), var(--dx))) * var(--reach))
    calc(sin(atan2(var(--dy), var(--dx))) * var(--reach));
}
.smile {
  transform-box: fill-box;
  transform-origin: center;
  transition: scale 0.22s ease;
}
.tile:is([data-hover], :focus-visible) .smile,
.face .smile {
  scale: 1.12 1.15;
}
@keyframes breathe {
  0%,
  100% {
    translate: 0 0;
  }
  50% {
    translate: 0 -3px;
  }
}
.defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}
/* For screen readers only: the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes leave {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
    scale: 0.6;
  }
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.5;
  }
}

/* A phone: four columns, the tiles and the strip's colours sized to the room (100cqi is the
   stage's width; 76px is everything around the four tiles). */
@container (max-width: 459px) {
  .scene {
    --cols: 4;
    --tile: clamp(46px, calc((100cqi - 76px) / 4), 60px);
    --item: clamp(28px, calc((100cqi - 76px) / 7.6), 34px);
    --strip-pad: 4px;
    gap: 18px;
    padding: 24px 14px 26px;
  }
}
@container (max-width: 339px) {
  .colourControls {
    gap: 8px;
  }
  .swatches {
    gap: 2px;
  }
  .swatch {
    width: 24px;
  }
  .swatch span {
    width: 16px;
    height: 16px;
  }
  .badge {
    width: 96px;
    height: 96px;
  }
  .face,
  .faceOut {
    width: 76px;
    height: 76px;
  }
}

/* Final states at once, eyes still. */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition: none !important;
  }
  .pupils {
    translate: none;
  }
  .character {
    transform: none;
  }
}
```

### character-picker/characters.tsx

```tsx
'use client';

import { createContext, useContext, type CSSProperties, type ReactNode } from 'react';
import { materialFilterOf, type ColourId } from './palette';
import { CHARACTER_RENDERS } from './render-assets';
import { gear, polygon, rect, star } from './shapes';
import styles from './character-picker.module.css';

/*
 * Twenty 3D object cutouts, generated with a blank face and a consistent mint material.
 * A calibrated CSS material filter supplies seven colours. Native SVG eyes, cheeks and smiles
 * stay above the render, so pupils track the pointer and blinks work in every colour.
 * The separate line-icon view keeps the same objects and public avatar ids.
 */

export type CharacterId =
  | 'robot'
  | 'gift'
  | 'heart'
  | 'star'
  | 'bubble'
  | 'calendar'
  | 'bulb'
  | 'envelope'
  | 'gear'
  | 'folder'
  | 'book'
  | 'chart'
  | 'pin'
  | 'padlock'
  | 'flame'
  | 'browser'
  | 'briefcase'
  | 'clipboard'
  | 'bolt'
  | 'ball';

export type CharacterInfo = {
  id: CharacterId;
  /** As read out: "Folder, blue". */
  name: string;
  /** The colour it comes in at first. */
  colour: ColourId;
};

/** In the grid's order: neighbours, across and down, never share a colour. */
export const CHARACTERS: readonly CharacterInfo[] = [
  { id: 'robot', name: 'Robot', colour: 'blue' },
  { id: 'gift', name: 'Gift', colour: 'purple' },
  { id: 'heart', name: 'Heart', colour: 'red' },
  { id: 'star', name: 'Star', colour: 'yellow' },
  { id: 'bubble', name: 'Speech bubble', colour: 'green' },
  { id: 'calendar', name: 'Calendar', colour: 'red' },
  { id: 'bulb', name: 'Light bulb', colour: 'yellow' },
  { id: 'envelope', name: 'Envelope', colour: 'pink' },
  { id: 'gear', name: 'Gear', colour: 'orange' },
  { id: 'folder', name: 'Folder', colour: 'blue' },
  { id: 'book', name: 'Book', colour: 'purple' },
  { id: 'chart', name: 'Bar chart', colour: 'green' },
  { id: 'pin', name: 'Map pin', colour: 'red' },
  { id: 'padlock', name: 'Padlock', colour: 'yellow' },
  { id: 'flame', name: 'Flame', colour: 'orange' },
  { id: 'browser', name: 'Browser window', colour: 'blue' },
  { id: 'briefcase', name: 'Briefcase', colour: 'orange' },
  { id: 'clipboard', name: 'Clipboard', colour: 'green' },
  { id: 'bolt', name: 'Lightning bolt', colour: 'purple' },
  { id: 'ball', name: 'Ball', colour: 'pink' },
];

/** Shared shading for live eyeballs, unique to each picker. */
export const DefsId = createContext('cp');

export function CharacterDefs({ id }: { id: string }) {
  return (
    <svg className={styles.defs} aria-hidden="true" focusable="false">
      <defs>
        <radialGradient id={`${id}-eye`} cx="0.34" cy="0.22" r="0.8">
          <stop offset="0" stopColor="#ffffff" />
          <stop offset="0.55" stopColor="#ffffff" />
          <stop offset="1" stopColor="#bcc6d0" />
        </radialGradient>
        <radialGradient id={`${id}-pupil`} cx="0.3" cy="0.2" r="0.9">
          <stop offset="0" stopColor="#535968" />
          <stop offset="0.52" stopColor="#242936" />
          <stop offset="1" stopColor="#111723" />
        </radialGradient>
      </defs>
    </svg>
  );
}

/** Live facial features, positioned on each generated front surface in the 64-unit box. */
const FACES: Record<CharacterId, { x: number; y: number; gap: number; size?: number }> = {
  robot: { x: 32, y: 37, gap: 12 },
  gift: { x: 38, y: 41, gap: 11 },
  heart: { x: 32, y: 31, gap: 13 },
  star: { x: 32, y: 33, gap: 11 },
  bubble: { x: 32, y: 30, gap: 13 },
  calendar: { x: 32, y: 35, gap: 13 },
  bulb: { x: 32, y: 28, gap: 11 },
  envelope: { x: 32, y: 36, gap: 13 },
  gear: { x: 32, y: 32, gap: 11 },
  folder: { x: 32, y: 37, gap: 13 },
  book: { x: 32, y: 32, gap: 12 },
  chart: { x: 32, y: 26, gap: 12 },
  pin: { x: 32, y: 27, gap: 11 },
  padlock: { x: 32, y: 41, gap: 13 },
  flame: { x: 32, y: 35.5, gap: 11 },
  browser: { x: 32, y: 35, gap: 13 },
  briefcase: { x: 32, y: 40, gap: 13 },
  clipboard: { x: 32, y: 34, gap: 12 },
  bolt: { x: 32, y: 31, gap: 9, size: 0.82 },
  ball: { x: 32, y: 32, gap: 12 },
};

export function Character({
  id,
  colour,
  className = '',
}: {
  id: CharacterId;
  colour: ColourId;
  className?: string;
}) {
  const defs = useContext(DefsId);
  const { x, y, gap, size = 1 } = FACES[id];
  return (
    <svg
      viewBox="0 0 64 64"
      className={`${styles.character} ${className}`}
      style={{ '--cp-material': materialFilterOf(colour) } as CSSProperties}
      data-character={id}
      data-colour={colour}
      aria-hidden="true"
      focusable="false"
    >
      <image
        href={CHARACTER_RENDERS[id]}
        x="0"
        y="0"
        width="64"
        height="64"
        className={styles.bodyRender}
      />
      <g transform={`translate(${x} ${y}) scale(${size})`}>
        <g opacity="0.2" fill="#ef7193">
          <ellipse cx={-gap / 2 - 4.2} cy="4.1" rx="2.6" ry="1.3" />
          <ellipse cx={gap / 2 + 4.2} cy="4.1" rx="2.6" ry="1.3" />
        </g>
        <g className={styles.eyeLids} data-eyes="">
          {[-1, 1].map((side) => (
            <ellipse
              key={side}
              cx={(side * gap) / 2}
              cy="0.4"
              rx="3.85"
              ry="4.7"
              fill="#233544"
              opacity="0.12"
            />
          ))}
          {[-1, 1].map((side) => (
            <ellipse
              key={side}
              cx={(side * gap) / 2}
              cy="0"
              rx="3.6"
              ry="4.4"
              fill={`url(#${defs}-eye)`}
            />
          ))}
          <g className={styles.pupils} data-pupils="">
            {[-1, 1].map((side) => (
              <g key={side}>
                <ellipse
                  cx={(side * gap) / 2}
                  cy="0.1"
                  rx="1.85"
                  ry="2.5"
                  fill={`url(#${defs}-pupil)`}
                />
                <circle cx={(side * gap) / 2 - 0.55} cy="-0.85" r="0.7" fill="#fff" />
              </g>
            ))}
          </g>
        </g>
        <path
          className={styles.smile}
          d="M-2.6 7.4Q0 9.8 2.6 7.4"
          fill="none"
          stroke="#263340"
          strokeOpacity="0.72"
          strokeWidth="1"
          strokeLinecap="round"
        />
      </g>
    </svg>
  );
}

/** The line icons, drawn on a 24-unit grid with a 1.8 stroke. */
const ICONS: Record<CharacterId, ReactNode> = {
  robot: (
    <>
      <path d={rect(5, 8, 14, 12, 4)} />
      <path d={rect(8.6, 11.6, 6.8, 4.6, 1.8)} />
      <path d="M12 8V5.6M3 12.5v3.2M21 12.5v3.2" />
      <circle cx={12} cy={4.4} r={1.2} />
    </>
  ),
  gift: (
    <>
      <path d={rect(4, 8.5, 16, 4, 1.2)} />
      <path d="M5.5 12.5V19A1.5 1.5 0 0 0 7 20.5H17A1.5 1.5 0 0 0 18.5 19V12.5M12 8.5v12" />
      <path d="M12 8.5C10.6 5.8 7.8 4.5 7.4 6.5 7.1 8 9.5 8.5 12 8.5ZM12 8.5c1.4-2.7 4.2-4 4.6-2 .3 1.5-2.1 2-4.6 2Z" />
    </>
  ),
  heart: (
    <path d="M12 19.6S4.5 15.2 4.5 9.6A4.1 4.1 0 0 1 8.6 5.5c1.5 0 2.7.8 3.4 2 .7-1.2 1.9-2 3.4-2a4.1 4.1 0 0 1 4.1 4.1c0 5.6-7.5 10-7.5 10Z" />
  ),
  star: <path d={star(12, 12.6, 8.8, 4.4, 5, 1, 0.6)} />,
  bubble: (
    <path d="M8.5 4.5h7a5 5 0 0 1 5 5V11a5 5 0 0 1-5 5H11l-4 3.4V15.6A5 5 0 0 1 3.5 11V9.5a5 5 0 0 1 5-5Z" />
  ),
  calendar: (
    <>
      <path d={rect(4, 5.5, 16, 14.5, 3)} />
      <path d="M4 10.2h16M8.5 3.5v4M15.5 3.5v4M9.5 15h5" />
    </>
  ),
  bulb: (
    <>
      <path d="M12 3.5a6 6 0 0 0-3.6 10.8c.7.5 1.1 1.3 1.1 2.2v.6h5v-.6c0-.9.4-1.7 1.1-2.2A6 6 0 0 0 12 3.5Z" />
      <path d="M9.6 19.5h4.8M10.8 21.6h2.4" />
    </>
  ),
  envelope: (
    <>
      <path d={rect(3.5, 5.5, 17, 13, 3)} />
      <path d="m5 7.6 7 5.4 7-5.4" />
    </>
  ),
  gear: (
    <>
      <path d={gear(12, 12, 6.3, 8.7, 8, 0.9)} />
      <circle cx={12} cy={12} r={2.5} />
    </>
  ),
  folder: (
    <>
      <path d="M3.5 7.5a2 2 0 0 1 2-2h3.3a2 2 0 0 1 1.5.7l1.2 1.4h7a2 2 0 0 1 2 2v8.4a2 2 0 0 1-2 2H5.5a2 2 0 0 1-2-2Z" />
      <path d="M3.5 10.6h17" />
    </>
  ),
  book: (
    <path d="M12 7C10 5.4 6.9 4.9 3.5 5.4v13c3.4-.5 6.5 0 8.5 1.6 2-1.6 5.1-2.1 8.5-1.6v-13C17.1 4.9 14 5.4 12 7Zm0 0v13" />
  ),
  chart: <path d="M4 20h16M7 16.5v-3M12 16.5v-7M17 16.5V5.5" />,
  pin: (
    <>
      <path d="M12 20.8s-6.4-5.5-6.4-10.6a6.4 6.4 0 0 1 12.8 0c0 5.1-6.4 10.6-6.4 10.6Z" />
      <circle cx={12} cy={10.2} r={2.3} />
    </>
  ),
  padlock: (
    <>
      <path d={rect(5, 10.5, 14, 10, 3)} />
      <path d="M8.2 10.5V8a3.8 3.8 0 0 1 7.6 0v2.5M12 14.4v2.2" />
    </>
  ),
  flame: (
    <path d="M12 20.6c-3.6 0-6.2-2.5-6.2-5.8 0-2.8 1.7-4.5 3.1-6.1.3 1.6 1.1 2.6 2.3 3.1 0-3.2 1.4-5.8 3.8-7.5-.1 2.5.9 4.2 2 5.8.9 1.4 1.5 2.8 1.5 4.7 0 3.3-2.6 5.8-6.5 5.8Z" />
  ),
  browser: (
    <>
      <path d={rect(3.5, 4.5, 17, 15, 3)} />
      <path d="M3.5 9h17M6.6 6.8h.01M9 6.8h.01M11.4 6.8h.01" />
    </>
  ),
  briefcase: (
    <>
      <path d={rect(3.5, 7.5, 17, 12.5, 3)} />
      <path d="M9 7.5V6a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 6v1.5M3.5 12.8h17M12 11.6v2.4" />
    </>
  ),
  clipboard: (
    <>
      <path d={rect(5.5, 5, 13, 16, 3)} />
      <path d={rect(9, 3.4, 6, 3.6, 1.4)} />
      <path d="M9 11.5h6M9 15h4" />
    </>
  ),
  bolt: (
    <path
      d={polygon(
        [
          [12.6, 2.8],
          [17.6, 2.8],
          [14.4, 9.8],
          [18.8, 9.8],
          [9.6, 21.4],
          [11.2, 13.4],
          [5.6, 13.4],
        ],
        1,
      )}
    />
  ),
  ball: (
    <>
      <circle cx={12} cy={12} r={8.5} />
      <path d="M5 7.2c4.6 2.3 9.4 2.3 14 0M3.6 13.6c5.5 2.4 11.3 2.4 16.8 0" />
    </>
  ),
};

/** An object as a line icon, in the current text colour. Decorative, like the characters. */
export function Icon({
  id,
  className,
  style,
}: {
  id: CharacterId;
  className?: string;
  style?: CSSProperties;
}) {
  return (
    <svg
      viewBox="0 0 24 24"
      className={className}
      style={style}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      {ICONS[id]}
    </svg>
  );
}
```

### character-picker/render-assets.ts

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

/** Transparent 3D bodies; live facial features are drawn by Character. */
export const CHARACTER_RENDERS = {
  robot: '/experiments/character-picker/robot-3d-23d38062f340.webp',
  gift: '/experiments/character-picker/gift-3d-c582bec17c61.webp',
  heart: '/experiments/character-picker/heart-3d-6067f62af297.webp',
  star: '/experiments/character-picker/star-3d-3b6683298df7.webp',
  bubble: '/experiments/character-picker/bubble-3d-c0930c190908.webp',
  calendar: '/experiments/character-picker/calendar-3d-e99464209cbf.webp',
  bulb: '/experiments/character-picker/bulb-3d-d55f5fd4d1ea.webp',
  envelope: '/experiments/character-picker/envelope-3d-24b90a2a495f.webp',
  gear: '/experiments/character-picker/gear-3d-2d3e7cda2aa4.webp',
  folder: '/experiments/character-picker/folder-3d-449b82b9f073.webp',
  book: '/experiments/character-picker/book-3d-1f63dd5d23b9.webp',
  chart: '/experiments/character-picker/chart-3d-122015d97523.webp',
  pin: '/experiments/character-picker/pin-3d-ca69d6bcffe4.webp',
  padlock: '/experiments/character-picker/padlock-3d-0c76042a5ec9.webp',
  flame: '/experiments/character-picker/flame-3d-4e0b9be3b98e.webp',
  browser: '/experiments/character-picker/browser-3d-759d64556239.webp',
  briefcase: '/experiments/character-picker/briefcase-3d-38f9b8e338fd.webp',
  clipboard: '/experiments/character-picker/clipboard-3d-022a141e3fff.webp',
  bolt: '/experiments/character-picker/bolt-3d-e4c518eb54fa.webp',
  ball: '/experiments/character-picker/ball-3d-39752beba86c.webp',
} as const;
```

### character-picker/shapes.ts

```tsx
/*
 * Path helpers for the drawings (characters.tsx): rounded rectangles, ellipses, and polygons, stars
 * and gears with rounded corners, as SVG path data. Every part of a drawing is one path, so the
 * same path can be painted twice: its flat colour, then the shared light and shade over it.
 */

/** Two decimals are plenty in a 64-unit drawing. */
const n = (value: number) => Math.round(value * 100) / 100;

/** A rectangle with rounded corners: `top` for the two upper ones, `bottom` for the two lower. */
export function rect(x: number, y: number, w: number, h: number, top: number, bottom = top) {
  const arc = (r: number, toX: number, toY: number) =>
    r ? `A${r} ${r} 0 0 1 ${n(toX)} ${n(toY)}` : `L${n(toX)} ${n(toY)}`;
  return [
    `M${n(x + top)} ${n(y)}H${n(x + w - top)}`,
    arc(top, x + w, y + top),
    `V${n(y + h - bottom)}`,
    arc(bottom, x + w - bottom, y + h),
    `H${n(x + bottom)}`,
    arc(bottom, x, y + h - bottom),
    `V${n(y + top)}`,
    arc(top, x + top, y),
    'Z',
  ].join('');
}

/** An ellipse (a circle when `ry` is left out). */
export function ellipse(cx: number, cy: number, rx: number, ry = rx) {
  return `M${n(cx - rx)} ${n(cy)}A${rx} ${ry} 0 1 0 ${n(cx + rx)} ${n(cy)}A${rx} ${ry} 0 1 0 ${n(cx - rx)} ${n(cy)}Z`;
}

/** A corner: where it is, and how round (the polygon's radius when left out). */
export type Corner = readonly [x: number, y: number, radius?: number];

/**
 * A polygon with rounded corners. Each corner is cut back along both of its edges by its radius
 * (at most half of either edge) and the two cuts are joined by a curve through the corner.
 */
export function polygon(corners: readonly Corner[], radius: number) {
  const count = corners.length;
  return (
    corners
      .map(([x, y, own], i) => {
        const [px, py] = corners[(i + count - 1) % count];
        const [nx, ny] = corners[(i + 1) % count];
        const before = Math.hypot(px - x, py - y);
        const after = Math.hypot(nx - x, ny - y);
        const r = own ?? radius;
        const a = Math.min(r, before / 2) / before;
        const b = Math.min(r, after / 2) / after;
        const start = `${n(x + (px - x) * a)} ${n(y + (py - y) * a)}`;
        const end = `${n(x + (nx - x) * b)} ${n(y + (ny - y) * b)}`;
        return `${i ? 'L' : 'M'}${start}Q${n(x)} ${n(y)} ${end}`;
      })
      .join('') + 'Z'
  );
}

/** A point at `radius` from the centre, `degrees` clockwise from the top. */
const around = (cx: number, cy: number, radius: number, degrees: number) => {
  const angle = ((degrees - 90) * Math.PI) / 180;
  return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)] as const;
};

/** A star of `points` points, its tips rounded by `tip` and the corners between by `notch`. */
export function star(
  cx: number,
  cy: number,
  outer: number,
  inner: number,
  points: number,
  tip: number,
  notch: number,
) {
  const step = 180 / points;
  const corners: Corner[] = [];
  for (let i = 0; i < points * 2; i += 1) {
    const out = i % 2 === 0;
    corners.push([...around(cx, cy, out ? outer : inner, i * step), out ? tip : notch]);
  }
  return polygon(corners, tip);
}

/** A gear: `teeth` square teeth around a disc of `body` radius, reaching out to `reach`. */
export function gear(
  cx: number,
  cy: number,
  body: number,
  reach: number,
  teeth: number,
  round: number,
) {
  const step = 360 / teeth;
  const corners: Corner[] = [];
  for (let i = 0; i < teeth; i += 1) {
    const middle = i * step;
    // The tooth narrows a little towards its tip, like a cut gear.
    corners.push(
      [...around(cx, cy, body, middle - step * 0.3), round * 0.6],
      [...around(cx, cy, reach, middle - step * 0.19), round],
      [...around(cx, cy, reach, middle + step * 0.19), round],
      [...around(cx, cy, body, middle + step * 0.3), round * 0.6],
    );
  }
  return polygon(corners, round);
}
```

### character-picker/palette.ts

```tsx
/*
 * The colours a character comes in. Each has a base for its body, a deeper tone for the parts in
 * shade (a ribbon, a tab, a handle) and a soft tint for the light parts (a screen, an inner flame,
 * the avatar's backdrop by day; at night the CSS deepens the backdrop from the base). A drawing
 * uses a calibrated material filter to recolour the mint 3D render without flattening its light
 * and shade. The live eyes are separate and keep their original colours.
 */

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

export type Colour = {
  id: ColourId;
  /** As read out: "Folder, blue". */
  name: string;
  base: string;
  deep: string;
  soft: string;
};

export const COLOURS: readonly Colour[] = [
  { id: 'red', name: 'red', base: '#ff6a61', deep: '#e1443e', soft: '#ffd4d0' },
  { id: 'orange', name: 'orange', base: '#ff9a3c', deep: '#e87316', soft: '#ffe2c4' },
  { id: 'yellow', name: 'yellow', base: '#ffca38', deep: '#e9a113', soft: '#fff0bf' },
  { id: 'green', name: 'green', base: '#55cb7c', deep: '#2ba257', soft: '#cff2da' },
  { id: 'blue', name: 'blue', base: '#4c9bff', deep: '#2b71e6', soft: '#d2e5ff' },
  { id: 'purple', name: 'purple', base: '#9a7bff', deep: '#7152eb', soft: '#e4dcff' },
  { id: 'pink', name: 'pink', base: '#ff7cb5', deep: '#e65192', soft: '#ffd8ea' },
];

export const colourOf = (id: ColourId) => COLOURS.find((colour) => colour.id === id) ?? COLOURS[0];

/** Transform the render's mint material to each palette colour; neutral details stay neutral. */
const MATERIAL_FILTERS: Record<ColourId, string> = {
  red: 'hue-rotate(227.7deg) saturate(1.571) brightness(0.796)',
  orange: 'hue-rotate(260.5deg) saturate(1.422) brightness(0.980)',
  yellow: 'hue-rotate(279.3deg) saturate(1.328) brightness(1.177)',
  green: 'none',
  blue: 'hue-rotate(86.5deg) saturate(1.542) brightness(0.845)',
  purple: 'hue-rotate(126.2deg) saturate(1.451) brightness(0.808)',
  pink: 'hue-rotate(194.8deg) saturate(1.132) brightness(0.906)',
};

export const materialFilterOf = (id: ColourId) => MATERIAL_FILTERS[id];
```

### character-picker/ghost-pointer.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './character-picker.module.css';

/*
 * The demo's ghost pointer, and the timeline that moves it. The pointer is drawn at
 * (--cp-px, --cp-py), the two properties the reader's pointer writes too, so the characters' eyes
 * follow it like a real one. A glide is a transition of those registered properties, one per axis
 * with its own easing, which bends the path into a gentle arc; nothing runs between glides.
 *
 * A demo is a script of awaited steps: wait, glide onto a control, press it and call the
 * component's own handler, as a click would. Stopping the demo (the reader takes over, or it
 * scrolls away) ends the step in progress at once: its promise rejects, which ends the script
 * right there, and the pointer fades out.
 */

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;
  /** Presses `target` and lets go; `action` runs as the pointer goes down. */
  press: (target: Element | null | undefined, action: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

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

/**
 * Plays `script` with the pointer over `stage`; `prepare` runs before each glide (the component
 * measures its characters there, if it has to). Returns the function that stops it.
 */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  prepare: () => void,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let pressed: Element | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(() => {
        pending.delete(cancel);
        if (alive) resolve();
      });
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const place = ({ x, y }: Point, ms: number) => {
    stage.style.setProperty('--move', `${ms}ms`);
    stage.style.setProperty('--cp-px', `${x}px`);
    stage.style.setProperty('--cp-py', `${y}px`);
  };

  // It comes in from the lower right: put there at once, then shown, then the glides begin.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.95 };
  place(at, 0);
  getComputedStyle(stage).getPropertyValue('--cp-px');
  stage.dataset.ghost = '';
  stage.style.setProperty('--cp-look', '1');

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    prepare();
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    // Longer trips take a little longer.
    const ms = Math.round(Math.min(900, Math.max(380, Math.hypot(to.x - at.x, to.y - at.y) * 2)));
    at = to;
    place(to, ms);
    return wait(ms + 30);
  };

  const press: Demo['press'] = async (target, action) => {
    pointer.firstElementChild?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    target?.setAttribute('data-pressed', '');
    pressed = target ?? null;
    await wait(110);
    action();
    pressed?.removeAttribute('data-pressed');
    pressed = null;
    await wait(170);
  };

  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    pending.forEach((cancel) => cancel());
    pressed?.removeAttribute('data-pressed');
    delete stage.dataset.ghost;
    stage.style.setProperty('--move', '0ms');
    stage.style.setProperty('--cp-look', '0');
  };
}

/** The arrow the demo moves around. It is decorative: the demo's effects show for themselves. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} className={styles.ghost} viewBox="0 0 20 20" 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>
  );
}
```

### character-picker/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### character-picker/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, awake: visible && !reduced, playing: visible && !reduced && !taken };
}
```