# Toolbar morph

> A floating toolbar folded into three tools that unfolds into any of them (text tools, a voice recorder or a time range) and folds back.

A small pill with three round buttons for notes, voice and time. Press one and its grey disc stretches into the toolbar’s well while the pill grows around it and the tool’s controls slide in: formatting tools, a voice note that records with a live waveform, or a time range whose ends roll by a quarter of an hour. The cross folds it back.

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

Category: Motion.

## Anatomy

1. **Pill**: One box the size of the open bar, clipped to the folded pill and opened on a spring, 460 ms. Its shadow is two fixed ones that trade places.
2. **Well**: The open tool’s grey disc, stretched into the well that holds its controls; the other two discs fade.
3. **Elapsed time**: The recording’s length, in the accent with a dot beside it once there is one; the dot dims when paused. Read out as a timer.
4. **Waveform**: Dots before a recording; bars as it fills, one every 280 ms, the latest on the right.
5. **Discard**: Throws the recording away. Disabled until there is one.
6. **Pause**: Slides in once a recording starts; pauses it, then resumes it.
7. **Record and save**: Play starts a recording; while there is one it becomes Save and calls `onSave`.
8. **Fold**: The cross folds the bar back into the pill, as Escape does. A running recording pauses.

## When to use

- A floating toolbar over a canvas or a note, where three or four tools each need a row of controls.
- Tools used one at a time: the bar folds to a small pill and unfolds into the one in use.
- Short, direct controls: formatting toggles, a voice note, a start and an end time.
- Touch or pointer surfaces with little room, where a full toolbar would cover the work.

## When not to use

- Tools people combine at once, such as bold with a colour and a list. Use a flat toolbar that shows them together.
- More than three or four tools, or tools with long menus. Use a menu bar or a side panel.
- Precise times or dates: the spin buttons move by a quarter of an hour. Use a time field or a date picker.
- A real recorder: the voice tool here only simulates one. Wire `onSave` to a microphone first.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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
'use client';

import { ToolbarMorph } from './toolbar-morph';

export function NoteComposer() {
  return (
    <ToolbarMorph
      defaultStart="12:00"
      defaultEnd="12:30"
      maxSeconds={60}
      autoplay={false}
      onAction={(action) => console.log(action)}
      onSave={({ duration }) => console.log(duration)}
      onTimeChange={({ start, end }) => console.log(start, end)}
    />
  );
}
```

## States

- **Folded**: A small pill with three tools: notes, voice and time. The arrow keys move between them.
- **Notes**: Colour, font, size, then emoji, image and the two lists, then magic, in three groups.
- **List on**: The starred list pressed. The two lists are toggles, one at a time.
- **Ready to record**: An empty waveform of dots and 0:00; Discard waits for a recording.
- **Recording**: Held at 0:06 here; live, the timer runs and a bar comes every 280 ms.
- **Paused**: The bars stay; Pause turns to Resume and Save is ready. It pauses by itself at the limit.
- **Time**: A start, an arrow and an end with the length beside it. Each time moves by 15 minutes.
- **Longer range**: The length reads in hours and minutes, up to 12 hours. Moving the start keeps it.

## How it works

- Everything sits in a box the size of the unfolded toolbar. The pill, the three grey discs and each tool’s controls are boxes of that fixed size, opened and closed by a clip-path inset on a spring (a CSS transition with a linear() easing), so nothing is laid out or redrawn at every size it passes through.
- The pressed tool’s disc opens into the well that holds its controls while the other two fade. The controls show through the same clip and slide in one after another.
- A clip would cut the pill’s shadow, so the shadow is two fixed shadows, the folded pill’s and the unfolded one’s. The one that leaves goes at once; the one that comes waits for the pill to reach it.
- The voice note is simulated and opens no microphone. A seeded model of speech gives a level every 280 ms, words in bursts with silences between, and these fill the waveform’s dots with bars. The time is measured, pause and resume pick up where it stopped, and a recording stops at maxSeconds.
- The times are spin buttons: the arrow keys or the wheel move them by a quarter of an hour, the start takes the end along, and the digits roll up or down the way they went.
- Every colour is a light-dark() token on the root, so the toolbar follows the page’s color-scheme, or the colorScheme prop; the notes icon’s front card still fills with its disc’s colour through --behind.

## API reference

- **defaultTool** (`'notes' | 'voice' | 'time' | null`, default `null`): The tool open at first, or null for the folded bar.
- **defaultList** (`'numbered' | 'starred' | null`, default `null`): The notes list turned on at first. Setting one turns the demo off.
- **defaultRecording** (`{ phase: 'recording' | 'paused'; seconds: number }`): A voice note under way at first (with defaultTool 'voice'); a recording is held still there until the reader presses something. Turns the demo off.
- **defaultStart** (`string`, default `'12:00'`): When the time range starts, as “HH:MM” on a 24-hour clock.
- **defaultEnd** (`string`, default `'12:30'`): When it ends, as “HH:MM”.
- **maxSeconds** (`number`, default `60`): The longest voice note; the recording pauses there.
- **onAction** (`(action: NoteAction) => void`): Called when a notes tool is pressed: color, font, size, emoji, image, numbered, starred or magic.
- **onSave** (`(note: { duration: number; levels: number[] }) => void`): Called with a saved voice note: its length in ms and its levels from 0 to 1.
- **onTimeChange** (`(range: { start: string; end: string }) => void`): Called with the new range when a time moves.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer opens each tool in turn. Set it to false in a product.
- **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

- It is a labelled toolbar of labelled buttons. The arrow keys move along it and Home and End go to its ends. Unfolding a tool from the keyboard focuses its first control. Escape or the cross folds it and gives the focus back to that tool’s button.
- The numbered and starred lists are toggles with aria-pressed. The recorder’s buttons rename themselves with the state (“Start recording”, “Pause recording”, “Resume recording”, “Save the voice note”), and the elapsed time is a timer.
- The times are spin buttons whose value is spoken as a time. The arrow keys move them by a quarter of an hour, Page Up and Page Down by an hour, and the wheel works too. Every change is announced (“Ends at 13:00, 1 hour.”, “Recording paused at 0:05.”).
- Under reduced motion every state swaps at once and the demo does not play.

## Source

### toolbar-morph/toolbar-morph.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentType,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { GhostPointer, runDemo } from './ghost-pointer';
import {
  ArrowIcon,
  ClockIcon,
  CloseIcon,
  ColorIcon,
  EmojiIcon,
  FontIcon,
  ImageIcon,
  MagicIcon,
  NotesIcon,
  NumberedIcon,
  PauseIcon,
  PlayIcon,
  ResumeIcon,
  SaveIcon,
  ScheduleIcon,
  SizeIcon,
  StarredIcon,
  TrashIcon,
  VoiceIcon,
} from './icons';
import { clock, SAMPLE_MS, voice } from './recorder';
import { spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './toolbar-morph.module.css';

/*
 * Toolbar morph: a floating toolbar that folds into three tools and unfolds into any of them.
 *
 * - Folded, it is a small white pill with three round buttons: notes, voice and time. Pressed, a
 *   button's grey disc stretches into the toolbar's grey well while the pill grows around it, the
 *   other two discs fade, and the tool's controls slide in one after another. The cross folds it
 *   back the same way. The pill, the discs and the controls are boxes of a fixed size, opened and
 *   closed by a clip on a spring (CSS transitions), so nothing is redrawn at every size it passes
 *   through; the pill's shadow, which a clip would cut, is two fixed shadows that trade places.
 * - Notes: text colour, font, size, emoji, image, numbered and starred lists, and magic.
 * - Voice: a recording, simulated (no microphone is opened). Play starts it: the timer runs and
 *   the waveform fills with bars as a voice would; pause, save and the bin behave as they would.
 * - Time: a start and an end, each a spin button that the arrow keys or the wheel move by a
 *   quarter of an hour. Moving the start keeps the length; moving the end changes it.
 * - The arrow keys move along the toolbar, Escape folds it, and every change is announced. While
 *   on screen and untouched, a ghost pointer opens each tool in turn.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

type Tool = 'notes' | 'voice' | 'time';
type Phase = 'ready' | 'recording' | 'paused';
type Edge = 'start' | 'end';
type Icon = ComponentType<{ className?: string }>;

/** A press on one of the notes tools. */
export type NoteAction =
  'color' | 'font' | 'size' | 'emoji' | 'image' | 'numbered' | 'starred' | 'magic';

const TOOLS: { id: Tool; label: string; Icon: Icon }[] = [
  { id: 'notes', label: 'Text tools', Icon: NotesIcon },
  { id: 'voice', label: 'Voice note', Icon: VoiceIcon },
  { id: 'time', label: 'Time', Icon: ScheduleIcon },
];

/** The notes tools, in their groups. The lists are toggles, one at a time. */
const NOTE_GROUPS: { id: NoteAction; label: string; Icon: Icon }[][] = [
  [
    { id: 'color', label: 'Text colour', Icon: ColorIcon },
    { id: 'font', label: 'Font', Icon: FontIcon },
    { id: 'size', label: 'Text size', Icon: SizeIcon },
  ],
  [
    { id: 'emoji', label: 'Emoji', Icon: EmojiIcon },
    { id: 'image', label: 'Image', Icon: ImageIcon },
    { id: 'numbered', label: 'Numbered list', Icon: NumberedIcon },
    { id: 'starred', label: 'Starred list', Icon: StarredIcon },
  ],
  [{ id: 'magic', label: 'Magic', Icon: MagicIcon }],
];

/** The slots of the waveform: dots before a recording, bars as it fills. */
const SLOTS = 34;
/** The slots that show while recording, when the pause button takes some of the room. */
const RECORDING_SLOTS = 27;

/** A quarter of an hour: one step of a time. */
const STEP = 15;
const DAY = 24 * 60;

/** The unfolding and folding: a spring with a little bounce. */
const MORPH = spring(0.16, 460);

/** "12:30" to minutes after midnight. */
const minutesOf = (text: string, fallback: number) => {
  const match = /^(\d{1,2}):(\d{2})$/.exec(text);
  return match && +match[1] < 24 && +match[2] < 60 ? +match[1] * 60 + +match[2] : fallback;
};
const timeLabel = (minutes: number) =>
  `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
