# Acting icons

> A toolbar and its glyph set, where every icon acts out its meaning when hovered and when pressed.

A section of a desktop app’s design system: a toolbar of glyphs in inset caps, in a light and a dark tone, and the set of twelve tools below it. Each glyph acts out what it does: the pointer tilts onto its tip, the T steps aside for a blinking caret, the links pull apart, the tiles trade sides. Every move is on a spring with a damping of 0.66 and can be interrupted.

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

Category: Motion.

## Anatomy

1. **Toolbar**: A `role="toolbar"` pill holding the caps; only one cap is in the tab order and the arrow keys, Home and End move along it.
2. **Tool cap**: An inset key for a mode. The tool in use stays sunk, with `aria-pressed="true"`.
3. **In-use light**: A small green light at the top right of the tool in use; it fades in over 200 ms when the tool changes.
4. **Glyph**: A 24-unit drawing in parts. Hovered, each part springs to its pose (here the Note’s corner lifts); pressed, it plays a short act on top.
5. **Divider**: An etched hairline between the groups: tools, layout commands, search, zoom.
6. **Command cap**: An inset key that acts once: it sinks while held and fires `onCommand` on release.
7. **Search field**: An inset well with a lens that sweeps on hover and sharpens on focus; Enter calls `onSearch`.
8. **Shortcut key**: Shows `⌘K`: Command-K or Control-K anywhere in the stage focuses the field.

## When to use

- A desktop-style tool palette where every glyph is clicked often and a little life on hover tells which one is about to act.
- Icon-only buttons whose meaning is worth previewing: a hover pose that acts out `Tidy` or `Fit` explains it before the press.
- Modes against one-shot commands: tools stay lit while in use (`aria-pressed`), commands only sink for the press.
- Toolbars that have to read in both a light and a dark tone, with the same glyphs and motion in each.

## When not to use

- Icons that sit beside a text label. The words already explain them; keep the glyph still.
- Dense lists or table rows with an icon per row: a dozen springs moving at once is noise. Use static icons.
- Touch-first screens: there is no hover, so the pose only flashes on a tap. Label the buttons instead.
- Destructive or rare commands. Put them in a menu with words, where a playful press can’t be misread.
- A toolbar with more than a dozen or so commands: group the rest in an overflow menu rather than adding caps.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/acting-icons.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 { ActingIcons, type Command, type Tool } from './acting-icons';

