# Voice note

> Press the mic and it grows into a live recording pill; send it and the pill becomes a voice message that rises into the thread.

The mic button stretches into a red pill with live bars and a thin ring that runs round its edge as the allowed time fills up, between cancel and send. Cancel folds it back into the mic. Send turns it into a message with the take’s own waveform and duration, which lifts into the conversation above. The bars follow a simulated voice, or your microphone if you allow it, and nothing is recorded or sent.

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

Category: Media.

## Anatomy

1. **Message**: A sent note: a mic badge, an 18-bar waveform of the take and its length. Two stay in view; older ones blur away.
2. **Pill**: The mic button grown: its width springs open in 550 ms and its colour cross-fades to the recording tint.
3. **Live bars**: Seven bars of loudness; the newest, at the right, is live, and every 100 ms they shift left by one. A simulated voice drives them, or the microphone when it is on.
4. **Ring**: The pill’s outline, drawn clockwise from its right end as the time allowed is used up.
5. **Cancel**: Slides out to the left while recording. Folds the pill back into the mic; Escape does the same.
6. **Send**: Slides out to the right. The pill turns into the message in 440 ms, then the message rises into the thread.
7. **Timer**: Whole seconds recorded, as `0:08`, in the recording red under the pill.
8. **Microphone switch**: Lets the bars follow the real microphone. It asks once, opens the mic only while recording and only measures loudness.

## When to use

- A chat or messaging composer, where saying something is quicker than typing it.
- Short takes with a hard limit: the ring shows the time used, and the note sends itself at `maxSeconds`.
- Recording without holding a button down: one tap starts, Send or Cancel ends, Escape cancels, so it works from a keyboard.
- Showing what was sent as a compact message: its length and a small waveform of the take.

## When not to use

- Long recordings, interviews or anything to review before sending: they need pause, playback and trimming. Use a full recorder.
- Dictation, where the words end up as text. Use speech-to-text inside the text field.
- Push-to-talk, where letting go sends: this control takes two taps, start and send.
- On its own when the audio itself is needed: it only measures loudness, and `onSend` gets the length and levels. Record with MediaRecorder alongside it.
- An assistant’s “listening” indicator, with nothing to send or cancel. Use a status or an orb.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/voice-note.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
'use client';

import { useState } from 'react';
import { VoiceNote, type Take } from './voice-note';