/** "30min", "1h", "1h 15min". */
const lengthLabel = (minutes: number) => {
  const hours = Math.floor(minutes / 60);
  const rest = minutes % 60;
  return hours ? `${hours}h${rest ? ` ${rest}min` : ''}` : `${rest}min`;
};
const spokenLength = (minutes: number) => {
  const hours = Math.floor(minutes / 60);
  const rest = minutes % 60;
  const parts = [];
  if (hours) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
  if (rest) parts.push(`${rest} minutes`);
  return parts.join(' ');
};

/** A voice note under way at first: recording (held still) or paused, this many seconds in. */
export type ToolbarMorphRecording = { phase: 'recording' | 'paused'; seconds: number };

/** The levels of the first `seconds` of the simulated voice. */
const levelsOf = (seconds: number) => {
  const next = voice(7);
  return Array.from({ length: Math.floor((seconds * 1000) / SAMPLE_MS) }, next);
};

export function ToolbarMorph({
  defaultTool = null,
  defaultList = null,
  defaultRecording,
  defaultStart = '12:00',
  defaultEnd = '12:30',
  maxSeconds = 60,
  onAction,
  onSave,
  onTimeChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The tool open at first, or null for the folded bar. */
  defaultTool?: Tool | null;
  /** The notes list turned on at first; setting one turns the demo off. */
  defaultList?: 'numbered' | 'starred' | null;
  /**
   * A voice note under way at first (open the voice tool with defaultTool="voice"): a recording
   * is held still at that time until the reader presses something. Turns the demo off.
   */
  defaultRecording?: ToolbarMorphRecording;
  /** When the time range starts, as "HH:MM" on a 24-hour clock. */
  defaultStart?: string;
  /** When it ends, as "HH:MM". */
  defaultEnd?: string;
  /** The longest voice note, in seconds: the recording pauses there. */
  maxSeconds?: number;
  /** Called when one of the notes tools is pressed. */
  onAction?: (action: NoteAction) => void;
  /** Called with a saved voice note: its length in ms and its levels, 0 to 1. */
  onSave?: (note: { duration: number; levels: number[] }) => void;
  /** Called with the new range when a time moves, as "HH:MM" strings. */
  onTimeChange?: (range: { start: string; end: string }) => void;
  /** While on screen and untouched, a ghost pointer opens each tool in turn. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [open, setOpen] = useState<Tool | null>(defaultTool);
  const [list, setList] = useState<'numbered' | 'starred' | null>(defaultList);
  const [phase, setPhase] = useState<Phase>(defaultRecording?.phase ?? 'ready');
  /** The recording so far: its length in ms, and a level per sample. */
  const [note, setNote] = useState<{ ms: number; levels: number[] }>(() =>
    defaultRecording
      ? {
          ms: Math.min(maxSeconds, defaultRecording.seconds) * 1000,
          levels: levelsOf(Math.min(maxSeconds, defaultRecording.seconds)),
        }
      : { ms: 0, levels: [] },
  );
  /** A recording opened by defaultRecording stays still until the reader presses something. */
  const [held, setHeld] = useState(defaultRecording?.phase === 'recording');
  const [range, setRange] = useState(() => {
    const start = minutesOf(defaultStart, 12 * 60);
    const end = minutesOf(defaultEnd, start + 30);
    return { start, length: Math.max(STEP, (end - start + DAY) % DAY) };
  });
  /** The last time that moved, and which way, for its digits to roll. */
  const [rolled, setRolled] = useState<{ edge: Edge; up: boolean; count: number } | null>(null);
  /** The control the demo's pointer is over. */
  const [hovered, setHovered] = useState<string | null>(null);
  const [message, setMessage] = useState('');

  const dock = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** True while the demo presses something: its changes are not announced. */
  const demoActing = useRef(false);
  /** Where the focus goes once a change made from the keyboard has rendered. */
  const focusNext = useRef<string | null>(null);
  const wheel = useRef(0);

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

  // The focus, once the toolbar has unfolded or folded.
  useLayoutEffect(() => {
    const target = focusNext.current;
    focusNext.current = null;
    if (target) dock.current?.querySelector<HTMLElement>(target)?.focus();
  });

  const unfold = (tool: Tool, byKeyboard: boolean) => {
    setOpen(tool);
    if (byKeyboard && !demoActing.current)
      focusNext.current = `[data-panel="${tool}"] [data-first]`;
    const label = TOOLS.find((entry) => entry.id === tool)?.label ?? '';
    announce(tool === 'voice' ? `${label}, ready to record.` : `${label} open.`);
  };

  const fold = (byKeyboard: boolean) => {
    if (!open) return;
    setOpen(null);
    if (byKeyboard && !demoActing.current) focusNext.current = `[data-tool="${open}"]`;
    // A recording does not run on unseen.
    if (phase === 'recording') {
      setHeld(false);
      setPhase('paused');
      announce(`Toolbar folded, recording paused at ${clock(note.ms)}.`);
    } else announce('Toolbar folded.');
  };

  // The recording: a sample every SAMPLE_MS, the time measured, up to the longest note.
  const recorded = useEffectEvent(() => note.ms);
  const record = useEffectEvent((elapsed: number, level: number) => {
    const limit = maxSeconds * 1000;
    setNote((now) => ({ ms: Math.min(limit, elapsed), levels: [...now.levels, level] }));
    if (elapsed >= limit) {
      setPhase('paused');
      announce(`Recording paused at the ${clock(limit)} limit.`);
    }
  });
  useEffect(() => {
    if (phase !== 'recording' || held) return;
    // A run picks up where the last one paused.
    const base = recorded();
    const next = voice(7 + Math.floor(base / SAMPLE_MS));
    const startedAt = performance.now();
    const timer = window.setInterval(() => {
      record(base + Math.max(0, performance.now() - startedAt), next());
    }, SAMPLE_MS);
    return () => window.clearInterval(timer);
  }, [phase, held]);

  const startRecording = () => {
    setHeld(false);
    setPhase('recording');
    announce(phase === 'paused' ? 'Recording.' : 'Recording started.');
  };
  const pauseRecording = () => {
    setHeld(false);
    setPhase('paused');
    announce(`Recording paused at ${clock(note.ms)}.`);
  };
  const saveRecording = () => {
    setHeld(false);
    onSave?.({ duration: note.ms, levels: note.levels });
    announce(`Voice note saved, ${clock(note.ms)} long.`);
    setPhase('ready');
    setNote({ ms: 0, levels: [] });
  };
  const discard = () => {
    if (phase === 'ready') return;
    setHeld(false);
    setPhase('ready');
    setNote({ ms: 0, levels: [] });
    announce('Recording discarded.');
  };

  /** Moves a time by `steps` quarters of an hour; the start takes the end along. */
  const nudge = (edge: Edge, steps: number) => {
    if (!steps) return;
    const next =
      edge === 'start'
        ? { ...range, start: (((range.start + steps * STEP) % DAY) + DAY) % DAY }
        : { ...range, length: Math.min(12 * 60, Math.max(STEP, range.length + steps * STEP)) };
    if (next.start === range.start && next.length === range.length) return;
    setRange(next);
    setRolled((now) => ({ edge, up: steps > 0, count: (now?.count ?? 0) + 1 }));
    const end = (next.start + next.length) % DAY;
    onTimeChange?.({ start: timeLabel(next.start), end: timeLabel(end) });
    announce(
      edge === 'start'
        ? `Starts at ${timeLabel(next.start)}, ends at ${timeLabel(end)}.`
        : `Ends at ${timeLabel(end)}, ${spokenLength(next.length)}.`,
    );
  };

  // The wheel moves a time: one step for each notch, the page does not scroll meanwhile.
  const wheeled = useEffectEvent((event: WheelEvent, edge: Edge) => {
    event.preventDefault();
    take();
    wheel.current += event.deltaMode === 0 ? event.deltaY : event.deltaY * 40;
    const steps = Math.trunc(wheel.current / 40);
    if (!steps) return;
    wheel.current -= steps * 40;
    nudge(edge, -steps);
  });
  useEffect(() => {
    const spinners = dock.current?.querySelectorAll<HTMLElement>('[role="spinbutton"]');
    if (!spinners) return;
    const listeners = Array.from(spinners, (element) => {
      const listener = (event: WheelEvent) => wheeled(event, element.dataset.edge as Edge);
      element.addEventListener('wheel', listener, { passive: false });
      return () => element.removeEventListener('wheel', listener);
    });
    return () => listeners.forEach((remove) => remove());
  }, []);

  const onSpinKey = (event: KeyboardEvent<HTMLElement>, edge: Edge) => {
    const steps: Record<string, number> = { ArrowUp: 1, ArrowDown: -1, PageUp: 4, PageDown: -4 };
    if (!(event.key in steps)) return;
    event.preventDefault();
    nudge(edge, steps[event.key]);
  };

  // The arrow keys move along the toolbar; Escape folds it.
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape') {
      if (!open) return;
      event.preventDefault();
      fold(true);
      return;
    }
    const moves: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 };
    if (!(event.key in moves) && event.key !== 'Home' && event.key !== 'End') return;
    const stops = Array.from(
      event.currentTarget.querySelectorAll<HTMLElement>('[data-stop]'),
    ).filter((element) => !element.closest('[inert]'));
    const at = stops.indexOf(document.activeElement as HTMLElement);
    if (at < 0) return;
    event.preventDefault();
    const next =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? stops.length - 1
          : (at + moves[event.key] + stops.length) % stops.length;
    stops[next].focus();
  };

  // The demo: each tool in turn, then folded again.
  const demo = autoplay && playing && !defaultList && !defaultRecording;
  const isOpen = useEffectEvent(() => open !== null);
  const demoNudge = useEffectEvent((edge: Edge, steps: number) => nudge(edge, steps));
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    const box = dock.current;
    if (!demo || !stage || !ghost || !box) return;
    const find = (selector: string) => box.querySelector<HTMLElement>(selector);
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      /** Glides onto a control and shows it hovered. */
      const visit = async (selector: string, ms = 0) => {
        await moveTo(find(selector), { shape: 'hand' });
        setHovered(selector);
        if (ms) await wait(ms);
      };
      /** Presses the control under the pointer, as the demo. */
      const tap = (selector: string) =>
        press(() => {
          demoActing.current = true;
          find(selector)?.click();
          demoActing.current = false;
        });
      for (let round = 0; ; round += 1) {
        await wait(round ? 900 : 700);
        if (isOpen()) {
          await visit('[data-close]', 200);
          await tap('[data-close]');
          await wait(700);
        }
        // Notes: unfold, try a list, fold.
        await visit('[data-tool="notes"]', 250);
        await tap('[data-tool="notes"]');
        setHovered(null);
        await wait(900);
        await visit('[data-action="font"]', 380);
        await visit('[data-action="emoji"]', 380);
        await visit(`[data-action="${round % 2 ? 'numbered' : 'starred'}"]`, 300);
        await tap(`[data-action="${round % 2 ? 'numbered' : 'starred'}"]`);
        await wait(700);
        await visit('[data-close]', 250);
        await tap('[data-close]');
        setHovered(null);
        await wait(850);

        // Voice: record a few seconds, pause, save.
        await visit('[data-tool="voice"]', 250);
        await tap('[data-tool="voice"]');
        setHovered(null);
        await wait(900);
        await visit('[data-record]', 300);
        await tap('[data-record]');
        setHovered(null);
        await wait(4300);
        await visit('[data-pause]', 300);
        await tap('[data-pause]');
        await wait(800);
        await visit('[data-record]', 300);
        await tap('[data-record]');
        setHovered(null);
        await wait(900);
        await visit('[data-close]', 250);
        await tap('[data-close]');
        setHovered(null);
        await wait(850);

        // Time: the end moves on by half an hour, then back.
        await visit('[data-tool="time"]', 250);
        await tap('[data-tool="time"]');
        setHovered(null);
        await wait(900);
        await visit('[data-edge="end"]', 350);
        for (let i = 0; i < 2; i += 1) {
          demoNudge('end', round % 2 ? -1 : 1);
          await wait(450);
        }
        setHovered(null);
        await wait(600);
        await visit('[data-close]', 250);
        await tap('[data-close]');
        setHovered(null);
        await wait(400);
        await moveTo(
          { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.88 },
          { shape: 'arrow' },
        );
      }
    });
    return () => {
      stop();
      setHovered(null);
    };
  }, [demo, root]);

  const recording = phase !== 'ready';
  const end = (range.start + range.length) % DAY;
  /** The waveform: the recorded levels, the latest last, in the slots that show. */
  const shown = note.levels.slice(-RECORDING_SLOTS);
  const hover = (selector: string) => hovered === selector || undefined;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': MORPH.easing, '--spring-ms': `${MORPH.duration}ms` } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div
        ref={dock}
        className={styles.dock}
        data-open={open ?? undefined}
        role="toolbar"
        aria-label="Note tools"
        onKeyDown={onKeyDown}
      >
        <span className={styles.shadow} data-shadow="folded" aria-hidden="true" />
        <span className={styles.shadow} data-shadow="open" aria-hidden="true" />
        <span className={styles.shape} data-part="pill" aria-hidden="true" />
        {TOOLS.map(({ id }) => (
          <span key={id} className={styles.well} data-well={id} aria-hidden="true" />
        ))}

        {/* Folded: the three tools. */}
        <div inert={!!open}>
          {TOOLS.map(({ id, label, Icon }, i) => (
            <button
              key={id}
              type="button"
              className={styles.tool}
              data-tool={id}
              data-stop=""
              data-hover={hover(`[data-tool="${id}"]`)}
              aria-label={label}
              tabIndex={i === 0 ? 0 : -1}
              onClick={(event) => unfold(id, event.detail === 0)}
            >
              <Icon className={styles.icon} />
            </button>
          ))}
        </div>

        {/* Notes. */}
        <div
          className={styles.panel}
          data-panel="notes"
          data-shown={open === 'notes' || undefined}
          inert={open !== 'notes'}
        >
          {NOTE_GROUPS.map((group, g) => (
            <div
              key={g}
              className={styles.group}
              data-part="group"
              style={{ '--i': g * 4 - 0.5 } as CSSProperties}
            >
              {group.map(({ id, label, Icon }, i) => {
                const toggle = id === 'numbered' || id === 'starred';
                return (
                  <button
                    key={id}
                    type="button"
                    className={`${styles.item} ${styles.button}`}
                    style={{ '--i': g * 4 + i } as CSSProperties}
                    data-action={id}
                    data-stop=""
                    data-first={(g === 0 && i === 0) || undefined}
                    data-hover={hover(`[data-action="${id}"]`)}
                    aria-label={label}
                    aria-pressed={toggle ? list === id : undefined}
                    tabIndex={g === 0 && i === 0 ? 0 : -1}
                    onClick={() => {
                      onAction?.(id);
                      if (!toggle) return;
                      const on = list !== id;
                      setList(on ? id : null);
                      announce(`${label} ${on ? 'on' : 'off'}.`);
                    }}
                  >
                    <Icon className={styles.icon} />
                  </button>
                );
              })}
            </div>
          ))}
        </div>

        {/* Voice. */}
        <div
          className={styles.panel}
          data-panel="voice"
          data-shown={open === 'voice' || undefined}
          data-phase={phase}
          inert={open !== 'voice'}
        >
          <span
            className={`${styles.item} ${styles.elapsed}`}
            data-part="elapsed"
            style={{ '--i': 0 } as CSSProperties}
          >
            <span className={styles.recordingDot} aria-hidden="true" />
            <span className={styles.digits} role="timer" aria-label={`Recorded ${clock(note.ms)}`}>
              {clock(note.ms)}
            </span>
          </span>
          <span
            className={`${styles.item} ${styles.track}`}
            style={{ '--i': 1 } as CSSProperties}
            aria-hidden="true"
          >
            <span className={styles.slots} data-part="waveform">
              {Array.from({ length: SLOTS }, (_, i) => {
                const level = i < shown.length ? shown[i] : null;
                return (
                  <i
                    key={i}
                    data-level={level === null ? undefined : ''}
                    style={level === null ? undefined : ({ '--level': level } as CSSProperties)}
                  />
                );
              })}
            </span>
          </span>
          <span className={styles.voiceButtons}>
            <button
              type="button"
              className={`${styles.item} ${styles.button} ${styles.round}`}
              style={{ '--i': 2 } as CSSProperties}
              data-stop=""
              data-hover={hover('[data-trash]')}
              data-trash=""
              aria-label="Discard the recording"
              aria-disabled={!recording}
              tabIndex={-1}
              onClick={discard}
            >
              <TrashIcon className={styles.icon} />
            </button>
            <span className={styles.pauseSlot}>
              <button
                type="button"
                className={`${styles.button} ${styles.round}`}
                data-stop=""
                data-pause=""
                data-hover={hover('[data-pause]')}
                aria-label={phase === 'paused' ? 'Resume recording' : 'Pause recording'}
                tabIndex={-1}
                inert={!recording}
                onClick={phase === 'paused' ? startRecording : pauseRecording}
              >
                <PauseIcon className={`${styles.icon} ${styles.pauseIcon}`} />
                <ResumeIcon className={`${styles.icon} ${styles.resumeIcon}`} />
              </button>
            </span>
            <button
              type="button"
              className={`${styles.item} ${styles.button} ${styles.round} ${styles.primary}`}
              style={{ '--i': 4 } as CSSProperties}
              data-stop=""
              data-first=""
              data-record=""
              data-hover={hover('[data-record]')}
              aria-label={recording ? 'Save the voice note' : 'Start recording'}
              tabIndex={0}
              onClick={recording ? saveRecording : startRecording}
            >
              <PlayIcon className={`${styles.icon} ${styles.playIcon}`} />
              <SaveIcon className={`${styles.icon} ${styles.saveIcon}`} />
            </button>
          </span>
        </div>

        {/* Time. */}
        <div
          className={styles.panel}
          data-panel="time"
          data-shown={open === 'time' || undefined}
          inert={open !== 'time'}
        >
          <span className={`${styles.item} ${styles.from}`} style={{ '--i': 0 } as CSSProperties}>
            <ClockIcon className={styles.clock} />
            <Spinner
              edge="start"
              label="Start"
              value={range.start}
              rolled={rolled}
              hovered={hovered === '[data-edge="start"]'}
              onKeyDown={onSpinKey}
            />
          </span>
          <span className={`${styles.item} ${styles.arrow}`} style={{ '--i': 1 } as CSSProperties}>
            <ArrowIcon />
          </span>
          <span className={`${styles.item} ${styles.to}`} style={{ '--i': 2 } as CSSProperties}>
            <Spinner
              edge="end"
              label="End"
              value={end}
              rolled={rolled}
              hovered={hovered === '[data-edge="end"]'}
              onKeyDown={onSpinKey}
            />
            <span className={styles.length} data-part="length" aria-hidden="true">
              {lengthLabel(range.length)}
            </span>
          </span>
        </div>

        <button
          type="button"
          className={`${styles.button} ${styles.round} ${styles.close}`}
          data-close=""
          data-stop=""
          data-hover={hover('[data-close]')}
          aria-label="Fold the toolbar"
          tabIndex={-1}
          inert={!open}
          onClick={(event) => fold(event.detail === 0)}
        >
          <CloseIcon className={styles.icon} />
        </button>
      </div>
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A time the arrow keys and the wheel move; its digits roll the way it went. */
function Spinner({
  edge,
  label,
  value,
  rolled,
  hovered,
  onKeyDown,
}: {
  edge: Edge;
  label: string;
  value: number;
  rolled: { edge: Edge; up: boolean; count: number } | null;
  hovered: boolean;
  onKeyDown: (event: KeyboardEvent<HTMLElement>, edge: Edge) => void;
}) {
  const roll = rolled?.edge === edge ? rolled : null;
  return (
    <span
      className={styles.spinner}
      role="spinbutton"
      data-edge={edge}
      data-stop=""
      data-first={edge === 'start' || undefined}
      data-hover={hovered || undefined}
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={DAY - 1}
      aria-valuenow={value}
      aria-valuetext={timeLabel(value)}
      tabIndex={edge === 'start' ? 0 : -1}
      onKeyDown={(event) => onKeyDown(event, edge)}
    >
      <span
        key={roll ? roll.count : 'still'}
        className={styles.value}
        data-roll={roll ? (roll.up ? 'up' : 'down') : undefined}
      >
        {timeLabel(value)}
      </span>
    </span>
  );
}
```

### toolbar-morph/toolbar-morph.module.css

```css
/* Toolbar morph (toolbar-morph.tsx): a light grey stage with faint diagonal lines, the toolbar in
   its middle; at night a near-black stage and a dark grey toolbar, its discs a step lighter. The
   dock is the unfolded toolbar's box, 394 × 64; the folded pill, its discs and the unfolded well
   are laid out inside it, and the dock's data-open attribute moves them on a spring (--spring,
   --spring-ms). */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The stage and its lines; the pill; the discs and the well; the voice tools' round buttons. */
  --surface: #f3f3f3;
  --hatch: rgb(0 0 0 / 0.032);
  --surface-2: #fff;
  --well: #f8f8f8;
  --button: #ededed;
  /* Times and pressed icons; icons; the elapsed time; the length; the clock and the arrow. */
  --ink: #111;
  --icon: #5c5c5f;
  --ink-muted: #5a5a5e;
  --ink-faint: #a3a3a3;
  --clock: #a8a8a8;
  --arrow: #a6a6a6;
  /* The stage's edge; the rules between the notes groups; the waveform's empty slots, then
     while recording. */
  --line: rgb(0 0 0 / 0.06);
  --rule: #d6d6d6;
  --slot: #d7d7d7;
  --slot-recording: #cdcee0;
  /* A button under the pointer, pressed; a time under it; a round button and the cross under it. */
  --hover: rgb(0 0 0 / 0.045);
  --pressed: rgb(0 0 0 / 0.07);
  --spinner-hover: rgb(0 0 0 / 0.04);
  --button-hover: #e4e4e4;
  --close-hover: #efefef;
  /* The recording, the waveform's bars and the focus ring; the record button, under the pointer,
     and its icon. */
  --accent: #3656e3;
  --primary: #3656e3;
  --primary-hover: #2c4bd6;
  --accent-ink: #fff;
  /* The pill's soft drop and the shade right under it. */
  --shadow: rgb(0 0 0 / 0.1);
  --shadow-near: rgb(0 0 0 / 0.04);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  min-height: 400px;
  border-radius: 18px;
  background:
    repeating-linear-gradient(135deg, var(--hatch) 0 1.5px, transparent 1.5px 4px), var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--icon);
  font:
    400 16px/20px Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f3f3f3, #111113);
    --hatch: light-dark(rgb(0 0 0 / 0.032), rgb(255 255 255 / 0.03));
    --surface-2: light-dark(#fff, #222224);
    --well: light-dark(#f8f8f8, #2f2f32);
    --button: light-dark(#ededed, #3a3a3d);
    --ink: light-dark(#111, #f2f2f2);
    --icon: light-dark(#5c5c5f, #a9a9ad);
    --ink-muted: light-dark(#5a5a5e, #a1a1a6);
    --ink-faint: light-dark(#a3a3a3, #98989d);
    --clock: light-dark(#a8a8a8, #8e8e93);
    --arrow: light-dark(#a6a6a6, #8e8e93);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --rule: light-dark(#d6d6d6, #48484b);
    --slot: light-dark(#d7d7d7, #4a4a4e);
    --slot-recording: light-dark(#cdcee0, #4b4f6e);
    --hover: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.07));
    --pressed: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.11));
    --spinner-hover: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07));
    --button-hover: light-dark(#e4e4e4, #444448);
    --close-hover: light-dark(#efefef, #3a3a3d);
    --accent: light-dark(#3656e3, #7b96ff);
    --primary: light-dark(#3656e3, #4562ee);
    --primary-hover: light-dark(#2c4bd6, #5672f2);
    --accent-ink: light-dark(#fff, #fff);
    --shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
  }
}

/* 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 unfolded toolbar's box. On a narrow stage it scales down as a whole, and its margins take
   back the room it no longer covers. */
.dock {
  --fit: min(1, tan(atan2(calc(100cqw - 24px), 394px)));
  position: relative;
  width: 394px;
  height: 64px;
  margin: calc(32px * (var(--fit) - 1)) calc(197px * (var(--fit) - 1));
  scale: var(--fit);
}

/* The white pill (dark grey at night): a box the size of the unfolded toolbar, clipped to the
   folded pill and opened to the whole on a spring. Its size never changes, so nothing is laid out
   or redrawn bigger as it moves; only the clip does. */
.shape {
  position: absolute;
  inset: 0;
  border-radius: 32px;
  background: var(--surface-2);
  clip-path: inset(8px 133px 8px 133px round 24px);
  transition: clip-path var(--spring-ms) var(--spring);
}
.dock[data-open] .shape {
  clip-path: inset(0 round 32px);
}
/* Its shadow, which a clip would cut away: the folded pill's and the unfolded one's, each fixed.
   The one that leaves goes at once; the one that comes waits for the pill to reach it. */
.shadow {
  position: absolute;
  border-radius: 999px;
  box-shadow:
    0 1px 2px var(--shadow-near),
    0 8px 24px -6px var(--shadow);
  opacity: 0;
  transition: opacity 0.12s ease;
}
.shadow[data-shadow='folded'] {
  top: 8px;
  left: 133px;
  width: 128px;
  height: 48px;
}
.shadow[data-shadow='open'] {
  inset: 0;
}
.dock:not([data-open]) .shadow[data-shadow='folded'],
.dock[data-open] .shadow[data-shadow='open'] {
  opacity: 1;
  transition: opacity 0.3s ease 0.2s;
}

/* The grey discs under the three tools: the well's box, clipped to each tool's disc. The open
   tool's disc opens into the well that holds its controls; the other two fade. */
.well {
  position: absolute;
  top: 8px;
  left: 8px;
  width: 338px;
  height: 48px;
  border-radius: 24px;
  background: var(--well);
  transition:
    clip-path var(--spring-ms) var(--spring),
    opacity 0.2s ease;
}
.well[data-well='notes'],
.panel[data-panel='notes'] {
  clip-path: inset(8px 173px 8px 133px round 16px);
}
.well[data-well='voice'],
.panel[data-panel='voice'] {
  clip-path: inset(8px 133px 8px 173px round 16px);
}
.well[data-well='time'],
.panel[data-panel='time'] {
  clip-path: inset(8px 93px 8px 213px round 16px);
}
.dock[data-open] .well {
  opacity: 0;
}
.dock[data-open='notes'] .well[data-well='notes'],
.dock[data-open='voice'] .well[data-well='voice'],
.dock[data-open='time'] .well[data-well='time'] {
  clip-path: inset(0 round 24px);
  opacity: 1;
}

/* The buttons. */
.button {
  display: grid;
  flex: none;
  place-items: center;
  width: 32px;
  height: 32px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--icon);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.icon {
  grid-area: 1 / 1;
  width: 18px;
  height: 18px;
}
@media (hover: hover) {
  .button:hover,
  .tool:hover {
    background-color: var(--hover);
  }
}
.button[data-hover],
.tool[data-hover] {
  background-color: var(--hover);
}
.button[aria-pressed='true'] {
  background-color: var(--pressed);
  color: var(--ink);
}
.button:focus-visible,
.tool:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.spinner:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 0;
}

/* Folded: the three tools over their discs. */
.tool {
  --behind: var(--well);
  position: absolute;
  top: 16px;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--icon);
  cursor: pointer;
  transition:
    opacity 0.2s ease 0.12s,
    transform var(--spring-ms) var(--spring) 0.08s,
    background-color 0.15s ease;
}
.tool[data-tool='notes'] {
  left: 141px;
}
.tool[data-tool='voice'] {
  left: 181px;
}
.tool[data-tool='time'] {
  left: 221px;
}
.dock[data-open] .tool {
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 0.12s ease,
    transform 0.2s ease;
}

/* Unfolded: a tool's controls, in the well. They show through a clip that grows with the well,
   from the tool's disc, and slide in one after another. */
.panel {
  position: absolute;
  top: 8px;
  left: 8px;
  width: 338px;
  height: 48px;
  visibility: hidden;
  transition:
    clip-path var(--spring-ms) var(--spring),
    visibility 0s linear 0.24s;
}
.panel[data-shown] {
  visibility: visible;
  clip-path: inset(0 round 24px);
  transition: clip-path var(--spring-ms) var(--spring);
}
.item {
  opacity: 0;
  transform: translateX(-10px);
  transition:
    opacity 0.12s ease,
    transform 0.2s ease;
}
.panel[data-shown] .item {
  --delay: calc(90ms + var(--i, 0) * 32ms);
  opacity: 1;
  transform: none;
  transition:
    opacity 0.26s ease var(--delay),
    transform var(--spring-ms) var(--spring) var(--delay),
    background-color 0.15s ease;
}

/* Notes: three groups, set apart by thin rules. */
.panel[data-panel='notes'] {
  display: flex;
  align-items: center;
  gap: 6px;
  padding-left: 12px;
}
.group {
  display: flex;
  align-items: center;
  gap: 6px;
}
.group + .group::before {
  content: '';
  flex: none;
  width: 1px;
  height: 17px;
  margin: 0 1.25px;
  background: var(--rule);
  opacity: 0;
  transition: opacity 0.12s ease;
}
.panel[data-shown] .group + .group::before {
  opacity: 1;
  transition: opacity 0.26s ease calc(90ms + var(--i, 0) * 32ms);
}

/* Voice: the time, the waveform, and the buttons on the right. */
.elapsed {
  position: absolute;
  top: 0;
  left: 12px;
  display: flex;
  align-items: center;
  height: 48px;
}
.digits {
  color: var(--ink-muted);
  transition:
    transform var(--spring-ms) var(--spring),
    color 0.2s ease;
}
.recordingDot {
  position: absolute;
  top: 22px;
  left: -0.3px;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0;
  transform: scale(0);
  transition:
    opacity 0.2s ease,
    transform var(--spring-ms) var(--spring);
}
.panel[data-phase='recording'] .recordingDot,
.panel[data-phase='paused'] .recordingDot {
  opacity: 1;
  transform: none;
}
.panel[data-phase='recording'] .digits,
.panel[data-phase='paused'] .digits {
  color: var(--accent);
  transform: translateX(10.9px);
}
.panel[data-phase='paused'] .recordingDot {
  opacity: 0.45;
}

.track {
  position: absolute;
  top: 0;
  left: 51px;
  display: flex;
  align-items: center;
  height: 48px;
}
.slots {
  display: flex;
  align-items: center;
  gap: 3.98px;
  transform-origin: 0 50%;
  transition: transform var(--spring-ms) var(--spring);
}
.slots i {
  flex: none;
  width: 1.8px;
  height: 1.8px;
  border-radius: 1px;
  background: var(--slot);
  transition:
    height 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
    background-color 0.2s ease,
    opacity 0.2s ease;
}
.panel[data-phase='recording'] .slots,
.panel[data-phase='paused'] .slots {
  transform: translateX(10.4px) scaleX(0.95);
}
.panel[data-phase='recording'] .slots i,
.panel[data-phase='paused'] .slots i {
  background: var(--slot-recording);
}
.panel[data-phase='recording'] .slots i:nth-child(n + 28),
.panel[data-phase='paused'] .slots i:nth-child(n + 28) {
  opacity: 0;
}
.panel .slots i[data-level] {
  height: calc(1.8px + var(--level) * 13px);
  background: var(--accent);
}

.voiceButtons {
  position: absolute;
  top: 8px;
  right: 8px;
  display: flex;
  gap: 6px;
}
.round {
  background-color: var(--button);
}
@media (hover: hover) {
  .round:hover {
    background-color: var(--button-hover);
  }
}
.round[data-hover] {
  background-color: var(--button-hover);
}
.round[aria-disabled='true'] {
  cursor: default;
}
/* The pause button's room: none until a recording starts, then the bin slides over for it. */
.pauseSlot {
  display: flex;
  width: 0;
  margin-left: -6px;
  transition:
    width var(--spring-ms) var(--spring),
    margin-left var(--spring-ms) var(--spring);
}
.panel[data-phase='recording'] .pauseSlot,
.panel[data-phase='paused'] .pauseSlot {
  width: 32px;
  margin-left: 0;
}
.pauseSlot .button {
  scale: 0.5;
  opacity: 0;
  transition:
    scale var(--spring-ms) var(--spring),
    opacity 0.15s ease,
    background-color 0.15s ease;
}
.panel[data-phase='recording'] .pauseSlot .button,
.panel[data-phase='paused'] .pauseSlot .button {
  scale: 1;
  opacity: 1;
}
.primary {
  background-color: var(--primary);
  color: var(--accent-ink);
}
@media (hover: hover) {
  .primary:hover {
    background-color: var(--primary-hover);
  }
}
.primary[data-hover] {
  background-color: var(--primary-hover);
}
/* Two icons in one button, swapped with a turn and a fade. */
.playIcon,
.saveIcon,
.pauseIcon,
.resumeIcon {
  transition:
    opacity 0.18s ease,
    transform var(--spring-ms) var(--spring);
}
.playIcon {
  width: 17px;
  height: 17px;
}
.saveIcon {
  width: 19px;
  height: 19px;
}
.panel[data-phase='ready'] .saveIcon,
.panel[data-phase='recording'] .playIcon,
.panel[data-phase='paused'] .playIcon,
.panel[data-phase='recording'] .resumeIcon,
.panel[data-phase='paused'] .pauseIcon {
  opacity: 0;
  transform: scale(0.5) rotate(-40deg);
}

/* Time: the start on the left, the arrow in the middle, the end and the length on the right. */
.from,
.to {
  position: absolute;
  top: 0;
  display: flex;
  align-items: center;
  height: 48px;
}
.from {
  left: 8px;
  gap: 4.6px;
}
.to {
  left: 190.3px;
  gap: 6px;
}
.clock {
  flex: none;
  width: 16px;
  height: 16px;
  color: var(--clock);
}
.arrow {
  position: absolute;
  top: 16px;
  left: 161px;
  width: 16px;
  height: 16px;
  color: var(--arrow);
}
.arrow svg {
  display: block;
  width: 100%;
  height: 100%;
}
.spinner {
  position: relative;
  display: inline-grid;
  padding: 2px;
  border-radius: 6px;
  color: var(--ink);
  font-weight: 500;
  cursor: ns-resize;
  outline: none;
  transition: background-color 0.15s ease;
}
@media (hover: hover) {
  .spinner:hover {
    background-color: var(--spinner-hover);
  }
}
.spinner[data-hover] {
  background-color: var(--spinner-hover);
}
/* The digits roll the way the time went. */
.value[data-roll] {
  animation: roll 0.34s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.value[data-roll='down'] {
  animation-name: roll-down;
}
@keyframes roll {
  from {
    opacity: 0;
    transform: translateY(45%);
  }
}
@keyframes roll-down {
  from {
    opacity: 0;
    transform: translateY(-45%);
  }
}
.length {
  color: var(--ink-faint);
}

/* The cross that folds it, on the right of the unfolded pill. */
.close {
  position: absolute;
  top: 16px;
  left: 354px;
  background-color: var(--well);
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 0.12s ease,
    transform 0.2s ease,
    background-color 0.15s ease;
}
.dock[data-open] .close {
  opacity: 1;
  transform: none;
  transition:
    opacity 0.24s ease 0.2s,
    transform var(--spring-ms) var(--spring) 0.2s,
    background-color 0.15s ease;
}
@media (hover: hover) {
  .close:hover {
    background-color: var(--close-hover);
  }
}
.close[data-hover] {
  background-color: var(--close-hover);
}

/* The demo's pointer: an arrow, or a hand over buttons. Its tip sits at the point it is moved to. */
.ghost {
  position: absolute;
  z-index: 10;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.ghost svg {
  position: absolute;
  width: 22px;
  height: 22px;
  overflow: visible;
  opacity: 0;
  filter: drop-shadow(0 1px 1.2px rgb(0 0 0 / 0.25));
}
.ghost[data-shape='arrow'] [data-shape-of='arrow'],
.ghost[data-shape='hand'] [data-shape-of='hand'] {
  opacity: 1;
}
.ghost [data-shape-of='arrow'] {
  top: -2.2px;
  left: -3.3px;
  transform-origin: 3.3px 2.2px;
  fill: #1c1c1e;
  stroke: #fff;
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.ghost [data-shape-of='hand'] {
  top: -1.3px;
  left: -8.4px;
  transform-origin: 8.4px 1.3px;
  fill: #fff;
  stroke: #141414;
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}

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

/* Reduced motion: every state swaps at once. */
@media (prefers-reduced-motion: reduce) {
  .shape,
  .shadow,
  .dock:not([data-open]) .shadow[data-shadow='folded'],
  .dock[data-open] .shadow[data-shadow='open'],
  .well,
  .button,
  .tool,
  .dock[data-open] .tool,
  .panel,
  .panel[data-shown],
  .item,
  .panel[data-shown] .item,
  .group + .group::before,
  .panel[data-shown] .group + .group::before,
  .digits,
  .recordingDot,
  .slots,
  .slots i,
  .pauseSlot,
  .pauseSlot .button,
  .playIcon,
  .saveIcon,
  .pauseIcon,
  .resumeIcon,
  .spinner,
  .close,
  .dock[data-open] .close,
  .ghost {
    transition: none;
  }
  .value[data-roll] {
    animation: none;
  }
}
```

### toolbar-morph/icons.tsx

```tsx
/*
 * The toolbar's icons: outlined, drawn on a 24 × 24 grid with round caps, in the current colour,
 * and shown at 18 px. They are decorative; the buttons that hold them carry the labels.
 */

type IconProps = { className?: string };

const outline = {
  viewBox: '0 0 24 24',
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.7,
  strokeLinecap: 'round' as const,
  strokeLinejoin: 'round' as const,
  'aria-hidden': true,
};

/* The three tools of the folded bar. */

/** Notes: two cards, fanned. The front one hides the back one with the button's own colour. */
export function NotesIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3.6" y="5.4" width="10.8" height="13.6" rx="2.6" transform="rotate(-14 9 12.2)" />
      <rect
        x="9"
        y="3.6"
        width="10.8"
        height="13.6"
        rx="2.6"
        transform="rotate(12 14.4 10.4)"
        fill="var(--behind, #fff)"
      />
      <path d="M12.6 8.2h4.4M12.2 11.4h3.2" transform="rotate(12 14.4 10.4)" />
    </svg>
  );
}

/** Voice: a waveform in a rounded square. */
export function VoiceIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3.5" y="3.5" width="17" height="17" rx="4" />
      <path d="M8 11v2M10 9.4v5.2M12 7.8v8.4M14 9.4v5.2M16 11v2" strokeWidth="1.5" />
    </svg>
  );
}

/** Time: a calendar page with a plus at its corner. */
export function ScheduleIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M20 11.5v-4A2.5 2.5 0 0 0 17.5 5h-11A2.5 2.5 0 0 0 4 7.5v11A2.5 2.5 0 0 0 6.5 21h5.5" />
      <path d="M4 9.6h16M8.4 3v3.6M15.6 3v3.6M17.8 15.2v6M14.8 18.2h6" />
    </svg>
  );
}

/* The text tools. */

/** Text colour: a letter over a bar. */
export function ColorIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M6.2 16.2 12 4.6l5.8 11.6M8.4 12h7.2M4.8 19.6h14.4" />
    </svg>
  );
}

/** Font: "Aa". */
export function FontIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <g transform="translate(12 12) scale(0.92) translate(-12 -12)">
        <path d="m2.6 18.4 4.9-12.6 4.9 12.6M4.4 14.2h6.2" />
        <path d="M15.1 10.6c.6-.9 1.5-1.4 2.6-1.4 1.8 0 2.9 1.1 2.9 3v6.2M20.6 13.6c-.6-.2-1.5-.4-2.6-.4-2.1 0-3.6 1-3.6 2.6 0 1.4 1 2.4 2.6 2.4 1.7 0 3-1 3.6-2.4" />
      </g>
    </svg>
  );
}