export function CanvasTools({
  onTool,
  onCommand,
}: {
  onTool: (tool: Tool) => void;
  onCommand: (command: Command) => void;
}) {
  return (
    <ActingIcons
      autoplay={false}
      defaultTool="select"
      onToolChange={(tool) => onTool(tool)}
      onCommand={onCommand}
    />
  );
}
```

## States

- **At rest**: Nothing moves. The tool in use is sunk and lit; every other cap is raised.
- **Hovered**: The cap brightens and the glyph springs to its hover pose: the Draw tip glides and leaves a stroke.
- **Pressed**: The cap sinks to 94% under a darker face. The glyph’s press act plays only on a real press, so it can’t be held here.
- **Search hovered**: The lens takes its pose; its glint crosses once when the hover begins.
- **Dark toolbar**: The same caps in the dark tone, with the Link glyph hovered: its links drift apart.
- **The set**: The twelve glyphs in larger caps, each with what it does on hover and press. Here Image is hovered.
- **The set, dark scheme**: With `colorScheme="dark"` the stage darkens and the set’s caps take the dark toolbar’s look.

## How it works

- Each glyph is drawn on a 24 grid with a 1.7 rounded stroke and split into parts, such as the arrow and its ring, the T and its caret, or the corners of a frame. Each part is a small SVG inside two HTML boxes, and only the boxes move, with transform, opacity and clip-path, so no SVG is restyled or laid out again while a glyph acts.
- A hover pose is a CSS transition between two transforms that each part keeps in custom properties. Its easing is a spring with a damping ratio of 0.66, sampled once into a CSS linear() curve that serves every duration from 180 to 460 ms; a hover that ends halfway springs back from wherever the glyph is.
- A press plays with the Web Animations API: a kick pulls the same spring towards a pose for a moment and lets it go, composited with “add” so a second press lands on top of the first. One-way effects, like the ring leaving the arrow’s point or the note’s lines writing themselves in, fly through a few poses with clip-path and opacity.
- Fit’s content grows to its frame without its stroke getting thicker: it is drawn in nine pieces, four corners that move out, four edges that move and stretch along their length, and a tint that scales underneath.
- Each cap’s key is a small layer of its own: hovered, it brightens under a light sheen; pressed, or while its tool is in use, a sunk face fades in over it, so only that key repaints. At rest nothing runs.
- The stage and the set are light-dark() tokens on the root, guarded in @supports, so they follow the page’s color-scheme, or the colorScheme prop; each toolbar declares its own tone’s colours, so the light toolbar stays light and the dark one dark, and at night the set’s caps take the dark toolbar’s look.

## API reference

- **defaultTool** (`'select' | 'text' | 'note' | 'image' | 'link' | 'draw'`, default `'select'`): The tool each toolbar starts with.
- **defaultHover** (`GlyphName`): A glyph drawn hovered at first, in its hover pose in each toolbar and in the set, held until a pointer leaves its cap. Turns the demo off.
- **defaultDown** (`GlyphName`): A glyph whose cap is drawn pressed down at first, held until it is released; the press’s motion itself plays only on a real press. Turns the demo off.
- **only** (`'light' | 'dark' | 'set'`): Draws only one part of the stage: the light toolbar, the dark one, or the set. Without it, all three.
- **onToolChange** (`(tool: Tool, toolbar: 'light' | 'dark') => void`): Called when the reader picks a tool, with the toolbar it was picked in.
- **onCommand** (`(command: 'layout' | 'tidy' | 'zoom-out' | 'fit' | 'zoom-in') => void`): Called when the reader presses a command.
- **onSearch** (`(query: string) => void`): Called with the query when the reader presses Enter in a search field.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer tours both toolbars and the set, hovering and pressing caps.
- **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 stage.

## Accessibility

- Each toolbar is a role="toolbar" with a single tab stop; the arrow keys, Home and End move along its caps. The six tools are toggle buttons (aria-pressed), and a tool the reader picks is announced in a polite live region.
- Enter and Space press a cap and play its press, as a click does. Keyboard focus shows the hover pose with a visible outline, and ⌘K or Ctrl+K inside the stage focuses the search field of the toolbar in use.
- The set is a list of buttons, each described by its hover and press lines; the arrow keys move through the grid by rows and columns.
- Under reduced motion poses appear at once, presses don’t play and the demo never runs. Glyphs and the demo pointer are aria-hidden.

## Source

### acting-icons/acting-icons.tsx

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

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { Glyph, pressGlyph, type GlyphName } from './glyphs';
import { SPRING } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './acting-icons.module.css';

/*
 * Acting icons: a desktop app's toolbar and the glyphs it is made of, each of which acts out its
 * meaning when hovered and when pressed.
 *
 * - The toolbar comes in light and dark: six tools (one in use, lit), two layout commands, a
 *   search field with its shortcut, and three zoom commands, each glyph in an inset cap.
 * - The set shows the twelve glyphs in larger caps, with what each does when hovered and pressed.
 * - Hover a cap and its glyph takes its hover pose on a spring; leave halfway and it springs back
 *   from where it is. Press it and the glyph plays its press, which adds itself on top of whatever
 *   moves already (glyphs.tsx).
 * - Every motion is a transform, an opacity or a clip on small HTML boxes around fixed SVG
 *   drawings; at rest nothing runs.
 * - While on screen and untouched, a ghost pointer hovers and presses caps in turn, in the
 *   toolbars and in the set. Under reduced motion every glyph shows its poses at once.
 * - Light or dark: the stage and the set follow the page's color-scheme, or the one the
 *   colorScheme prop sets, through light-dark() tokens in the stylesheet (light in browsers
 *   without it). Each toolbar keeps its own tone; in the dark scheme the set's caps take the dark
 *   toolbar's look.
 */

/** The tools of the toolbar: one of them is in use. */
export type Tool = 'select' | 'text' | 'note' | 'image' | 'link' | 'draw';
/** The commands of the toolbar: each acts once. */
export type Command = 'layout' | 'tidy' | 'zoom-out' | 'fit' | 'zoom-in';
/** The two toolbars. */
export type Tone = 'light' | 'dark';

const TOOLS: Tool[] = ['select', 'text', 'note', 'image', 'link', 'draw'];
const ARRANGE: Command[] = ['layout', 'tidy'];
const ZOOM: Command[] = ['zoom-out', 'fit', 'zoom-in'];

const NAMES: Record<GlyphName, string> = {
  select: 'Select',
  text: 'Text',
  note: 'Note',
  image: 'Image',
  link: 'Link',
  draw: 'Draw',
  layout: 'Layout',
  tidy: 'Tidy',
  search: 'Search',
  'zoom-in': 'Zoom In',
  'zoom-out': 'Zoom Out',
  fit: 'Fit',
};

/** The set, in its order, with what each glyph does. */
const SET: { name: GlyphName; hover: string; press: string }[] = [
  { name: 'select', hover: 'leans on its tip', press: 'the tip dips, a ring spreads out' },
  { name: 'text', hover: 'the T steps aside, a caret blinks', press: 'stamps down' },
  { name: 'note', hover: 'a corner lifts', press: 'its lines write themselves in' },
  { name: 'image', hover: 'a sun climbs over the ridge', press: 'the frame breathes, sun flares' },
  { name: 'link', hover: 'the links drift apart, the bar shrinks', press: 'they clasp' },
  { name: 'draw', hover: 'the tip glides and leaves a stroke', press: 'taps the page' },
  { name: 'layout', hover: 'tiles switch', press: 'tiles settle in' },
  {
    name: 'tidy',
    hover: 'stray tiles snap to the guide',
    press: 'the guide pulses, tiles nudge in',
  },
  { name: 'search', hover: 'lens sweeps, a glint crosses', press: 'sharpens' },
  { name: 'zoom-in', hover: 'plus spins 90°', press: 'lens swells' },
  { name: 'zoom-out', hover: 'the minus draws in to a dash', press: 'lens shrinks' },
  { name: 'fit', hover: 'content grows out to the frame', press: 'corners grip' },
];

const isTool = (name: GlyphName): name is Tool => (TOOLS as GlyphName[]).includes(name);

/** The demo's tour: where the pointer goes, and whether it presses there. */
const TOUR: [zone: Tone | 'set', target: GlyphName, press: boolean][] = [
  ['light', 'text', false],
  ['light', 'note', true],
  ['light', 'link', true],
  ['light', 'search', false],
  ['dark', 'layout', true],
  ['dark', 'zoom-in', true],
  ['set', 'select', true],
  ['set', 'image', true],
  ['set', 'draw', true],
  ['set', 'tidy', true],
  ['set', 'search', false],
  ['set', 'fit', true],
  ['light', 'select', true],
  ['dark', 'draw', true],
  ['dark', 'tidy', false],
  ['set', 'text', true],
  ['set', 'note', true],
  ['set', 'link', true],
  ['set', 'layout', true],
  ['set', 'zoom-in', false],
  ['set', 'zoom-out', true],
  ['dark', 'select', true],
  ['light', 'zoom-out', false],
  ['light', 'fit', true],
];

const markHot = (element: HTMLElement, on: boolean) => {
  if (on) element.dataset.hot = '';
  else delete element.dataset.hot;
};

/** A cap is pressed: it sinks and its glyph plays its press. */
const pressDown = (cap: HTMLElement) => {
  cap.dataset.down = '';
  pressGlyph(cap.querySelector('[data-glyph]'));
};

/** Moves focus with the arrow keys, Home and End among `items`, `columns` to a row. */
const roam = (event: KeyboardEvent, items: HTMLElement[], columns = items.length) => {
  const index = items.indexOf(event.target as HTMLElement);
  if (index < 0) return;
  const moves: Partial<Record<string, number>> = {
    ArrowRight: index + 1,
    ArrowLeft: index - 1,
    ArrowDown: columns < items.length ? index + columns : undefined,
    ArrowUp: columns < items.length ? index - columns : undefined,
    Home: 0,
    End: items.length - 1,
  };
  const next = moves[event.key];
  if (next === undefined) return;
  event.preventDefault();
  // Sideways, a row wraps around; up and down stop at the edges.
  const to =
    event.key === 'ArrowLeft' || event.key === 'ArrowRight'
      ? (next + items.length) % items.length
      : Math.min(items.length - 1, Math.max(0, next));
  items[to]?.focus();
};

/**
 * A glyph in its cap, as a button. Hovered (or focused from the keyboard) it takes its hover
 * pose; pressed, it sinks and plays its press. `pressed` marks a tool: lit while in use.
 */
function IconCap({
  glyph,
  label,
  pressed,
  describedBy,
  tabIndex,
  onPress,
  onFocus,
  hot,
  down,
}: {
  /** The glyph in the cap. */
  glyph: GlyphName;
  /** What the button does, for screen readers. */
  label: string;
  /** For a tool: whether it is the one in use. */
  pressed?: boolean;
  /** The ids of the texts that describe the button. */
  describedBy?: string;
  /** Its place in the tab order (a toolbar keeps one cap in it). */
  tabIndex?: number;
  /** Runs on a click, a tap, Enter or Space. */
  onPress?: () => void;
  /** Runs when it takes focus. */
  onFocus?: () => void;
  /** Opens drawn hovered (its hover pose), until a pointer leaves it. */
  hot?: boolean;
  /** Opens drawn pressed down (sunk), until it is released. */
  down?: boolean;
}) {
  const lift = useRef(0);
  useEffect(() => () => window.clearTimeout(lift.current), []);

  const release = (cap: HTMLElement) => delete cap.dataset.down;

  return (
    <button
      type="button"
      className={styles.cap}
      data-cap={glyph}
      data-part="cap"
      data-hot={hot ? '' : undefined}
      data-down={down ? '' : undefined}
      aria-label={label}
      aria-pressed={pressed}
      aria-describedby={describedBy}
      tabIndex={tabIndex}
      onPointerEnter={(event) => {
        if (event.pointerType !== 'touch') markHot(event.currentTarget, true);
      }}
      onPointerLeave={(event) => {
        release(event.currentTarget);
        // A lifted finger leaves at once: its timer below lets go of the hover pose.
        if (event.pointerType !== 'touch') markHot(event.currentTarget, false);
      }}
      onPointerDown={(event) => {
        if (event.button !== 0) return;
        window.clearTimeout(lift.current);
        // A touch has no hover: the press shows the hover pose too, for a moment.
        markHot(event.currentTarget, true);
        pressDown(event.currentTarget);
      }}
      onPointerUp={(event) => {
        const cap = event.currentTarget;
        release(cap);
        if (event.pointerType === 'touch') {
          lift.current = window.setTimeout(() => markHot(cap, false), 600);
        }
      }}
      onPointerCancel={(event) => {
        release(event.currentTarget);
        markHot(event.currentTarget, false);
      }}
      onKeyDown={(event) => {
        if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) {
          pressDown(event.currentTarget);
        }
      }}
      onKeyUp={(event) => {
        if (event.key === 'Enter' || event.key === ' ') release(event.currentTarget);
      }}
      onBlur={(event) => release(event.currentTarget)}
      onFocus={onFocus}
      onClick={onPress}
    >
      <span className={styles.key} data-part="key">
        <Glyph name={glyph} />
        {pressed !== undefined && <span className={styles.light} data-part="light" />}
      </span>
    </button>
  );
}

/** One toolbar: its tools, commands and search field, in a light or a dark tone. */
function Toolbar({
  tone,
  tool,
  onTool,
  onCommand,
  onSearch,
  hover,
  down,
}: {
  tone: Tone;
  tool: Tool;
  onTool: (tool: Tool) => void;
  onCommand?: (command: Command) => void;
  onSearch?: (query: string) => void;
  /** The glyph drawn hovered at first. */
  hover?: GlyphName;
  /** The glyph drawn pressed down at first. */
  down?: GlyphName;
}) {
  // One cap is in the tab order (the tool in use, at first); the arrow keys move along the row.
  const [current, setCurrent] = useState<GlyphName>(tool);

  const cap = (name: GlyphName) => (
    <IconCap
      key={name}
      glyph={name}
      label={NAMES[name]}
      pressed={isTool(name) ? tool === name : undefined}
      tabIndex={name === current ? 0 : -1}
      hot={name === hover}
      down={name === down}
      onFocus={() => setCurrent(name)}
      onPress={() => (isTool(name) ? onTool(name) : onCommand?.(name as Command))}
    />
  );

  return (
    <div className={styles.track} data-track="">
      <div
        role="toolbar"
        aria-label={`Canvas tools, ${tone}`}
        className={styles.bar}
        data-part="toolbar"
        data-tone={tone}
        data-zone={tone}
        onKeyDown={(event) =>
          roam(event, [...event.currentTarget.querySelectorAll<HTMLElement>('button[data-cap]')])
        }
      >
        {TOOLS.map(cap)}
        <span className={styles.divider} data-part="divider" aria-hidden="true" />
        {ARRANGE.map(cap)}
        <span className={styles.divider} data-part="divider" aria-hidden="true" />
        <label
          className={styles.search}
          data-cap="search"
          data-part="search"
          data-hot={hover === 'search' ? '' : undefined}
          onPointerEnter={(event) => markHot(event.currentTarget, event.pointerType !== 'touch')}
          onPointerLeave={(event) => markHot(event.currentTarget, false)}
        >
          <Glyph name="search" />
          <input
            className={styles.field}
            type="search"
            placeholder="Search"
            aria-label="Search"
            aria-keyshortcuts="Meta+K Control+K"
            // The lens focuses with the field.
            onFocus={(event: FocusEvent<HTMLInputElement>) =>
              pressGlyph(event.currentTarget.parentElement?.querySelector('[data-glyph]') ?? null)
            }
            onKeyDown={(event) => {
              if (event.key === 'Enter') onSearch?.(event.currentTarget.value);
            }}
          />
          <kbd className={styles.kbd} data-part="shortcut" aria-hidden="true">
            <span className={styles.cmd}>⌘</span>K
          </kbd>
        </label>
        <span className={styles.divider} data-part="divider" aria-hidden="true" />
        {ZOOM.map(cap)}
      </div>
    </div>
  );
}

/**
 * The demo: the toolbar, light and dark, and the set of twelve glyphs, each acting out its
 * meaning when hovered and pressed. While on screen and untouched, a ghost pointer plays with it.
 */
export function ActingIcons({
  defaultTool = 'select',
  onToolChange,
  onCommand,
  onSearch,
  autoplay = true,
  colorScheme,
  defaultHover,
  defaultDown,
  only,
  className = '',
}: {
  /** The tool each toolbar starts with. */
  defaultTool?: Tool;
  /** Called when the reader picks a tool, with the toolbar it was picked in. */
  onToolChange?: (tool: Tool, toolbar: Tone) => void;
  /** Called when the reader presses a command: Layout, Tidy, Zoom Out, Fit or Zoom In. */
  onCommand?: (command: Command) => void;
  /** Called with the query when the reader presses Enter in a search field. */
  onSearch?: (query: string) => void;
  /** Plays the demo while it is on screen and untouched; false leaves it to the reader. */
  autoplay?: boolean;
  /**
   * The stage's colours: light, dark, or 'system' for the reader's setting. Unset, it follows the
   * page's color-scheme (light if the page sets none). Each toolbar keeps its tone either way.
   */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * A glyph drawn hovered at first, in its hover pose (in each toolbar and in the set), held until
   * a pointer leaves its cap. Turns the demo off.
   */
  defaultHover?: GlyphName;
  /**
   * A glyph's cap drawn pressed down (sunk) at first, held until it is released. The press's
   * motion itself plays only on a real press. Turns the demo off.
   */
  defaultDown?: GlyphName;
  /** Draws only one part of the stage: the light toolbar, the dark one, or the set. */
  only?: Tone | 'set';
  /** Extra class for the stage. */
  className?: string;
}) {
  const id = useId();
  const [tools, setTools] = useState<Record<Tone, Tool>>({ light: defaultTool, dark: defaultTool });
  const [message, setMessage] = useState('');
  const [cell, setCell] = useState(0);
  const pointer = useRef<HTMLSpanElement>(null);
  // While the demo clicks a cap, its effects stay silent.
  const demoClick = useRef(false);
  const { ref, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const live = autoplay && playing && !defaultHover && !defaultDown;

  const pickTool = (toolbar: Tone, tool: Tool) => {
    setTools((now) => ({ ...now, [toolbar]: tool }));
    if (demoClick.current) return;
    onToolChange?.(tool, toolbar);
    // A trailing space tells the same news twice apart, so it is read again.
    setMessage((now) => {
      const news = `${NAMES[tool]} tool, ${toolbar} toolbar`;
      return now === news ? `${news} ` : news;
    });
  };

  const command = (name: Command) => {
    if (!demoClick.current) onCommand?.(name);
  };

  useEffect(() => {
    const stage = ref.current;
    const arrow = pointer.current;
    if (!live || !stage || !arrow) return;
    // What the demo marks as hovered or pressed, to let go of when it stops.
    const held = new Set<HTMLElement>();
    const find = (zone: string, name: GlyphName) =>
      stage.querySelector<HTMLElement>(`[data-zone="${zone}"] [data-cap="${name}"]`);
    // A cap out of sight, even partly, is skipped: scrolled away in its toolbar (in a narrow
    // stage), or off the screen (a tall stage on a phone).
    const inView = (element: HTMLElement) => {
      const box = element.getBoundingClientRect();
      const frame = (element.closest('[data-track]') ?? stage).getBoundingClientRect();
      return (
        box.left >= frame.left + 8 &&
        box.right <= frame.right - 8 &&
        box.top >= 0 &&
        box.bottom <= window.innerHeight
      );
    };

    const script = async (demo: Demo) => {
      await demo.wait(700);
      for (;;) {
        let visits = 0;
        for (const [zone, name, press] of TOUR) {
          const target = find(zone, name);
          if (!target || !inView(target)) continue;
          visits += 1;
          const isCap = target instanceof HTMLButtonElement;
          demo.hand(false);
          // On a cap, the finger rests low and to the right, so that the glyph stays in sight.
          await demo.moveTo(target, isCap ? { x: 0.7, y: 0.76 } : { x: 0.45, y: 0.62 });
          demo.hand(isCap);
          markHot(target, true);
          held.add(target);
          await demo.wait(press ? 620 : 1150);
          if (press && isCap) {
            await demo.press(() => {
              pressDown(target);
              demoClick.current = true;
              target.click();
              demoClick.current = false;
            });
            delete target.dataset.down;
            await demo.wait(720);
          }
          markHot(target, false);
          held.delete(target);
        }
        // Nothing was in sight this round: look again in a moment.
        if (!visits) await demo.wait(1000);
      }
    };

    return runDemo(stage, arrow, script, () => {
      held.forEach((element) => {
        markHot(element, false);
        delete element.dataset.down;
      });
      held.clear();
    });
  }, [live, ref]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    take();
    // ⌘K or Ctrl+K: the search field of the toolbar in use, or the first one.
    if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
      event.preventDefault();
      const zone = (event.target as HTMLElement).closest('[data-zone]');
      const field = zone?.querySelector('input') ?? event.currentTarget.querySelector('input');
      field?.focus();
    }
  };

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring-curve': SPRING } as CSSProperties}
      onPointerDown={take}
      onPointerMove={(event: PointerEvent) => {
        if (event.pointerType === 'mouse') take();
      }}
      onKeyDown={onKeyDown}
      onFocus={take}
    >
      {only !== 'set' && (
        <>
          <div className={styles.head} data-part="head">
            <span className={styles.name}>Toolbar</span>
            <span className={styles.caption}>
              16pt glyphs in 36pt inset caps · hover to preview · press to act
            </span>
          </div>
          <div className={styles.bars} style={only ? { marginBottom: 16 } : undefined}>
            {(['light', 'dark'] as const)
              .filter((tone) => !only || tone === only)
              .map((tone) => (
                <Toolbar
                  key={tone}
                  tone={tone}
                  tool={tools[tone]}
                  onTool={(tool) => pickTool(tone, tool)}
                  onCommand={command}
                  onSearch={onSearch}
                  hover={defaultHover}
                  down={defaultDown}
                />
              ))}
          </div>
        </>
      )}

      {(!only || only === 'set') && (
        <>
          <div className={styles.head} data-part="head">
            <span className={styles.name}>The set</span>
            <span className={styles.caption}>
              Each glyph acts out its meaning · spring damping 0.66 · 180–460 ms · interruptible
            </span>
          </div>
          <p className={styles.count}>
            Tools <span className={styles.total}>12</span>
          </p>
          <ul
            className={styles.grid}
            data-part="set"
            aria-label="The set"
            data-zone="set"
            onKeyDown={(event) => {
              const items = [
                ...event.currentTarget.querySelectorAll<HTMLElement>('button[data-cap]'),
              ];
              const columns = getComputedStyle(event.currentTarget).gridTemplateColumns.split(' ');
              roam(event, items, columns.length);
            }}
          >
            {SET.map(({ name, hover, press }, index) => (
              <li key={name} className={styles.cell} data-part="cell">
                <IconCap
                  glyph={name}
                  label={NAMES[name]}
                  describedBy={`${id}-${name}-hover ${id}-${name}-press`}
                  tabIndex={index === cell ? 0 : -1}
                  hot={name === defaultHover}
                  down={name === defaultDown}
                  onFocus={() => setCell(index)}
                />
                <div className={styles.notes}>
                  <span className={styles.label} data-part="label" aria-hidden="true">
                    {NAMES[name]}
                  </span>
                  <p id={`${id}-${name}-hover`} className={styles.line} data-part="note">
                    hover · {hover}
                  </p>
                  <p id={`${id}-${name}-press`} className={styles.line}>
                    press · {press}
                  </p>
                </div>
              </li>
            ))}
          </ul>
        </>
      )}

      <DemoPointer ref={pointer} className={styles.pointer} />
      <p className={styles.announce} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### acting-icons/acting-icons.module.css

```css
/* Acting icons (acting-icons.tsx): a page of a desktop app's design system, on a warm light grey
   stage, or a near-black one in the dark scheme. A toolbar of glyphs in inset caps, light and
   dark, then the set, each glyph in its cap with what it does when hovered and pressed. In a
   narrow stage the toolbars scroll sideways and the set falls into two columns, then one.

   Colours. Every colour is a token, light first as a plain value; those that follow the scheme
   come again as light-dark(light, dark) pairs inside @supports, which a build that rewrites
   light-dark() for older browsers leaves as written (rewritten, the pairs would need a
   color-scheme the same build had rewritten too, and on a page that sets none every colour would
   drop). The stage and the set follow the color-scheme in force: the page's, or the one the
   colorScheme prop sets (data-color-scheme); without light-dark() they stay light. Each toolbar
   keeps its tone whatever the scheme, with colours of its own; in the dark scheme the set's caps
   take the dark toolbar's. Where a dark cap's shadow has another shape, not only other colours,
   its pair lists the layers of both, each transparent in the other scheme.

   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted
   again costs a second buffer. So each cap's key is a small layer of its own (will-change), and
   what changes while a glyph acts stays inside it: the face fading to its pressed look, the parts
   moving. Nothing that changes while the demo plays needs a layout: the parts move by transform,
   opacity and clip-path on HTML boxes around SVG drawings that never change, and the pointer is
   a box that moves on its own layer. The stage does not clip its content: a rounded clip around
   so many layers costs a phone more memory. */