export default function ReplyBar() {
  const [last, setLast] = useState<Take | null>(null);
  return (
    <>
      {/* Notes up to 30 s; each comes with its length and levels (no audio). */}
      <VoiceNote maxSeconds={30} onSend={setLast} autoplay={false} />
      {last && <p>Last note: {last.seconds} s</p>}
    </>
  );
}
```

## States

- **Ready**: The mic under the last note. Cancel and Send are tucked behind it and out of reach.
- **Recording**: The pill, the live bars, the ring and the timer; Cancel and Send at either side. Held still here.
- **Almost out of time**: At 18 of 20 seconds the ring is nearly closed. At `maxSeconds` the note sends itself.
- **Becoming a message**: The pill widens into the message before it lifts off. Live it lasts 440 ms; held here.
- **Sent**: The new note sits under the earlier one, and the mic pops back in.
- **Microphone on**: The switch is filled: the next take’s bars follow the microphone. Nothing is recorded or sent.
- **No microphone**: Refused or missing: the caption says so, and the bars follow the simulated voice.

## How it works

- One shape morphs through every state. The circle widens into the pill on a spring and turns red, and cancel and send slide out from behind it. On send it widens into the message, which then glides up into the thread with FLIP from the pill’s box.
- Only that plain shape changes size. The mic, the bars, the ring and the message keep their own sizes on top of it, inside a fixed, clipped box, which took the iPhone’s GPU memory from about 100 MB to about 50 MB.
- A requestAnimationFrame loop runs only while recording and on screen: it writes seven bars (a smoothed, scrolling history of levels) straight to the page, sets the dash length of the ring (an SVG path of the pill’s outline), and keeps one level every 100 ms.
- The levels come from a seeded simulated voice, with stressed syllables, gaps and breaths. If the reader opts in, they come from the microphone through an AnalyserNode instead: it is open only during a take the reader started, its tracks stop right afterwards, and nothing is stored or sent.
- A sent note keeps its levels, not audio, summarised into 18 waveform bars. A timer sends the note at the limit, even off screen.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the shadows are built from colour tokens, and the mic’s gloss fades to a faint sheen at night.

## API reference

- **maxSeconds** (`number`, default `20`): The longest a note can be: the ring fills up over it, then the note sends itself.
- **defaultState** (`VoiceNoteState`): Opens on a given moment, held still, and turns the demo off: phase ('idle', 'recording' or 'sending'), seconds into the take, its levels, the notes already in the thread and the microphone ('off', 'on' or 'blocked').
- **onSend** (`(take: Take) => void`): Called with each note sent: its length in seconds and its loudness every 100 ms (there is no audio).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer records a note and sends it, then records one and cancels it. It never uses the microphone.
- **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

- Mic, cancel and send are labelled buttons. Focus follows the control: to Send when a take starts, back to the mic when it ends, and Escape cancels a recording.
- “Recording.”, “Recording cancelled.” and “Voice note sent, 4 seconds.” are announced. Sent notes form a list, each read as “Voice note, 0:04”.
- “Use microphone” is a toggle (aria-pressed), and the caption always says where the bars come from and that nothing is recorded or sent.
- Under reduced motion states change at once and there is no demo. The level bars and the ring still update, because they carry information.

## Source

### voice-note/voice-note.tsx

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

'use client';

import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { GhostPointer, runDemo } from './ghost-pointer';
import { openMicrophone, simulatedVoice, type LevelSource } from './signal';
import { useAutoplay } from './use-autoplay';
import styles from './voice-note.module.css';

/*
 * Voice note: a recording control. Press the mic and it grows into a red pill with live bars
 * and a thin ring that runs round its edge as the time allowed fills up, between a cancel and a
 * send button. Cancel folds it back into the mic; send turns it into a voice message, with its
 * duration and a small waveform of the take, which then rises into the conversation above.
 *
 * - One shape does it all: the pill is the mic button grown (its width eases on a spring and its
 *   colour cross-fades), and it becomes the message before the message lifts off (FLIP: the new
 *   message starts where the pill is, then glides to its place). Only that plain shape changes
 *   size; what shows on it (the mic, the bars, the ring, the message) keeps its own size, so a
 *   phone never has to make their layers again while the shape moves.
 * - The bars follow a simulated voice (signal.ts), unless the reader lets them follow the
 *   microphone: it then only feeds an analyser that measures loudness, and is open only while a
 *   recording runs. Nothing is recorded, kept or sent.
 * - A requestAnimationFrame loop runs only while recording, and on screen: it writes the bars'
 *   heights and the ring's length straight to the DOM, and keeps a level every 100 ms for the
 *   message's waveform.
 * - While on screen and untouched, a ghost pointer records a note and sends it, then records one
 *   and cancels it; the demo never uses the microphone. Under reduced motion, states change at
 *   once.
 * - 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.
 */

/** The live bars in the pill. */
const BARS = 7;
/** The bars of a sent message's waveform. */
const WAVE = 18;
/** How often a level is kept for the waveform, in ms. */
const SAMPLE_EVERY = 100;
/** How long the pill takes to become the message, in ms. */
const MORPH = 440;

/**
 * A spring that overshoots a little and settles, as a CSS linear() easing. The stylesheet's
 * transitions use it too, through the --spring custom property.
 */
const SPRING =
  'linear(0, 0.054, 0.18, 0.336, 0.496, 0.641, 0.764, 0.861, 0.932, 0.982, 1.014, 1.031, 1.038, 1.039, 1.036, 1.03, 1.024, 1.017, 1.012, 1.008, 1.004, 1.002, 1, 0.999, 0.999, 0.998, 1)';

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Browsers without linear() easings get a close cubic curve. */
const easing = (curve: string) =>
  CSS.supports('transition-timing-function', curve) ? curve : 'cubic-bezier(0.3, 1.3, 0.5, 1)';

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

/** A take: how long it lasted, and how loud it was every 100 ms. No audio: there is none. */
export type Take = { seconds: number; levels: number[] };

type Message = Take & { id: number };

type Phase = 'idle' | 'recording' | 'sending';

/**
 * A moment to open on, held still, for docs and screenshots; the demo stays off. A held take
 * waits there, its bars, ring and timer frozen, until the reader cancels or sends it.
 */
export type VoiceNoteState = {
  /** 'recording': mid-take. 'sending': the pill turned into the message, held there. */
  phase?: Phase;
  /** How far into the take, in seconds (recording, sending). Default 4. */
  seconds?: number;
  /** The take's loudness so far, 0 to 1 every 100 ms; by default a simulated voice. */
  levels?: number[];
  /** The notes already in the thread, oldest first; by default one six-second note. */
  notes?: Take[];
  /** Where the bars' levels come from, as the toggle and its caption show it. */
  microphone?: 'off' | 'on' | 'blocked';
};

/** A note already in the conversation, so the stage opens on a meaningful frame. */
const EARLIER: Message = {
  id: 0,
  seconds: 6,
  levels: [
    0.2, 0.45, 0.8, 0.55, 0.3, 0.7, 1, 0.65, 0.25, 0.5, 0.85, 0.6, 0.35, 0.75, 0.5, 0.3, 0.6, 0.4,
  ],
};

/**
 * `count` bars from a take's levels: each bar is the loudest level of its stretch of the take,
 * relative to the loudest of all.
 */
function waveform(levels: number[], count: number) {
  if (!levels.length) return Array.from({ length: count }, () => 0.2);
  const peaks = Array.from({ length: count }, (_, i) => {
    const from = Math.floor((i * levels.length) / count);
    const to = Math.max(from + 1, Math.floor(((i + 1) * levels.length) / count));
    return Math.max(...levels.slice(from, to));
  });
  const loudest = Math.max(...peaks, 0.01);
  return peaks.map((peak) => Math.round((0.2 + 0.8 * (peak / loudest)) * 100) / 100);
}

/** A held take's levels: the ones given, or a simulated voice (always the same one). */
function heldLevels(state: VoiceNoteState | undefined) {
  if (state?.levels) return state.levels;
  const voice = simulatedVoice(7);
  return Array.from({ length: (state?.seconds ?? 4) * 10 }, (_, i) => voice.level(i * 100));
}

export function VoiceNote({
  maxSeconds = 20,
  onSend,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The longest a note can be, in seconds: the ring fills up over it, then the note sends itself. */
  maxSeconds?: number;
  /** Called with each note sent: its length and its loudness over time (there is no audio). */
  onSend?: (take: Take) => void;
  /** While on screen and untouched, a demo pointer plays with it on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens on a given moment, held still (see VoiceNoteState); the demo stays off. */
  defaultState?: VoiceNoteState;
  className?: string;
}) {
  const { ref: root, playing, take: handOver } = useAutoplay<HTMLDivElement>(6000);
  const [phase, setPhase] = useState<Phase>(defaultState?.phase ?? 'idle');
  /** The mic pops back in after a send. */
  const [returning, setReturning] = useState(false);
  /** Whole seconds recorded so far. */
  const [elapsed, setElapsed] = useState(defaultState?.seconds ?? 0);
  /** The take the pill shows as a message, while it turns into one. */
  const [sending, setSending] = useState<Take | null>(() =>
    defaultState?.phase === 'sending'
      ? { seconds: defaultState.seconds ?? 4, levels: heldLevels(defaultState) }
      : null,
  );
  const [messages, setMessages] = useState<Message[]>(() =>
    defaultState?.notes
      ? defaultState.notes.map((note, i) => ({ id: -1 - i, ...note }))
      : [EARLIER],
  );
  const [micOn, setMicOn] = useState(defaultState?.microphone === 'on');
  const [micAsking, setMicAsking] = useState(false);
  const [micBlocked, setMicBlocked] = useState(defaultState?.microphone === 'blocked');
  /** The take in progress is the reader's: the demo waits for it to end. */
  const [readersTake, setReadersTake] = useState(false);
  const [message, setMessage] = useState('');

  const shape = useRef<HTMLSpanElement>(null);
  const bars = useRef<(HTMLSpanElement | null)[]>([]);
  const ring = useRef<SVGPathElement>(null);
  const micButton = useRef<HTMLButtonElement>(null);
  const cancelButton = useRef<HTMLButtonElement>(null);
  const sendButton = useRef<HTMLButtonElement>(null);
  const bubbles = useRef(new Map<number, HTMLLIElement>());
  const pointer = useRef<SVGSVGElement>(null);
  /** Where the levels come from during a take. */
  const source = useRef<LevelSource | null>(null);
  /** Counts the takes, so a microphone that opens late knows its take is over. */
  const takeCount = useRef(0);
  const startedAt = useRef(0);
  const levels = useRef<number[]>(
    defaultState?.phase === 'recording' ? heldLevels(defaultState) : [],
  );
  /** A take opened by defaultState: held still, nothing runs, until it is cancelled or sent. */
  const held = useRef(defaultState?.phase === 'recording');
  const nextId = useRef(1);
  /** The pill's shape when the newest message left it, to start the message from there. */
  const liftFrom = useRef<DOMRect | null>(null);
  /** Where the focus goes once the phase has changed. */
  const focusNext = useRef<HTMLButtonElement | null>(null);
  const handoff = useRef(0);
  const stopDemo = useRef<(() => void) | null>(null);

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

  /** Moves the focus along with the controls, if it was on one of them. */
  const keepFocus = (next: HTMLButtonElement | null) => {
    const focused = document.activeElement;
    const inside = [micButton, cancelButton, sendButton].some(
      (button) => button.current === focused,
    );
    focusNext.current = inside ? next : null;
  };

  /** Ends the take's source: the microphone, if it was open, stops at once. */
  const release = () => {
    takeCount.current += 1;
    source.current?.close();
    source.current = null;
  };

  /** Starts recording. The reader's takes follow the microphone, if they allowed it. */
  const start = (byReader: boolean) => {
    if (phase !== 'idle') return;
    release();
    const take = takeCount.current;
    if (byReader && micOn) {
      openMicrophone().then(
        (mic) => {
          if (takeCount.current === take) source.current = mic;
          else mic.close();
        },
        () => {
          if (takeCount.current === take) source.current = simulatedVoice();
          setMicOn(false);
          setMicBlocked(true);
          announce('The microphone is not available: the bars follow a simulated voice.');
        },
      );
    } else {
      source.current = simulatedVoice();
    }
    held.current = false;
    startedAt.current = performance.now();
    levels.current = [];
    ring.current?.setAttribute('stroke-dasharray', '0 100');
    bars.current.forEach((bar) => bar?.style.removeProperty('transform'));
    keepFocus(sendButton.current);
    setElapsed(0);
    setReturning(false);
    setReadersTake(byReader);
    setPhase('recording');
    announce('Recording. Send it, or cancel it.');
  };

  const cancel = () => {
    if (phase !== 'recording') return;
    release();
    keepFocus(micButton.current);
    setPhase('idle');
    announce('Recording cancelled.');
  };

  const send = () => {
    if (phase !== 'recording') return;
    const take: Take = {
      seconds: held.current
        ? Math.max(1, elapsed)
        : Math.max(1, Math.round((performance.now() - startedAt.current) / 1000)),
      levels: levels.current.slice(),
    };
    held.current = false;
    release();
    keepFocus(micButton.current);
    setSending(take);
    setPhase('sending');
    onSend?.(take);
    announce(`Voice note sent, ${take.seconds} ${take.seconds === 1 ? 'second' : 'seconds'}.`);
    // The pill turns into the message, then the message lifts into the conversation.
    window.clearTimeout(handoff.current);
    handoff.current = window.setTimeout(
      () => {
        liftFrom.current = shape.current?.getBoundingClientRect() ?? null;
        setMessages((now) => [...now.slice(-2), { id: nextId.current++, ...take }]);
        setSending(null);
        setReturning(!reducedMotion());
        setPhase('idle');
      },
      reducedMotion() ? 0 : MORPH,
    );
  };

  const toggleMic = () => {
    if (micOn) {
      setMicOn(false);
      // A recording in progress lets go of the microphone at once.
      if (phase === 'recording') {
        release();
        source.current = simulatedVoice();
      }
      announce('Microphone off: the bars follow a simulated voice.');
      return;
    }
    // Asked now, from the reader's click; it is opened again, only while recording.
    setMicAsking(true);
    openMicrophone().then(
      (mic) => {
        mic.close();
        setMicAsking(false);
        setMicOn(true);
        setMicBlocked(false);
        announce(
          'Microphone on: the bars follow it while you record. Nothing is recorded or sent.',
        );
      },
      () => {
        setMicAsking(false);
        setMicBlocked(true);
        announce('The microphone is not available: the bars follow a simulated voice.');
      },
    );
  };

  // The demo and the recording loop call the handlers of the latest render.
  const actions = useRef({ start, cancel, send, phase });
  useLayoutEffect(() => {
    actions.current = { start, cancel, send, phase };
  });

  // While recording: the bars follow the levels, the ring fills up, a level is kept every 100 ms.
  // Off screen the loop rests (the take goes on); a timer sends the note when the time is up.
  useEffect(() => {
    const stage = root.current;
    if (phase !== 'recording' || !stage) return;
    if (held.current) {
      // A held take: the bars show its last levels, the ring its share of the time, still.
      const last = levels.current.slice(-BARS);
      bars.current.forEach((bar, i) => {
        const level = last[i - (BARS - last.length)] ?? 0;
        bar?.style.setProperty('transform', `scaleY(${(0.16 + 0.84 * level).toFixed(3)})`);
      });
      ring.current?.setAttribute(
        'stroke-dasharray',
        `${Math.min(100, (elapsed / maxSeconds) * 100).toFixed(2)} 100`,
      );
      return;
    }
    const recent = Array.from({ length: BARS }, () => 0);
    const shown = recent.slice();
    let frame = 0;
    let kept = -Infinity;
    let second = 0;
    let visible = true;
    const tick = () => {
      const now = performance.now();
      const time = Math.max(0, now - startedAt.current);
      const level = source.current?.level(now) ?? 0;
      // The newest bar is live; every 100 ms the bars shift left by one.
      if (now - kept >= SAMPLE_EVERY) {
        kept = now;
        levels.current.push(level);
        recent.shift();
        recent.push(level);
      }
      recent[BARS - 1] = level;
      bars.current.forEach((bar, i) => {
        shown[i] += (recent[i] - shown[i]) * 0.3;
        bar?.style.setProperty('transform', `scaleY(${(0.16 + 0.84 * shown[i]).toFixed(3)})`);
      });
      ring.current?.setAttribute(
        'stroke-dasharray',
        `${Math.min(100, time / (maxSeconds * 10)).toFixed(2)} 100`,
      );
      if (Math.floor(time / 1000) !== second) {
        second = Math.floor(time / 1000);
        setElapsed(second);
      }
      frame = requestAnimationFrame(tick);
    };
    const resume = () => {
      cancelAnimationFrame(frame);
      if (visible) frame = requestAnimationFrame(tick);
    };
    const observer = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      resume();
    });
    observer.observe(stage);
    const limit = window.setTimeout(
      () => actions.current.send(),
      maxSeconds * 1000 - (performance.now() - startedAt.current),
    );
    resume();
    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
      window.clearTimeout(limit);
    };
    // `actions` is a ref: the phase and the limit are all this depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, maxSeconds, root]);

  // After a change of phase: the focus follows the controls, once its new place can take it.
  useLayoutEffect(() => {
    const next = focusNext.current;
    if (!next || next.closest('[inert]')) return;
    next.focus();
    focusNext.current = null;
  }, [phase]);

  // A message that just left the pill starts where the pill was, then glides to its place.
  useLayoutEffect(() => {
    const from = liftFrom.current;
    liftFrom.current = null;
    const newest = messages[messages.length - 1];
    const bubble = bubbles.current.get(newest.id);
    const stage = root.current;
    if (!from || !bubble || !stage || reducedMotion()) return;
    const to = bubble.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = stage.getBoundingClientRect().width / stage.offsetWidth || 1;
    const dx = (from.left + from.width / 2 - (to.left + to.width / 2)) / scale;
    const dy = (from.top - to.top) / scale;
    bubble.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
      duration: 620,
      easing: easing(SPRING),
    });
  }, [messages, root]);

  // Nothing keeps running, and the microphone closes, once the control is gone.
  useEffect(
    () => () => {
      window.clearTimeout(handoff.current);
      source.current?.close();
    },
    [],
  );

  // The demo: a ghost pointer records a note and sends it, then records one and cancels it; again
  // while on screen and untouched, but never over a take of the reader's. It never uses the
  // microphone.
  const demo = autoplay && !defaultState && playing && !(phase === 'recording' && readersTake);
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const act = () => actions.current;
      await wait(600);
      for (;;) {
        // A take of the demo's own, left running when it last stopped, is cancelled first.
        if (act().phase === 'recording') {
          await moveTo(cancelButton.current);
          await press(() => act().cancel());
          await wait(900);
        }
        await moveTo(micButton.current);
        await wait(200);
        await press(() => act().start(false));
        await wait(3300);
        await moveTo(sendButton.current);
        await wait(250);
        await press(() => act().send());
        await wait(2100);
        await moveTo(micButton.current);
        await wait(250);
        await press(() => act().start(false));
        await wait(2200);
        await moveTo(cancelButton.current);
        await wait(250);
        await press(() => act().cancel());
        await wait(1700);
      }
    });
    stopDemo.current = stop;
    return stop;
  }, [demo, root]);

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

  const recording = phase === 'recording';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': SPRING } as CSSProperties}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && recording) cancel();
      }}
    >
      <div className={styles.scene}>
        <ol className={styles.thread} aria-label="Sent voice notes">
          {messages.map((note, i) => {
            const slot = messages.length - 1 - i;
            return (
              <li
                key={note.id}
                ref={(element) => {
                  if (!element) return;
                  bubbles.current.set(note.id, element);
                  return () => {
                    bubbles.current.delete(note.id);
                  };
                }}
                className={styles.bubble}
                data-part="message"
                style={{ '--slot': slot } as CSSProperties}
                data-gone={slot > 1 || undefined}
                aria-hidden={slot > 1 || undefined}
              >
                <NoteFace take={note} />
              </li>
            );
          })}
        </ol>

        <div className={styles.composer} data-phase={phase}>
          <button
            ref={cancelButton}
            type="button"
            className={`${styles.side} ${styles.cancel}`}
            data-part="cancel"
            aria-label="Cancel recording"
            inert={phase === 'idle'}
            onClick={cancel}
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="m6 6 8 8m0-8-8 8" />
            </svg>
          </button>

          <div
            className={styles.pill}
            data-returning={returning || undefined}
            onAnimationEnd={(event) => {
              if (event.target === event.currentTarget) setReturning(false);
            }}
          >
            <span className={styles.stack}>
              <span ref={shape} className={styles.shape} data-part="pill" aria-hidden="true" />
              <span className={styles.bars} aria-hidden="true">
                {Array.from({ length: BARS }, (_, i) => (
                  <span
                    key={i}
                    ref={(element) => {
                      bars.current[i] = element;
                    }}
                    className={styles.bar}
                    data-part="bar"
                    style={{ '--i': Math.abs(i - (BARS - 1) / 2) } as CSSProperties}
                  />
                ))}
              </span>
              <svg
                className={styles.ring}
                data-part="ring"
                viewBox="0 0 67.5 27.5"
                aria-hidden="true"
              >
                {/* The pill's outline, from the middle of its right end, clockwise. */}
                <path
                  ref={ring}
                  d="M66.7 13.75A12.95 12.95 0 0 1 53.75 26.7H13.75A12.95 12.95 0 0 1 13.75 0.8H53.75A12.95 12.95 0 0 1 66.7 13.75Z"
                  pathLength={100}
                  strokeDasharray="0 100"
                />
              </svg>
              <span className={styles.face} data-part="face" aria-hidden="true">
                {sending && <NoteFace take={sending} />}
              </span>
              <button
                ref={micButton}
                type="button"
                className={styles.mic}
                data-part="mic"
                aria-label="Record a voice note"
                inert={phase !== 'idle'}
                onClick={() => start(true)}
              >
                <MicIcon />
              </button>
            </span>
          </div>

          <button
            ref={sendButton}
            type="button"
            className={`${styles.side} ${styles.send}`}
            data-part="send"
            aria-label="Send voice note"
            inert={phase === 'idle'}
            onClick={send}
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="m5 10.5 3.25 3.25L15 7" />
            </svg>
          </button>

          <span className={styles.timer} data-part="timer" aria-hidden="true">
            {clock(elapsed)}
          </span>
        </div>

        <div className={styles.source}>
          <button
            type="button"
            className={styles.toggle}
            data-part="microphone"
            aria-pressed={micOn}
            disabled={micAsking}
            onClick={toggleMic}
          >
            <MicIcon />
            Use microphone
          </button>
          <p className={styles.caption} data-part="caption">
            {micOn
              ? 'The bars follow your microphone.'
              : micBlocked
                ? 'No microphone here: the bars follow a simulated voice.'
                : 'The bars follow a simulated voice.'}{' '}
            Nothing is recorded or sent.
          </p>
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A voice message: a mic badge, the take's waveform and its duration. */
function NoteFace({ take }: { take: Take }) {
  return (
    <span className={styles.note} data-part="note">
      <span className={styles.hidden}>Voice note, {clock(take.seconds)}</span>
      <span className={styles.badge} data-part="badge" aria-hidden="true">
        <MicIcon />
      </span>
      <span className={styles.wave} data-part="wave" aria-hidden="true">
        {waveform(take.levels, WAVE).map((height, i) => (
          <span key={i} style={{ '--h': height } as CSSProperties} />
        ))}
      </span>
      <span className={styles.duration} data-part="duration" aria-hidden="true">
        {clock(take.seconds)}
      </span>
    </span>
  );
}

function MicIcon() {
  return (
    <svg className={styles.micIcon} viewBox="0 0 20 20" aria-hidden="true">
      <rect x="7" y="2.5" width="6" height="10" rx="3" />
      <path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" />
    </svg>
  );
}
```