/** Size: a tall T and a short one. */
export function SizeIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M2.8 8.8V6.6h10.6v2.2M8.1 6.6v11.8M6.4 18.4h3.4" />
      <path d="M13.6 12.4v-1.4h8v1.4M17.6 11v8.2M16.2 19.2h2.8" />
    </svg>
  );
}

/** Emoji: a face with its eyes closed, smiling. */
export function EmojiIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="12" cy="12" r="9" />
      <path d="M7.6 9.6c.8 1 2 1 2.8 0M13.6 9.6c.8 1 2 1 2.8 0M8.2 14.2c2.1 2.6 5.5 2.6 7.6 0" />
    </svg>
  );
}

/** Image: hills and a sun in a frame. */
export function ImageIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3.2" y="3.6" width="17.6" height="16.8" rx="3" />
      <path d="m3.6 15.8 4.8-5 4.8 4.6 2.6-2.4 4.6 4.4" />
      <circle cx="16.2" cy="8.4" r="1.1" fill="currentColor" stroke="none" />
    </svg>
  );
}

/** Numbered list. */
export function NumberedIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M4 5.6 5.7 4.6v5.6M4 10.2h3.4" strokeWidth="1.6" />
      <path
        d="M4 14.6c.3-.8 1-1.3 1.9-1.3 1 0 1.7.7 1.7 1.5 0 .7-.4 1.3-1.2 1.9L4 18.6h3.7"
        strokeWidth="1.6"
      />
      <path d="M11.6 6.4h9M11.6 12h9M11.6 17.6h9" strokeWidth="2" />
    </svg>
  );
}