.root {
  /* The stage, its rim, and its words: names, captions, the count, and the set's notes. */
  --surface: #f0f0ee;
  --rim: rgb(0 0 0 / 6%);
  --ink: #1d1d1f;
  --ink-label: #232325;
  --ink-muted: #959492;
  --ink-note: #969492;
  --ink-faint: #9a9997;
  --ink-faintest: #c8c7c5;
  /* A head's hairline, etched: a line and a glint under it. */
  --line: #dddddb;
  --line-glint: #fbfbf9;

  /* The same in both schemes, as the dark toolbar has always shown them: the light of the tool in
     use (its colour, its hot core, its glow), the focus ring, and the demo's pointer, which looks
     like the system's. */
  --accent: #5fe0a8;
  --accent-ink: #e9fff4;
  --accent-glow: rgb(95 224 168 / 35%);
  --focus: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 25%);

  --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono', JetBrains, monospace;
  /* The glyphs' spring (spring.ts); browsers without linear() easings get a close cubic. */
  --spring: cubic-bezier(0.3, 1.25, 0.5, 1);
  position: relative;
  container: icons / inline-size;
  padding: clamp(18px, 3.4%, 28px) clamp(16px, 4.4%, 36px) clamp(20px, 3.6%, 30px);
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--rim);
  color: var(--ink);
  font:
    400 12px/1.4 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