### voice-note/voice-note.module.css

```css
/* Voice note (voice-note.tsx). Sizes are in em: the stage sets the font-size from its own width
   (a container query), so the control grows with the room it has. The composer's phase (idle,
   recording, sending) is a data attribute that drives the shape; --spring, the easing of the
   springy transitions, comes from the component.

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

.root {
  /* The stage, and what is raised on it: the messages, the mic, cancel, send and the toggle. */
  --surface: #efeff3;
  --surface-2: #fff;
  /* Under the pointer: the mic, cancel and send. */
  --hover: #f7f7f9;
  /* The words and icons; a duration and the caption; a message's waveform. */
  --ink: #1c1c1e;
  --ink-muted: #8e8e93;
  --wave: #b9b9c0;
  /* The stage's edge, and the hairline round what is raised on it. */
  --line: rgb(0 0 0 / 0.05);
  --edge: rgb(0 0 0 / 0.06);
  /* A recording: the bars, the ring, the timer and the mic badge; the badge's disc; the pill,
     and its edge. */
  --red: #e5484d;
  --red-tint: #ffe8e6;
  --recording: #fddcd8;
  --recording-edge: rgb(229 72 77 / 0.3);
  /* The mic's soft highlight, on the white shape; barely there on the dark one. */
  --gloss: rgb(255 255 255 / 0.85);
  /* The microphone toggle, on. */
  --accent: #1c1c1e;
  --accent-ink: #fff;
  --focus: #0a84ff;
  /* The lift under what is raised: near, then far. */
  --shadow-near: rgb(0 0 0 / 0.05);
  --shadow-far: rgb(0 0 0 / 0.16);
  --shadow:
    0 0 0 0.5px var(--edge), 0 1px 2px var(--shadow-near), 0 8px 22px -10px var(--shadow-far);
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  /* The pill while recording, and the message it becomes. */
  --pill: 6.75em;
  --note: 10.75em;
  --size: 2.75em;
  --gap: 0.55em;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#efeff3, #121214);
    --surface-2: light-dark(#fff, #242427);
    --hover: light-dark(#f7f7f9, #2e2e32);
    --ink: light-dark(#1c1c1e, #ececef);
    --ink-muted: light-dark(#8e8e93, #8e8e95);
    --wave: light-dark(#b9b9c0, #707077);
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --red: light-dark(#e5484d, #f06063);
    --red-tint: light-dark(#ffe8e6, #3d1f21);
    --recording: light-dark(#fddcd8, #4a2224);
    --recording-edge: light-dark(rgb(229 72 77 / 0.3), rgb(240 96 99 / 0.35));
    --gloss: light-dark(rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.06));
    --accent: light-dark(#1c1c1e, #ececef);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --focus: light-dark(#0a84ff, #3594ff);
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow-far: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.5));
  }
}
/* 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 buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

.scene {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: max(380px, 56cqi);
  padding: 2em 16px 1.75em;
  font-size: clamp(14px, 9.5px + 1.1cqi, 20px);
}

/* The conversation: the newest message at the bottom, the one before above it. */
.thread {
  position: relative;
  width: 100%;
  height: 6.5em;
  margin: 0;
  padding: 0;
  list-style: none;
}
.bubble {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: var(--note);
  height: var(--size);
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--shadow);
  translate: -50% calc(var(--slot) * -3.65em);
  transition:
    translate 0.6s var(--spring),
    opacity 0.35s ease,
    filter 0.35s ease,
    scale 0.35s ease;
}
.bubble[data-gone] {
  opacity: 0;
  filter: blur(4px);
  scale: 0.9;
}

/* A message's face: a mic badge, the waveform, the duration. */
.note {
  display: flex;
  align-items: center;
  gap: 0.6em;
  height: 100%;
  padding: 0 1em 0 0.375em;
}
.badge {
  display: grid;
  flex: none;
  place-items: center;
  width: 2em;
  height: 2em;
  border-radius: 50%;
  background: var(--red-tint);
  color: var(--red);
}
.badge .micIcon {
  width: 1.05em;
  height: 1.05em;
}
.wave {
  display: flex;
  flex: 1;
  align-items: center;
  justify-content: space-between;
  height: 1.3em;
}
.wave span {
  width: 0.18em;
  height: calc(var(--h) * 100%);
  border-radius: 99px;
  background: var(--wave);
}
.duration {
  flex: none;
  font-size: 0.8em;
  font-weight: 600;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* The composer: cancel, the pill, send. Everything sits on the pill's centre. */
.composer {
  position: relative;
  width: calc(var(--pill) + 2 * (var(--gap) + var(--size)));
  height: var(--size);
  margin-top: 1.1em;
}
/* The pill: a box as wide as a message, with room round it for the shadow, which never changes
   size (it is a small layer of its own, clipped to itself). Its shape changes inside it, and what
   shows on the shape sits on top, each at its own size. */
.pill {
  --side: 0.8em;
  position: absolute;
  z-index: 1;
  top: -0.35em;
  left: 50%;
  width: calc(var(--note) + 2 * var(--side));
  height: calc(0.35em + var(--size) + 1.3em);
  padding: 0.35em var(--side) 1.3em;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
  translate: -50% 0;
  will-change: transform;
}
/* The pill's contents, inside the room left for the shadow: the shape, and what shows on it. */
.stack {
  position: relative;
  display: block;
  height: var(--size);
}
/* After a send, the mic comes back at once, small, and pops in. */
.pill[data-returning] {
  animation: pop 0.5s var(--spring) 0.12s backwards;
}
.pill[data-returning],
.pill[data-returning] * {
  transition: none;
}

/* The shape: white round the mic (dark grey at night), red while recording, white again as the
   message. Centred with auto margins, it only repaints as it eases (nothing on it is a layer of
   its own). */
.shape {
  position: absolute;
  inset: 0;
  width: var(--size);
  margin: 0 auto;
  border-radius: 999px;
  background: radial-gradient(70% 150% at 20% 0%, var(--gloss), transparent 60%) var(--surface-2);
  box-shadow:
    var(--shadow),
    inset 0 0 0 0.5px transparent;
  transition:
    width 0.55s var(--spring),
    background-color 0.3s ease,
    box-shadow 0.3s ease;
}
.composer[data-phase='recording'] .shape {
  width: var(--pill);
  background-color: var(--recording);
  box-shadow:
    var(--shadow),
    inset 0 0 0 0.5px var(--recording-edge);
}
.composer[data-phase='sending'] .shape {
  width: var(--note);
  transition-duration: 0.44s, 0.3s, 0.3s;
}

.mic {
  position: absolute;
  top: 0;
  left: 50%;
  display: grid;
  place-items: center;
  width: var(--size);
  height: 100%;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  pointer-events: auto;
  translate: -50% 0;
  transition:
    opacity 0.2s ease,
    scale 0.35s var(--spring),
    filter 0.2s ease,
    background-color 0.2s ease;
}
.mic:hover {
  background: var(--hover);
}
.mic:active {
  scale: 0.92;
}
.mic:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.mic .micIcon {
  width: 1.3em;
  height: 1.3em;
}
.composer:not([data-phase='idle']) .mic {
  opacity: 0;
  scale: 0.5;
  filter: blur(3px);
}

/* The live bars: the component scales each one to the level. */
.bars {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.26em;
  opacity: 0;
  filter: blur(3px);
  transition:
    opacity 0.2s ease,
    filter 0.2s ease;
}
.composer[data-phase='recording'] .bars {
  opacity: 1;
  filter: none;
  transition-delay: 0.08s;
}
.bar {
  width: 0.2em;
  height: 1.3em;
  border-radius: 99px;
  background: var(--red);
  transform: scaleY(0.16);
}
.composer[data-phase='recording'] .bar {
  animation: grow 0.45s var(--spring) backwards;
  animation-delay: calc(var(--i) * 45ms + 100ms);
}

/* The ring: the pill's outline, drawn as far as the time allowed is used. */
.ring {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--pill);
  height: var(--size);
  fill: none;
  stroke: var(--red);
  stroke-width: 1.3;
  stroke-linecap: round;
  opacity: 0;
  translate: -50% 0;
  transition: opacity 0.2s ease;
}
.composer[data-phase='recording'] .ring {
  opacity: 1;
  transition-delay: 0.3s;
}

/* The message the pill becomes. */
.face {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--note);
  height: 100%;
  opacity: 0;
  filter: blur(4px);
  translate: -50% 0;
  transition:
    opacity 0.3s ease,
    filter 0.3s ease;
}
.composer[data-phase='sending'] .face {
  opacity: 1;
  filter: none;
  /* Once the shape is nearly as wide as the message. */
  transition-delay: 0.22s;
}

/* Cancel and send: tucked behind the pill, they slide out on each side while recording. */
.side {
  position: absolute;
  top: 0;
  left: 50%;
  display: grid;
  place-items: center;
  width: var(--size);
  height: var(--size);
  margin-left: calc(var(--size) / -2);
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow: var(--shadow);
  cursor: pointer;
  opacity: 0;
  scale: 0.5;
  transition:
    translate 0.5s var(--spring),
    scale 0.5s var(--spring),
    opacity 0.2s ease,
    background-color 0.2s ease;
}
.side svg {
  width: 1.15em;
  height: 1.15em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.side:hover {
  background: var(--hover);
}
.side:active {
  scale: 0.92;
}
.side:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.composer[data-phase='recording'] .side {
  opacity: 1;
  scale: 1;
  transition-delay: 0.05s;
}
.composer[data-phase='recording'] .cancel {
  translate: calc(-1 * (var(--pill) / 2 + var(--gap) + var(--size) / 2)) 0;
}
.composer[data-phase='recording'] .send {
  translate: calc(var(--pill) / 2 + var(--gap) + var(--size) / 2) 0;
}

.timer {
  position: absolute;
  top: calc(100% + 0.6em);
  left: 50%;
  font-size: 0.78em;
  font-weight: 600;
  color: var(--red);
  font-variant-numeric: tabular-nums;
  opacity: 0;
  translate: -50% -0.3em;
  transition:
    opacity 0.2s ease,
    translate 0.3s var(--ease-out);
}
.composer[data-phase='recording'] .timer {
  opacity: 1;
  translate: -50% 0;
}

/* Where the bars' levels come from, and the switch to the microphone. */
.source {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.6em 0.75em;
  max-width: 30em;
  margin-top: 3.75em;
  font-size: 0.8em;
  text-align: center;
}
.source .toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  height: 2.1em;
  padding: 0 0.85em 0 0.6em;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: var(--shadow);
  font-weight: 600;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.toggle .micIcon {
  width: 1.15em;
  height: 1.15em;
}
.toggle[aria-pressed='true'] {
  background: var(--accent);
  color: var(--accent-ink);
}
.toggle:disabled {
  cursor: progress;
  opacity: 0.6;
}
.toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.caption {
  margin: 0;
  line-height: 1.4;
  color: var(--ink-muted);
}

.micIcon {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}

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

@keyframes pop {
  from {
    opacity: 0;
    scale: 0.5;
  }
}
@keyframes grow {
  from {
    scale: 1 0;
  }
}

/* Final states at once. Animations still run, for no time, so their end events fire. */
@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition: none !important;
  }
}
```