/** Starred list: two star bullets beside three lines. */
export function StarredIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M5.4 4.2v4.4M3.5 5.3l3.8 2.2M7.3 5.3 3.5 7.5" strokeWidth="1.7" />
      <path d="M5.4 15.4v4.4M3.5 16.5l3.8 2.2M7.3 16.5l-3.8 2.2" strokeWidth="1.7" />
      <path d="M11.6 6.4h9M11.6 12h9M11.6 17.6h9" strokeWidth="2" />
    </svg>
  );
}

/** Magic: a wand and two sparks. */
export function MagicIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M5.6 5.4q.5 2.6 3 3.1-2.5.5-3 3.1-.5-2.6-3-3.1 2.5-.5 3-3.1Z" strokeWidth="1.6" />
      <path d="M13.8 1.8q.6 3 3.6 3.6-3 .6-3.6 3.6-.6-3-3.6-3.6 3-.6 3.6-3.6Z" strokeWidth="1.6" />
      <g transform="rotate(-45 15.8 15.8)">
        <rect x="13.9" y="8.9" width="3.8" height="13.8" rx="1.9" />
        <path d="M13.9 12.6h3.8" />
      </g>
    </svg>
  );
}

/* The voice tools. */

export function TrashIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M4.4 6.4h15.2M9.4 6.4V5a1.6 1.6 0 0 1 1.6-1.6h2A1.6 1.6 0 0 1 14.6 5v1.4" />
      <path d="m6.4 6.4.9 11.4a2 2 0 0 0 2 1.8h5.4a2 2 0 0 0 2-1.8l.9-11.4M10.3 10.4v5.4M13.7 10.4v5.4" />
    </svg>
  );
}