/* The light tone: the light toolbar's colours, and the set's while the scheme is light. A cap: its
   face under a sheen, its edge and cast shadow, its face hovered, its face sunk (pressed, or while
   its tool is in use) and that face's shadow, and its glyph. A toolbar: its body and shadow
   (shorter in a narrow stage), its dividers, the search well and its words, and the shortcut's
   key. */
.root,
.bar[data-tone='light'] {
  --surface-2: #f0edec;
  --sheen: linear-gradient(rgb(255 255 255 / 94%), rgb(255 255 255 / 0%));
  --shadow:
    0 0 0 1px rgb(0 0 0 / 7.5%), 0 1px 1px rgb(0 0 0 / 8%), 0 2px 5px -1px rgb(0 0 0 / 10%),
    inset 0 1px 0 #fff;
  --lift: #fffffd;
  --pressed: #e5e2e1;
  --pressed-shadow: inset 0 1.5px 2.5px rgb(0 0 0 / 10%), inset 0 0 0 1px rgb(0 0 0 / 4%);
  --glyph-ink: #39393b;
  --bar-top: #fdfaf9;
  --bar-bottom: #ebe8e7;
  --bar-shadow:
    0 0 0 1px rgb(0 0 0 / 6%), 0 1px 1px rgb(0 0 0 / 5%), 0 4px 8px -2px rgb(0 0 0 / 7%),
    0 18px 36px -10px rgb(0 0 0 / 16%), inset 0 1px 0 #fff;
  --bar-shadow-short:
    0 0 0 1px rgb(0 0 0 / 6%), 0 1px 1px rgb(0 0 0 / 5%), 0 4px 8px -2px rgb(0 0 0 / 7%),
    0 10px 16px -6px rgb(0 0 0 / 14%), inset 0 1px 0 #fff;
  --divider: #e1dedd;
  --divider-glint: #fffdfc;
  --well: #e3e0df;
  --well-shade: rgb(0 0 0 / 7%);
  --well-glint: #fff;
  --well-ink: #96938f;
  --placeholder: #94918f;
  --kbd: #fff;
  --kbd-ink: #5f5e5c;
  --kbd-shadow: 0 0 0 1px rgb(0 0 0 / 4%), 0 1px 2px rgb(0 0 0 / 10%);
}
/* The dark tone: the dark toolbar's colours, and the set's in the dark scheme (the pairs below). */
.bar[data-tone='dark'] {
  --surface-2: #232325;
  --sheen: linear-gradient(rgb(255 255 255 / 3.5%), rgb(255 255 255 / 0%));
  --shadow:
    0 0 0 1px rgb(0 0 0 / 55%), 0 2px 3px rgb(0 0 0 / 45%), inset 0 1px 0 rgb(255 255 255 / 7%),
    inset 0 0 0 2.5px rgb(255 255 255 / 1.5%);
  --lift: #2a2a2c;
  --pressed: #141415;
  --pressed-shadow: inset 0 1px 2px rgb(0 0 0 / 70%), 0 1px 0 rgb(255 255 255 / 4%);
  --glyph-ink: #dcdcde;
  --bar-top: #2b2b2d;
  --bar-bottom: #18181a;
  --bar-shadow:
    0 0 0 1px rgb(0 0 0 / 80%), 0 1px 2px rgb(0 0 0 / 22%), 0 17px 42px -8px rgb(0 0 0 / 46%),
    inset 0 1px 0 rgb(255 255 255 / 7%);
  --bar-shadow-short:
    0 0 0 1px rgb(0 0 0 / 80%), 0 1px 2px rgb(0 0 0 / 22%), 0 10px 18px -6px rgb(0 0 0 / 45%),
    inset 0 1px 0 rgb(255 255 255 / 7%);
  --divider: #0c0c0d;
  --divider-glint: rgb(255 255 255 / 5%);
  --well: #121213;
  --well-shade: rgb(0 0 0 / 70%);
  --well-glint: rgb(255 255 255 / 4%);
  --well-ink: #5c5c5e;
  --placeholder: #5f5f61;
  --kbd: #403f44;
  --kbd-ink: #939297;
  --kbd-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    /* The stage and its words. */
    --surface: light-dark(#f0f0ee, #151516);
    --rim: light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 6%));
    --ink: light-dark(#1d1d1f, #ececee);
    --ink-label: light-dark(#232325, #e4e4e6);
    --ink-muted: light-dark(#959492, #929295);
    --ink-note: light-dark(#969492, #929295);
    --ink-faint: light-dark(#9a9997, #88888b);
    --ink-faintest: light-dark(#c8c7c5, #7f7f82);
    --line: light-dark(#dddddb, #0b0b0c);
    --line-glint: light-dark(#fbfbf9, rgb(255 255 255 / 5%));
    /* The set's caps: the light toolbar's, or the dark toolbar's, though a dark one, sunk, leaves
       out the faint glint under its face: an outer shadow, even transparent in light, would widen
       what light paints and move its edges by a shade. */
    --surface-2: light-dark(#f0edec, #232325);
    --sheen: linear-gradient(
      light-dark(rgb(255 255 255 / 94%), rgb(255 255 255 / 3.5%)),
      rgb(255 255 255 / 0%)
    );
    --shadow:
      0 0 0 1px light-dark(rgb(0 0 0 / 7.5%), rgb(0 0 0 / 55%)),
      0 1px 1px light-dark(rgb(0 0 0 / 8%), transparent),
      0 2px 5px -1px light-dark(rgb(0 0 0 / 10%), transparent),
      0 2px 3px light-dark(transparent, rgb(0 0 0 / 45%)),
      inset 0 1px 0 light-dark(#fff, rgb(255 255 255 / 7%)),
      inset 0 0 0 2.5px light-dark(transparent, rgb(255 255 255 / 1.5%));
    --lift: light-dark(#fffffd, #2a2a2c);
    --pressed: light-dark(#e5e2e1, #141415);
    --pressed-shadow:
      inset 0 1.5px 2.5px light-dark(rgb(0 0 0 / 10%), transparent),
      inset 0 1px 2px light-dark(transparent, rgb(0 0 0 / 70%)),
      inset 0 0 0 1px light-dark(rgb(0 0 0 / 4%), transparent);
    --glyph-ink: light-dark(#39393b, #dcdcde);
  }
}
@supports (transition-timing-function: linear(0, 1)) {
  .root {
    --spring: var(--spring-curve);
  }
}
/* The colorScheme prop. Without it, nothing is set: the color-scheme is the page's. */
.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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(button, input, p, ul) {
  margin: 0;
  font: inherit;
  color: inherit;
}

/* A section's head: its name, a line of specifications in mono capitals, an etched hairline
   under. */
.head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line);
  box-shadow: 0 1px 0 var(--line-glint);
}
.name {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.005em;
}
.caption,
.count {
  font: 9.5px/1.5 var(--mono);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.count {
  margin: 22px 0 22px;
  color: var(--ink-faint);
}
.total {
  margin-left: 3px;
  color: var(--ink-faintest);
}

/* The toolbars, light over dark. In a stage narrower than a bar, its track scrolls sideways
   (see the container queries below). */
.bars {
  display: grid;
  justify-items: center;
  gap: 44px;
  margin: 44px 0 64px;
}
.track {
  max-width: 100%;
  scrollbar-width: none;
}
.track::-webkit-scrollbar {
  display: none;
}
.bar {
  display: flex;
  align-items: center;
  gap: 6px;
  width: max-content;
  padding: 7px;
  border-radius: 16px;
  background: linear-gradient(var(--bar-top), var(--bar-bottom));
  box-shadow: var(--bar-shadow);
}
/* On a dark page, the light toolbar keeps the light scheme for what the browser draws in its search
   field itself: the clear button would turn white on the light well. */
.bar[data-tone='light'] {
  color-scheme: light;
}
.divider {
  flex: none;
  width: 1.5px;
  height: 22px;
  margin: 0 3.25px;
  border-radius: 1px;
  background: var(--divider);
  box-shadow: 1px 0 0 var(--divider-glint);
}

/* A cap: a rounded key on its own small layer, raised at rest. Hovered, its face brightens (the
   colour under a light sheen, so the key repaints alone; a second face over every key cost a phone
   about 30 MB); pressed, or while its tool is in use, a sunk face fades in over it. */
.cap {
  --cap: 36px;
  --radius: 10.5px;
  --glyph: 16px;
  position: relative;
  flex: none;
  display: block;
  width: var(--cap);
  height: var(--cap);
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: none;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-user-select: none;
  user-select: none;
}
.cap:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.key {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: inherit;
  background: var(--sheen) var(--surface-2);
  box-shadow: var(--shadow);
  color: var(--glyph-ink);
  transform: scale(1);
  transition:
    transform 300ms var(--spring),
    background-color 180ms ease;
  will-change: transform;
}
.key::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--pressed);
  box-shadow: var(--pressed-shadow);
  opacity: 0;
  /* A box whose opacity reaches 1 or leaves it would gain or lose a layer of its own, and be laid
     out again: a transform at rest keeps it one. */
  transform: scale(1);
  transition: opacity 180ms ease;
}
.cap[data-hot] .key,
.cap:focus-visible .key {
  background-color: var(--lift);
}
.cap[aria-pressed='true'] .key::before,
.cap[data-down] .key::before {
  opacity: 1;
}
.cap[data-down] .key {
  transform: scale(0.94);
  transition-duration: 120ms;
}
/* The tool in use: a small light, top right. */
.light {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    var(--accent-ink) 0 1.2px,
    var(--accent) 1.8px 2.2px,
    var(--accent-glow) 3px,
    transparent 5.5px
  );
  opacity: 0;
  transform: scale(1);
  transition: opacity 200ms ease;
}
.cap[aria-pressed='true'] .light {
  opacity: 1;
}

/* The search field: an inset well, with its glyph, the field, and the shortcut's key. */
.search {
  position: relative;
  flex: none;
  display: flex;
  align-items: center;
  gap: 8px;
  width: 128px;
  height: 36px;
  padding: 0 6px 0 12px;
  border-radius: 10.5px;
  background: var(--well);
  box-shadow:
    inset 0 1px 1.5px var(--well-shade),
    0 1px 0 var(--well-glint);
  color: var(--well-ink);
  cursor: text;
  will-change: transform;
}
.search:focus-within {
  box-shadow:
    inset 0 1px 1.5px var(--well-shade),
    0 0 0 2px var(--focus);
}
[data-tone='dark'] > .search {
  box-shadow:
    inset 0 1px 2px var(--well-shade),
    0 1px 0 var(--well-glint);
}
/* The dark well's own shadow outranked the focus ring above; it needs one too. */
[data-tone='dark'] > .search:focus-within {
  box-shadow:
    inset 0 1px 2px var(--well-shade),
    0 0 0 2px var(--focus);
}
.search .glyph {
  --glyph: 12px;
}
.field {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  font-size: 13px;
  letter-spacing: -0.005em;
}
.field::placeholder {
  color: var(--placeholder);
  opacity: 1;
}
.kbd {
  flex: none;
  display: flex;
  align-items: center;
  height: 17px;
  padding: 0 4.5px;
  border-radius: 5px;
  background: var(--kbd);
  box-shadow: var(--kbd-shadow);
  font:
    500 9.5px/1 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    sans-serif;
  color: var(--kbd-ink);
}
/* The command sign sits smaller than the letter, as on a key. */
.cmd {
  margin-right: 0.5px;
  font-size: 8px;
}

/* The set: four columns of cells, a cap and its notes. */
.grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 36px 14px;
  padding: 0;
  list-style: none;
}
.cell {
  display: flex;
  align-items: center;
  gap: 15px;
  min-width: 0;
}
.cell .cap {
  --cap: 44px;
  --radius: 12.5px;
}
.notes {
  min-width: 0;
}
.label {
  display: block;
  margin-bottom: 3.5px;
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--ink-label);
}
.line {
  font-size: 11px;
  line-height: 14.2px;
  color: var(--ink-note);
}
.line + .line {
  margin-top: 2.5px;
}

/* Narrower than a bar: each toolbar scrolls sideways in its track, which clips: the bars cast a
   shorter shadow that stays inside it. */
@container icons (max-width: 659px) {
  .bars {
    gap: 0;
    margin: 6px 0 10px;
  }
  .bar {
    box-shadow: var(--bar-shadow-short);
  }
  .track {
    padding: 10px 14px 32px;
    margin: 0 -14px;
    max-width: calc(100% + 28px);
    overflow-x: auto;
    overscroll-behavior-x: contain;
  }
}
@container icons (max-width: 599px) {
  .grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@container icons (max-width: 399px) {
  .grid {
    grid-template-columns: minmax(0, 1fr);
    row-gap: 12px;
  }
  .cell .cap {
    --cap: 38px;
    --radius: 11px;
  }
}

/* A glyph: 24 grid units at the size of its box, its parts stacked in it. */
.glyph {
  position: relative;
  display: block;
  width: var(--glyph);
  height: var(--glyph);
}
.whole,
.pose,
.act {
  --still: translate(0, 0) rotate(0deg) scale(1, 1);
  position: absolute;
  inset: 0;
  display: block;
}
/* Every box that moves keeps a transform at rest: a box that gains or loses one is laid out
   again. */
.whole,
.act {
  transform: var(--still);
}
.drawing {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Each part's pose, at rest and while its cap is hovered (glyphs.tsx writes the variables). */
.pose {
  transform: var(--rest, var(--still));
  transform-origin: var(--at, 50% 50%);
  opacity: var(--rest-o, 1);
  clip-path: var(--rest-clip, none);
  transition:
    transform var(--ms, 360ms) var(--spring),
    opacity calc(var(--ms, 360ms) * 0.5) ease,
    clip-path var(--ms, 360ms) var(--spring);
}
:is([data-hot], :focus-visible) > .key .pose,
.search:is([data-hot], :focus-within) .pose {
  transform: var(--hover, var(--rest, var(--still)));
  opacity: var(--hover-o, var(--rest-o, 1));
  clip-path: var(--hover-clip, var(--rest-clip, none));
}
/* A caret blinks while its cap is hovered; a glint crosses a lens once. */
:is([data-hot], :focus-visible) > .key .act[data-loop='blink'] {
  animation: blink 1.06s step-end 0.32s infinite;
}
.act[data-loop='glint'] {
  opacity: 0;
}
:is([data-hot], :focus-visible) > .key .act[data-loop='glint'],
.search:is([data-hot], :focus-within) .act[data-loop='glint'] {
  animation: glint 640ms cubic-bezier(0.3, 0.6, 0.4, 1) 90ms both;
}
@keyframes blink {
  50% {
    opacity: 0;
  }
}
@keyframes glint {
  from {
    transform: translate(-15%, -15%);
    opacity: 0;
  }
  35% {
    opacity: 1;
  }
  to {
    transform: translate(19%, 19%);
    opacity: 0;
  }
}

/* The demo's pointer: an arrow, or a pointing hand over a cap. */
.pointer {
  z-index: 3;
}
.pointer [data-glyph='hand'],
.pointer[data-hand] [data-glyph='arrow'] {
  visibility: hidden;
}
.pointer[data-hand] [data-glyph='hand'] {
  visibility: visible;
}

.announce {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .key,
  .key::before,
  .light,
  .pose {
    transition: none;
  }
  .act[data-loop] {
    animation: none !important;
  }
}
```

### acting-icons/glyphs.tsx

```tsx
import type { CSSProperties, ReactNode } from 'react';
import { EASE_OUT, KICK } from './spring';
import styles from './acting-icons.module.css';

/*
 * The twelve glyphs and how each acts out its meaning. Every glyph is drawn on a 24 grid with a
 * 1.7 stroke, rounded, and split into parts: the arrow and the ring it sends out, the T and its
 * caret, the four corners of a frame. Each part is its own small SVG in two HTML boxes, one in the
 * other: the outer box takes the hover pose, the inner one plays the press. The drawings never
 * change; only the boxes move, with transforms, opacity and clip-path, so a browser repaints a few
 * small boxes at most and never lays an SVG out again while a glyph acts.
 *
 * - A hover pose is a CSS transition between two transforms the part keeps in custom properties
 *   (--rest, --hover), on the spring of spring.ts. The cap around the glyph switches it.
 * - A press is played by `pressGlyph`, with the Web Animations API: a kick, the spring pulled
 *   towards a pose and let go, which adds itself to whatever moves already; or a path through a
 *   few poses (a ring that leaves, lines written in), with an ease-out.
 */

/** A pose, on the 24 grid: moves in grid units, a turn in degrees, scales, opacity, a reveal. */
type Pose = {
  x?: number;
  y?: number;
  r?: number;
  /** Scales both ways; sx and sy scale across and down on top of it. */
  s?: number;
  sx?: number;
  sy?: number;
  o?: number;
  /** Shows the drawing up to this x only, as a pen would have left it. */
  reveal?: number;
  /** Where the pose falls in a path, from 0 to 1 (spread evenly when left out). */
  t?: number;
};

/** A press: a kick towards `kick` and back, or a path through `path`. Durations in ms. */
type Press = { ms: number; delay?: number } & ({ kick: Pose } | { path: Pose[] });

type Part = {
  /** The drawing, on the 24 grid. */
  draw: ReactNode;
  /** The point it turns and scales around, on the grid (its middle when left out). */
  at?: [number, number];
  /** Where the hover pose turns around, when not at `at`. */
  pivot?: [number, number];
  /** Its pose at rest, when it is not the drawing itself, and while hovered. */
  rest?: Pose;
  hover?: Pose;
  /** How long the move to its hover pose takes, in ms. */
  ms?: number;
  press?: Press;
  /** Hidden until a press shows it. */
  hidden?: boolean;
  /** Plays while hovered: a caret's blink, a glint's pass. */
  loop?: 'blink' | 'glint';
  /** Seen only inside this circle (cx, cy, r on the grid): the glass of a lens. */
  lens?: [number, number, number];
};

/** A glyph's parts, in the order they are drawn, and a press of the whole. */
type Acts = {
  parts: Part[];
  /** A press of the whole glyph, on top of its parts'. */
  press?: Press;
};

export type GlyphName =
  | 'select'
  | 'text'
  | 'note'
  | 'image'
  | 'link'
  | 'draw'
  | 'layout'
  | 'tidy'
  | 'search'
  | 'zoom-in'
  | 'zoom-out'
  | 'fit';

/** The soft fill some shapes carry under their stroke. */
const TINT = { fill: 'currentColor', fillOpacity: 0.2 } as const;

/** A link's drawings lie along the diagonal. */
const diagonal = (child: ReactNode) => <g transform="rotate(-45 12 12)">{child}</g>;

/** A lens and its handle, shared by the three magnifiers. */
const LENS: Part = {
  draw: <circle cx="10.4" cy="10.4" r="6.4" />,
  at: [10.4, 10.4],
};
const HANDLE: Part = { draw: <path d="M15.2 15.2 20.4 20.4" /> };

/** The content of Fit, drawn in nine pieces so that it can grow without its stroke growing. */
const GROW = 2.7;
const EDGE = 2.8;
const STRETCH = (EDGE + 2 * GROW) / EDGE;
const fitContent: Part[] = [
  {
    draw: <rect x="9.1" y="9.1" width="5.8" height="5.8" rx="1.5" stroke="none" {...TINT} />,
    hover: { s: (5.8 + 2 * GROW) / 5.8 },
  },
  ...(
    [
      ['M9.1 10.9v-.3a1.5 1.5 0 0 1 1.5-1.5h.3', -1, -1],
      ['M13.1 9.1h.3a1.5 1.5 0 0 1 1.5 1.5v.3', 1, -1],
      ['M14.9 13.1v.3a1.5 1.5 0 0 1-1.5 1.5h-.3', 1, 1],
      ['M10.9 14.9h-.3a1.5 1.5 0 0 1-1.5-1.5v-.3', -1, 1],
    ] as const
  ).map(([d, x, y]) => ({ draw: <path d={d} />, hover: { x: x * GROW, y: y * GROW } })),
  {
    draw: <path d="M10.6 9.1h2.8" strokeLinecap="butt" />,
    at: [12, 9.1],
    hover: { y: -GROW, sx: STRETCH },
  },
  {
    draw: <path d="M10.6 14.9h2.8" strokeLinecap="butt" />,
    at: [12, 14.9],
    hover: { y: GROW, sx: STRETCH },
  },
  {
    draw: <path d="M9.1 10.6v2.8" strokeLinecap="butt" />,
    at: [9.1, 12],
    hover: { x: -GROW, sy: STRETCH },
  },
  {
    draw: <path d="M14.9 10.6v2.8" strokeLinecap="butt" />,
    at: [14.9, 12],
    hover: { x: GROW, sy: STRETCH },
  },
];

const GLYPHS: Record<GlyphName, Acts> = {
  select: {
    parts: [
      {
        draw: <circle cx="5.3" cy="5.3" r="3" strokeWidth="1.3" />,
        at: [5.3, 5.3],
        hidden: true,
        press: {
          ms: 460,
          delay: 70,
          path: [
            { s: 0.3, o: 0.9 },
            { s: 2, o: 0 },
          ],
        },
      },
      {
        draw: <path d="M5.3 5.3 19.2 10.6 13.1 13.1 10.6 19.2z" {...TINT} />,
        at: [5.3, 5.3],
        hover: { r: -18, x: 0.4, y: 0.7 },
        ms: 380,
        press: { ms: 380, kick: { x: -0.9, y: -0.9, s: 0.86 } },
      },
    ],
  },
  text: {
    parts: [
      {
        draw: (
          <path d="M6 7.7C6 6 6.4 5.2 7.6 5.2h8.8c1.2 0 1.6.8 1.6 2.5M12 5.2v13.6m-2.8 0h5.6" />
        ),
        at: [12, 18.8],
        hover: { x: -3.2 },
        ms: 360,
        press: { ms: 340, kick: { y: 1.1, sx: 1.08, sy: 0.84 } },
      },
      {
        draw: <path d="M17.8 4.6v14.8" />,
        rest: { o: 0, sy: 0.5, x: -1 },
        hover: { o: 1 },
        ms: 260,
        loop: 'blink',
      },
    ],
  },
  note: {
    parts: [
      {
        draw: (
          <path d="M13.9 20.6H6.4a3.2 3.2 0 0 1-3.2-3.2V6.6a3.2 3.2 0 0 1 3.2-3.2h11.2a3.2 3.2 0 0 1 3.2 3.2v7.3" />
        ),
      },
      {
        draw: (
          <>
            <path d="M20.8 13.9 13.9 20.6V17a3 3 0 0 1 3-3z" stroke="none" {...TINT} />
            <path d="M20.8 13.9 13.9 20.6V17a3 3 0 0 1 3-3h3.9" />
          </>
        ),
        at: [20.8, 20.6],
        hover: { s: 1.28, r: -4 },
        ms: 420,
      },
      {
        draw: <path d="M7.6 8.4h8.4" />,
        press: { ms: 300, path: [{ reveal: 6.6 }, { reveal: 17 }] },
      },
      {
        draw: <path d="M7.6 12.1h2.8" />,
        press: { ms: 220, delay: 220, path: [{ reveal: 6.6 }, { reveal: 11.4 }] },
      },
    ],
  },
  image: {
    parts: [
      { draw: <rect x="2.8" y="4" width="18.4" height="16" rx="2.8" /> },
      {
        draw: (
          <>
            <path
              d="M2.8 17 8.4 11.2l5.2 5.2 2.4-2.4 5.2 3v.2a2.8 2.8 0 0 1-2.8 2.8H5.6a2.8 2.8 0 0 1-2.8-2.8z"
              stroke="none"
              {...TINT}
            />
            <path d="M2.8 17 8.4 11.2l5.2 5.2 2.4-2.4 5.2 3" />
          </>
        ),
      },
      {
        draw: (
          <path
            d="M17 7.6v-.7M19 9.6h.7M15 9.6h-.7m4.11-1.41.5-.5m-3.32.5-.5-.5"
            strokeWidth="1.2"
          />
        ),
        at: [17, 9.6],
        rest: { y: 1.6 },
        hover: { y: -1.8 },
        ms: 460,
        hidden: true,
        press: {
          ms: 460,
          path: [
            { s: 0.6, o: 0 },
            { s: 1, o: 1, t: 0.35 },
            { s: 1.3, o: 0 },
          ],
        },
      },
      {
        draw: <circle cx="17" cy="9.6" r="1.15" fill="currentColor" stroke="none" />,
        at: [17, 9.6],
        rest: { y: 1.6 },
        hover: { y: -1.8, s: 1.15 },
        ms: 460,
        press: { ms: 420, kick: { s: 1.9 } },
      },
    ],
    press: { ms: 440, kick: { s: 0.9 } },
  },
  link: {
    parts: [
      {
        draw: diagonal(<path d="M9.7 8.8H7.2a3.2 3.2 0 0 0 0 6.4h2.5" />),
        hover: { x: -1.8, y: 1.8 },
        ms: 380,
        press: { ms: 340, kick: { x: 1.2, y: -1.2 } },
      },
      {
        draw: diagonal(<path d="M14.3 8.8h2.5a3.2 3.2 0 0 1 0 6.4h-2.5" />),
        hover: { x: 1.8, y: -1.8 },
        ms: 380,
        press: { ms: 340, kick: { x: -1.2, y: 1.2 } },
      },
      {
        draw: diagonal(<path d="M9.2 12h5.6" />),
        hover: { s: 0.7 },
        ms: 380,
      },
    ],
  },
  draw: {
    parts: [
      {
        draw: <path d="M4.6 20.6q1.4-1.2 2.8 0t2.8 0" />,
        rest: { reveal: 3.7 },
        hover: { reveal: 11.1 },
        ms: 420,
      },
      {
        draw: <circle cx="5.6" cy="19.7" r="0.9" fill="currentColor" stroke="none" />,
        at: [5.6, 19.7],
        hover: { x: 4.4 },
        ms: 420,
        hidden: true,
        press: {
          ms: 380,
          delay: 60,
          path: [
            { s: 0.4, o: 0 },
            { s: 1.1, o: 1, t: 0.3 },
            { s: 1.4, o: 0 },
          ],
        },
      },
      {
        draw: (
          <>
            <path d="M13.76 6.56a2.6 2.6 0 0 1 3.68 3.68l-7.4 7.4-4.24.56.56-4.24z" {...TINT} />
            <path d="m11.78 8.54 3.68 3.68" />
          </>
        ),
        at: [5.8, 18.2],
        hover: { x: 4.4, r: -6 },
        ms: 420,
        press: { ms: 320, kick: { x: -0.5, y: 0.9, r: 5 } },
      },
    ],
  },
  layout: {
    parts: [
      {
        draw: <rect x="3.2" y="3.7" width="6.6" height="16.6" rx="2.6" {...TINT} />,
        hover: { x: 11 },
        ms: 460,
      },
      {
        draw: <rect x="13.6" y="3.7" width="7.2" height="6.9" rx="2.4" />,
        hover: { x: -10.4 },
        ms: 400,
      },
      {
        draw: <rect x="13.6" y="13.4" width="7.2" height="6.9" rx="2.4" />,
        hover: { x: -10.4 },
        ms: 440,
      },
    ],
    press: { ms: 380, kick: { sx: 0.84, sy: 0.94 } },
  },
  tidy: {
    parts: [
      {
        draw: <path d="M3.4 3.4v17.2" />,
        at: [3.4, 12],
        press: { ms: 360, kick: { sx: 1.6, sy: 1.12 } },
      },
      {
        draw: <rect x="7.6" y="3.9" width="12.4" height="3.4" rx="1.7" />,
        at: [7.6, 5.6],
        rest: { x: 0.7, y: -0.1, r: -5 },
        hover: { x: -1.4 },
        ms: 420,
        press: { ms: 300, delay: 60, kick: { x: -0.7 } },
      },
      {
        draw: <rect x="7.6" y="10.1" width="11" height="3.4" rx="1.7" />,
        rest: { x: 0.3 },
        hover: { x: -1.4 },
        ms: 360,
        press: { ms: 300, delay: 60, kick: { x: -0.7 } },
      },
      {
        draw: <rect x="7.6" y="15.5" width="10" height="3.4" rx="1.7" />,
        hover: { x: -1.4 },
        ms: 460,
        press: { ms: 300, delay: 60, kick: { x: -0.7 } },
      },
    ],
  },
  search: {
    parts: [
      {
        ...LENS,
        pivot: [20.4, 20.4],
        hover: { r: -12 },
        ms: 420,
        press: { ms: 400, kick: { s: 0.84 } },
      },
      { ...HANDLE, pivot: [20.4, 20.4], hover: { r: -12 }, ms: 420 },
      {
        draw: <path d="M7.3 10.2a3.2 3.2 0 0 1 2.9-2.9" strokeWidth="1.4" />,
        pivot: [20.4, 20.4],
        hover: { r: -12 },
        ms: 420,
        loop: 'glint',
        lens: [10.4, 10.4, 5.6],
      },
    ],
  },
  'zoom-in': {
    parts: [
      { ...LENS, press: { ms: 420, kick: { s: 1.16 } } },
      HANDLE,
      {
        draw: <path d="M10.4 7.7v5.4M7.7 10.4h5.4" />,
        at: [10.4, 10.4],
        hover: { r: 90 },
        ms: 440,
        press: { ms: 420, kick: { s: 1.16 } },
      },
    ],
  },
  'zoom-out': {
    parts: [
      { ...LENS, press: { ms: 420, kick: { s: 0.84 } } },
      HANDLE,
      {
        draw: <path d="M7.7 10.4h5.4" />,
        at: [10.4, 10.4],
        hover: { sx: 0.5 },
        ms: 260,
        press: { ms: 420, kick: { s: 0.84 } },
      },
    ],
  },
  fit: {
    parts: [
      ...(
        [
          ['M3.6 8.6V6.4a2.8 2.8 0 0 1 2.8-2.8h2.2', 1, 1],
          ['M15.4 3.6h2.2a2.8 2.8 0 0 1 2.8 2.8v2.2', -1, 1],
          ['M20.4 15.4v2.2a2.8 2.8 0 0 1-2.8 2.8h-2.2', -1, -1],
          ['M8.6 20.4H6.4a2.8 2.8 0 0 1-2.8-2.8v-2.2', 1, -1],
        ] as const
      ).map(([d, x, y]) => ({
        draw: <path d={d} />,
        press: { ms: 360, kick: { x: x * 1.1, y: y * 1.1 } },
      })),
      ...fitContent.map((part) => ({ ...part, ms: 440 })),
    ],
  },
};

/** One grid unit, as a share of the glyph's box (a percentage). */
const UNIT = 100 / 24;
const num = (n: number) => String(Math.round(n * 1000) / 1000);
const percent = (units: number) => `${num(units * UNIT)}%`;
const origin = ([x, y]: readonly [number, number]) => `${percent(x)} ${percent(y)}`;

/**
 * A pose as a transform, `k` of the way from rest (0) to the pose (1). Every pose writes the same
 * list of functions, so that a transition moves each of them on its own.
 */
const transformOf = (pose: Pose, k = 1) => {
  const s = pose.s ?? 1;
  const sx = 1 + (s * (pose.sx ?? 1) - 1) * k;
  const sy = 1 + (s * (pose.sy ?? 1) - 1) * k;
  return `translate(${percent((pose.x ?? 0) * k)}, ${percent((pose.y ?? 0) * k)}) rotate(${num((pose.r ?? 0) * k)}deg) scale(${num(sx)}, ${num(sy)})`;
};

/** The clip that shows a drawing up to `x` on the grid (with room for overshoot elsewhere). */
const clipTo = (x: number) => `inset(-25% ${percent(24 - x)} -25% -25%)`;

/** A part's custom properties: its origin, its poses, how long it takes. */
const partStyle = (part: Part) => {
  const style: Record<string, string> = {};
  const { rest, hover } = part;
  const at = part.pivot ?? part.at;
  if (at) style['--at'] = origin(at);
  if (part.ms) style['--ms'] = `${part.ms}ms`;
  if (rest) style['--rest'] = transformOf(rest);
  if (hover) style['--hover'] = transformOf(hover);
  if (rest?.o !== undefined) style['--rest-o'] = num(rest.o);
  if (hover?.o !== undefined) style['--hover-o'] = num(hover.o);
  if (rest?.reveal !== undefined) style['--rest-clip'] = clipTo(rest.reveal);
  if (hover?.reveal !== undefined) style['--hover-clip'] = clipTo(hover.reveal);
  if (part.lens) {
    const [cx, cy, r] = part.lens;
    style['--rest-clip'] = `circle(${percent(r)} at ${percent(cx)} ${percent(cy)})`;
  }
  return style as CSSProperties;
};

/** The inner box's origin, for its press. */
const actStyle = (part: Part): CSSProperties | undefined => {
  if (!part.at && !part.hidden) return undefined;
  return {
    transformOrigin: part.at ? origin(part.at) : undefined,
    opacity: part.hidden ? 0 : undefined,
  };
};

/**
 * A glyph, 24 grid units drawn at the size of its box (the stylesheet's --glyph). It takes its
 * hover pose while its cap is marked `data-hot` or focused from the keyboard (see the
 * stylesheet), and plays its press on `pressGlyph`.
 */
export function Glyph({ name }: { name: GlyphName }) {
  return (
    <span className={styles.glyph} data-glyph={name} aria-hidden="true">
      <span className={styles.whole} data-whole="">
        {GLYPHS[name].parts.map((part, index) => (
          <span key={index} className={styles.pose} style={partStyle(part)}>
            <span className={styles.act} data-act="" data-loop={part.loop} style={actStyle(part)}>
              <svg className={styles.drawing} viewBox="0 0 24 24">
                {part.draw}
              </svg>
            </span>
          </span>
        ))}
      </span>
    </span>
  );
}

/** The keyframes of a press. */
const framesOf = (press: Press): Keyframe[] => {
  if ('kick' in press) {
    return KICK.map((k, index) => ({
      offset: index / (KICK.length - 1),
      transform: transformOf(press.kick, k),
    }));
  }
  return press.path.map((pose) => ({
    offset: pose.t,
    transform: transformOf(pose),
    ...(pose.o === undefined ? null : { opacity: pose.o }),
    ...(pose.reveal === undefined ? null : { clipPath: clipTo(pose.reveal) }),
  }));
};

const play = (element: Element | null | undefined, press: Press) =>
  element?.animate(framesOf(press), {
    duration: press.ms,
    delay: press.delay ?? 0,
    // A kick is sampled already; a path flies out and slows down.
    easing: 'kick' in press ? 'linear' : EASE_OUT,
    // Kicks add up: a second press lands on the first. Paths start over.
    composite: 'kick' in press ? 'add' : 'replace',
    fill: 'backwards',
  });

/** Plays the press of a `Glyph` (its element). Under reduced motion, nothing. */
export function pressGlyph(glyph: Element | null) {
  if (!glyph || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const name = glyph.getAttribute('data-glyph') as GlyphName | null;
  if (!name || !(name in GLYPHS)) return;
  const { parts, press } = GLYPHS[name];
  const acts = glyph.querySelectorAll('[data-act]');
  parts.forEach((part, index) => {
    if (part.press) play(acts[index], part.press);
  });
  if (press) play(glyph.querySelector('[data-whole]'), press);
}
```

### acting-icons/spring.ts

```tsx
/*
 * The springs every glyph moves on. They share one damping ratio, 0.66: a move overshoots its
 * target by about 6% and settles, whatever its length. With the damping fixed, a spring's curve
 * is the same at every speed once its time is counted up to the moment it settles, so one curve,
 * sampled once, serves every duration from 180 to 460 ms:
 *
 * - SPRING, a CSS `linear()` easing, carries a glyph to its hover pose and back. A CSS transition
 *   starts again from wherever the glyph is when the pointer leaves halfway, so every move can be
 *   interrupted.
 * - KICK is a press: the spring is pulled towards a pose for a moment and let go, so it dips,
 *   comes back past its rest a little and settles. Presses play with the Web Animations API and
 *   add themselves to whatever already moves, so a second press lands on top of the first.
 */

/** The damping ratio of every spring: 1 would settle without overshooting. */
const DAMPING = 0.66;

const WOBBLE = Math.sqrt(1 - DAMPING * DAMPING);

/** A spring let go at 0 towards 1, after `t` (in units of 1 / its natural frequency). */
const step = (t: number) =>
  t <= 0
    ? 0
    : 1 -
      Math.exp(-DAMPING * t) * (Math.cos(WOBBLE * t) + (DAMPING / WOBBLE) * Math.sin(WOBBLE * t));

/** How long `curve` takes to stay within 0.4% of `target`, in the same units. */
const settling = (curve: (t: number) => number, target: number) => {
  let last = 0;
  for (let t = 0; t < 40; t += 0.01) if (Math.abs(target - curve(t)) > 0.004) last = t;
  return last;
};

const round = (n: number) => Math.round(n * 1000) / 1000;

/** Points sampled along each curve. */
const SAMPLES = 32;

const sample = (curve: (t: number) => number, end: number) =>
  Array.from({ length: SAMPLES + 1 }, (_, i) => curve((i / SAMPLES) * end));

/** The move to a new pose, as a CSS easing: from 0 to 1 over the transition's duration. */
export const SPRING = `linear(${sample(step, settling(step, 1))
  .map((n, i) => (i === SAMPLES ? 1 : round(n)))
  .join(', ')})`;

/** How long the press holds the spring towards its pose before letting go. */
const HOLD = 2.2;
const press = (t: number) => step(t) - step(t - HOLD);
const KICK_POINTS = sample(press, settling(press, 0));
const KICK_PEAK = Math.max(...KICK_POINTS);

/** A press, sampled: 0 at rest, 1 at the deepest point, a little below 0 on the way back. */
export const KICK = KICK_POINTS.map((n, i) => (i === SAMPLES ? 0 : round(n / KICK_PEAK)));

/** An easing for one-way flights (a ring that leaves, a glint): fast, then slowing down. */
export const EASE_OUT = 'cubic-bezier(0.2, 0.7, 0.3, 1)';
```

### acting-icons/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a cap, press it. 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,
 * `stopped` lets go of what the script held, and the pointer fades out.
 *
 * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, the
 * press scales a box that keeps a transform at rest (a box that gains or loses one is laid out
 * again), and the arrow and the hand are HTML boxes that swap by their visibility (a change of
 * style inside an SVG lays the SVG out again).
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /** Shows the pointing hand (over something to press) or the arrow. */
  hand: (on: boolean) => void;
};

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

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
  stopped: () => void,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.92 };

  /** 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();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    // The point in the stage's own px: the stage may sit in a scaled container.
    const frame = stage.getBoundingClientRect();
    const box = target.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const hand: Demo['hand'] = (on) => {
    if (on) pointer.dataset.hand = '';
    else delete pointer.dataset.hand;
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press, hand }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    hand(false);
    pointer.style.opacity = '0';
    stopped();
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses, on a layer of its own. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** Its colours, the stage's tokens (acting-icons.module.css): as the system's, in light and dark. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const PALM: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };
const CREASES: CSSProperties = { stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a cap. It is decorative:
 * the demo's effects show on the caps themselves.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            <path
              d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
              style={ARROW}
              strokeWidth="1.3"
              strokeLinejoin="round"
            />
          </svg>
        </span>
        <span data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The fingertip sits on the arrow's tip. */}
            <g transform="translate(-3 0.6)">
              <path
                d="M4.8 11.2V2.6a1.2 1.2 0 0 1 2.4 0V8a1.1 1.1 0 0 1 2.2 0v.4a1.05 1.05 0 0 1 2.1 0V9a1 1 0 0 1 2 0v4.4c0 2.4-1.4 4.2-3.6 4.2h-.7c-1.6 0-2.6-.7-3.5-2l-2.8-3.7a1 1 0 0 1 1.5-1.3z"
                style={PALM}
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M9.4 8.4v2.6M11.5 9v2M13.5 9.6v1.6"
                fill="none"
                style={CREASES}
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### acting-icons/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 };
}
```