### voice-note/signal.ts

```tsx
/*
 * Where the recording's bars get their levels: a simulated voice, or the reader's microphone.
 * Either one gives a level between 0 and 1, read once a frame while recording.
 *
 * The microphone only feeds an analyser that measures how loud it is: nothing is recorded, kept,
 * played back or sent anywhere, and the stream stops as soon as the recording ends.
 */

export type LevelSource = {
  /** How loud it is now, from 0 to 1. */
  level: (now: number) => number;
  /** Lets go of whatever the source holds. */
  close: () => void;
};

/** A small seeded random generator (mulberry32), so a take can be replayed. */
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;
  };
}

/** A stretch of the simulated voice: a syllable (with its peak) or a pause (peak 0). */
type Sound = { start: number; end: number; peak: number };

/**
 * A voice, simulated: words of one to four syllables, each a short swell of sound, with brief
 * gaps between syllables and longer pauses between words, plus a little breath noise.
 */
export function simulatedVoice(seed = Math.random() * 2 ** 32): LevelSource {
  const next = random(seed);
  const between = (low: number, high: number) => low + (high - low) * next();
  let queue: Sound[] = [];
  let sound: Sound = { start: 0, end: 0, peak: 0 };

  /** Queues the next word and the pause after it, from `start` (ms). */
  const say = (start: number) => {
    let at = start;
    const syllables = 1 + Math.floor(next() * 4);
    for (let i = 0; i < syllables; i += 1) {
      const length = between(110, 250);
      // The first syllable of a word carries the stress.
      queue.push({
        start: at,
        end: at + length,
        peak: i === 0 ? between(0.75, 1) : between(0.4, 0.85),
      });
      at += length;
      const gap = between(20, 70);
      queue.push({ start: at, end: at + gap, peak: 0 });
      at += gap;
    }
    // Now and then, a longer breath.
    const pause = next() < 0.2 ? between(380, 560) : between(110, 300);
    queue.push({ start: at, end: at + pause, peak: 0 });
  };

  return {
    level(now) {
      if (now >= sound.end) {
        queue = queue.filter((item) => item.end > now);
        if (!queue.length) say(Math.max(now, sound.end));
        sound = queue.shift() ?? sound;
      }
      const progress = Math.min(1, Math.max(0, (now - sound.start) / (sound.end - sound.start)));
      const swell = Math.sin(Math.PI * progress) ** 0.6;
      const breath = 0.03 + 0.05 * next();
      return Math.min(1, sound.peak * swell * between(0.85, 1) + breath);
    },
    close() {},
  };
}

/**
 * Opens the microphone, for its loudness only: the stream feeds an analyser and nothing else.
 * Call it from a click or a key press: some browsers start audio only then.
 */
export function openMicrophone(): Promise<LevelSource> {
  let context: AudioContext;
  try {
    if (!navigator.mediaDevices?.getUserMedia) throw new Error('No microphone access here');
    // Made at once, while the click still counts as the reader's gesture.
    context = new AudioContext();
  } catch (error) {
    return Promise.reject(error);
  }
  return navigator.mediaDevices.getUserMedia({ audio: true }).then(
    (stream) => {
      const analyser = context.createAnalyser();
      analyser.fftSize = 1024;
      context.createMediaStreamSource(stream).connect(analyser);
      const samples = new Float32Array(analyser.fftSize);
      return {
        level() {
          analyser.getFloatTimeDomainData(samples);
          let sum = 0;
          for (const sample of samples) sum += sample * sample;
          // The root mean square, lifted: speech sits around 0.02 to 0.2.
          return Math.min(1, (Math.sqrt(sum / samples.length) * 8) ** 0.7);
        },
        close() {
          stream.getTracks().forEach((track) => track.stop());
          void context.close();
        },
      };
    },
    (error: unknown) => {
      void context.close();
      throw error;
    },
  );
}
```

### voice-note/ghost-pointer.tsx

```tsx
'use client';

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

/*
 * 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. Stopping the demo (the reader takes over, or it scrolls away) ends the step in
 * progress at once: its promise rejects, which ends the script right there, and the pointer
 * fades out.
 */

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;
  /** Presses 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 {}

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

/** Where the arrow's tip is, in its 20 × 20 box. */
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: SVGSVGElement,
  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.8, 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 moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    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) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

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

/** Its colours are the stage's tokens (voice-note.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### voice-note/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 };
}
```