/** Play, filled. */
export function PlayIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M8.6 6.6a1.2 1.2 0 0 1 1.8-1l8.2 5.4a1.2 1.2 0 0 1 0 2l-8.2 5.4a1.2 1.2 0 0 1-1.8-1Z"
        fill="currentColor"
      />
    </svg>
  );
}

/** Pause, in a ring. */
export function PauseIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="12" cy="12" r="8.2" />
      <path d="M10.1 9.2v5.6M13.9 9.2v5.6" />
    </svg>
  );
}

/** Resume a paused recording: a dot in a ring. */
export function ResumeIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="12" cy="12" r="8.6" />
      <circle cx="12" cy="12" r="3.2" fill="currentColor" stroke="none" />
    </svg>
  );
}

/** Save: a floppy disk, filled, its label and window cut out. */
export function SaveIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <path
        fillRule="evenodd"
        fill="currentColor"
        d="M6.6 3.6h9.2c.5 0 1 .2 1.4.6l2.6 2.6c.4.4.6.9.6 1.4v9.2a3 3 0 0 1-3 3H6.6a3 3 0 0 1-3-3V6.6a3 3 0 0 1 3-3Zm2.6 2.8a.6.6 0 0 0 0 1.2h4.4a.6.6 0 0 0 0-1.2Zm.6 7a1.2 1.2 0 0 0-1.2 1.2v3.8h6.8v-3.8a1.2 1.2 0 0 0-1.2-1.2Z"
      />
    </svg>
  );
}

/* The time tools. */

/** A clock, its hands at ten past ten. */
export function ClockIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="12" cy="12" r="8.6" />
      <path d="m8.9 9.6 3.1 3 3.1-3" />
    </svg>
  );
}

export function ArrowIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M5 12h14M13.6 6.6 19 12l-5.4 5.4" />
    </svg>
  );
}

export function CloseIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m6.4 6.4 11.2 11.2m0-11.2L6.4 17.6" />
    </svg>
  );
}
```

### toolbar-morph/recorder.ts

```tsx
/*
 * A voice note, simulated: no microphone is opened. The levels come from a small model of speech,
 * so that the waveform looks like a voice: words come in bursts of a few syllables that rise and
 * fall, with short silences between them. The sequence is seeded, so each recording starts the
 * same way.
 */

/** A level from 0 (silence) to 1 (loudest), every SAMPLE_MS while recording. */
export const SAMPLE_MS = 280;

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

/** Returns the next level of a voice each time it is called. */
export function voice(seed = 7) {
  const next = random(seed);
  // Samples left in the current word, or in the silence after it.
  let word = 0;
  let silence = 2;
  let peak = 0;
  return () => {
    if (silence > 0) {
      silence -= 1;
      if (silence === 0) {
        word = 3 + Math.floor(next() * 6);
        peak = 0.45 + next() * 0.55;
      }
      return next() * 0.06;
    }
    word -= 1;
    if (word <= 0) silence = 1 + Math.floor(next() * 4);
    // Syllables: a stress now and then over a murmur.
    const stress = next() < 0.3 ? 1 : 0.35 + next() * 0.45;
    return Math.min(1, peak * stress);
  };
}

/** "0:04". */
export const clock = (ms: number) => {
  const seconds = Math.floor(ms / 1000);
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
};
```

### toolbar-morph/ghost-pointer.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './toolbar-morph.module.css';

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. The pointer is an arrow, and a hand over buttons. 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.
 */

export type Shape = 'arrow' | 'hand';

/** A point in the stage's own pixels. */
type Point = { x: number; y: number };

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

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

/** Plays `script` with `pointer` over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<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.78, y: stage.offsetHeight * 0.9 };

  /** 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 place = ({ x, y }: Point) => `translate(${x}px, ${y}px)`;

  const moveTo: Demo['moveTo'] = (target, options = {}) => {
    if (options.shape) pointer.dataset.shape = options.shape;
    if (!target) return step(Promise.resolve());
    let to = target as Point;
    if (target instanceof Element) {
      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 share = options.at ?? { x: 0.5, y: 0.5 };
      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.14, y: (from.y + to.y) / 2 + dx * 0.14 };
    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(900, Math.max(340, Math.hypot(dx, dy) * 1.5)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const shape = pointer.querySelector(`[data-shape-of="${pointer.dataset.shape}"]`);
    const tap = shape?.animate([{ scale: 1 }, { scale: 0.84, offset: 0.35 }, { scale: 1 }], {
      duration: 260,
      easing: 'ease-in-out',
    });
    await wait(90);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.dataset.shape = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  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));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer the demo moves around. It is decorative: the demo's effects show on screen. */
export function GhostPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.ghost} data-shape="arrow" aria-hidden="true">
      <svg data-shape-of="arrow" viewBox="0 0 20 20">
        <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
      </svg>
      <svg data-shape-of="hand" viewBox="0 0 20 20">
        <path d="M6.1 2.7a1.5 1.5 0 0 1 3 0v4.7a1.25 1.25 0 0 1 2.5.2v.4a1.2 1.2 0 0 1 2.4.4v.6a1.1 1.1 0 0 1 2.2.3V13c0 2.6-1.6 4.6-4.4 4.6H9.6c-1.6 0-2.6-.7-3.5-1.9L3.3 12.1c-.6-.8-.4-1.8.4-2.2.7-.4 1.5-.1 2 .5l.4.6z" />
        <path d="M9.1 8v2.6M11.6 8.3v2.3M14 9.2v1.9" fill="none" />
      </svg>
    </span>
  );
}
```

### toolbar-morph/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)';
}
```

### toolbar-morph/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 };
}
```