# Composer states

> A chat composer in four states: a one-line pill, a box that grows as the message wraps, a dictation row with a live waveform, and tools riding as chips.

One composer that moves between its states. Empty, it is a pill with a +, the model picker, a microphone and send; as the message wraps it springs open into a box, the text above and the buttons below. The + attaches tools as tinted chips that pop in and out. The microphone morphs the row into dictation: the + turns into a cancel ×, the microphone into a stop square, and a dotted line comes alive as a simulated voice writes into the message. Enter sends, and the box springs back down.

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

Category: AI.

## Anatomy

1. **Shell**: A pill while the message fits beside the buttons, a box once it doesn’t. Its height springs from a foot that stays put, so it grows upwards; corners stay 26 px in both.
2. **Message**: A textarea measured by a hidden copy of itself: one line inline, up to six stacked, then it scrolls. Enter sends, Shift+Enter breaks the line.
3. **Add tools**: Opens the tools menu above the shell; while dictating it turns a quarter round into the cancel ×.
4. **Tool chip**: An attached tool, a tinted icon with a × to remove it. It springs open in its own width; with one in the row the composer stays stacked.
5. **Model picker**: The model’s name and the effort after it, quiet until pressed. Opens one menu with both.
6. **Dictate**: Starts the simulated dictation; while it runs the same button is the stop square, which keeps the words.
7. **Send**: The violet disc. Without words it is `aria-disabled`; sending lifts the message out and the arrow shoots up and back.

## When to use

- The message box at the foot of a chat with an agent, where most messages are one line and some are a paragraph.
- A conversation where the model, its effort and a few tools are picked per message, right where it is written.
- Products where speaking a message is as likely as typing it: dictation lives in the same row.
- A narrow column or a phone: below 140 px of room beside the buttons it simply stays a box.

## When not to use

- A search field or a one-line command. Use a plain input or a command palette; nothing here needs to grow.
- Long-form writing with formatting. Use an editor: the box stops growing at six lines and scrolls.
- Attaching files, mentions or images. This row carries two tool chips; use a composer built for attachments.
- A form field with a label, validation and a submit button of its own.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/composer-states.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
import { ComposerStates } from './composer-states';

export function AgentComposer({ onSend }: { onSend: (message: string) => void }) {
  return (
    <ComposerStates
      defaultModel="nova-3"
      defaultEffort="High"
      onSend={(message, { model, effort, tools }) => {
        console.log(model, effort, tools);
        onSend(message);
      }}
      autoplay={false}
    />
  );
}
```

## States

- **Empty**: A 52 px pill with the placeholder between + and the model picker. Send is there but does nothing.
- **Short message**: Still a pill: the words fit on one line beside the buttons.
- **Grown**: The words no longer fit inline: the text slides to the top and the shell springs taller, a line at a time.
- **Six lines**: The most the box grows to; past it the message scrolls inside.
- **Tools attached**: Two chips in the row. With a chip there, even a short message keeps the box.
- **Tools menu**: Opened by +: tick boxes that attach or remove a tool, the lit row under the pointer or the arrow keys.
- **Model menu**: Models with a line each, and the three efforts as segments under them; the chosen ones ticked.
- **Dictating**: The row morphs: cancel ×, a dotted line, stop. Here the line is held still; live, a voice runs along it and writes.

## How it works

- One shell, two layouts. Inline, the text runs from the + button to 10 px before the model picker; stacked, it takes the top and the buttons a 52 px row of their own. The row is pinned to the shell’s foot in both and the stage holds the foot still, so the shell grows upwards and only the text moves.
- The layout is measured, not guessed: a hidden copy of the message in the same type gives its width on one line (does it fit inline?) and its lines at the stacked width (how tall?). A ResizeObserver keeps both right as the shell changes width. The box grows to six lines, then scrolls; with under 140 px of room beside the buttons (a phone) it stays a box even when empty.
- A change of layout is played from a layout effect before paint: the shell’s height springs from where it was to where it now is, and the text slides from its old left edge to its new one (a FLIP of 38 px). Both are Web Animations whose easing is a damped spring (stiffness 420, damping 34) stepped at 240 Hz and written as a CSS linear() curve; an animation cut short starts the next one from its current height.
- Chips spring open in their own width, so the row makes room for them as they pop from half size (a livelier spring, damping 26), and close in 260 ms before they leave the DOM. With a chip in the row the composer stays stacked.
- Dictation is CSS transitions on one attribute. The + turns 45° and fades as the × turns in from −45°, the microphone shrinks away as the stop square grows, each on a 35 px disc that scales in; the model picker and chips blur 4 px and fade, and become inert; the waveform opens from its middle with a clip-path over 520 ms.
- The waveform is a canvas: a slot every 6.5 px, a 2.5 px dot for silence and a bar up to 20 px for speech, sliding left one slot every 82 ms and smoothly in between. There is no microphone: the voice is built from the words it will say, two bars a syllable, phrases parted by runs of dots, each phrase’s first and last three bars dimmer. Each word is written into the message once its last bar is in view, so the transcript keeps time with the line. Its two colours are read from the canvas’s own CSS, so it follows the scheme.
- Stop keeps what was heard; cancel puts the message back as it was. Sending lifts a copy of the message up 14 px as it blurs and fades, the arrow shoots out of the top of the send button and comes back from below, and the shell springs back to its new height.
- The model menu lists the models with a note each and the effort as a row of three; the + menu lists the tools, checked when attached. Both pop from their button’s edge in 180 ms and keep one highlight that follows the pointer, the arrows or the demo.
- While on screen and untouched, a demo plays a 22 s round through all four states: it types the message, attaches both tools, looks through the models, dictates a sentence, stops, sends and removes the chips. It never moves the focus; its changes never reach the callbacks and are not announced, and any press or key hands the composer to the reader.
- Every colour is a light-dark() token on the root: the night is the composer’s own near-black stage and #262626 shell, the day a white shell with a hairline and a soft lift on a pale stage, the same violet send on both.

## API reference

- **models** (`ComposerModel[]`, default `three sample models`): What the model picker offers: { id, label, note? }.
- **defaultModel** (`string`, default `the first model`): The model chosen at first.
- **defaultEffort** (`'Low' | 'Medium' | 'High'`, default `'High'`): The effort chosen at first, shown after the model’s name.
- **onModelChange** (`(model: string, effort: ComposerEffort) => void`): Called when the reader picks another model or effort.
- **defaultTools** (`('web' | 'visual')[]`, default `[]`): Tools attached at first, shown as chips.
- **onToolsChange** (`(tools: ComposerTool[]) => void`): Called with the attached tools whenever one is attached or removed.
- **defaultValue** (`string`, default `''`): The message at first.
- **defaultState** (`ComposerStatesState`): Opens the composer mid-way: menu ('tools' or 'model') with active, its lit row, or dictating, the waveform held still and hearing nothing until the composer is used. Setting it turns the demo off.
- **placeholder** (`string`, default `'Ask about anything…'`): The empty composer’s words.
- **listeningLabel** (`string`, default `'Listening…'`): The empty composer’s words while dictating.
- **dictation** (`string`, default `'Keep it short, and put the fixes first.'`): What the simulated dictation hears, written in word by word in time with the waveform. There is no microphone.
- **onSend** (`(message: string, options: { model, effort, tools }) => void`): Called with the trimmed message, the model, the effort and the tools when the reader sends.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo plays the four states by itself.
- **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.

## Accessibility

- The message is a real textarea named “Message”: Enter sends, Shift+Enter starts a new line. Tab goes from it to the +, the chips’ remove buttons, the model picker, the microphone and send, in the order they are seen.
- The + and model buttons say they open menus and whether they are expanded; Enter, Space or the up arrow opens a menu with the focus on its first or chosen entry. Arrows, Home and End move, Enter picks, Escape closes and returns to the button, Tab closes. Tools are menuitemcheckbox entries and models and efforts menuitemradio ones, each with aria-checked.
- Each chip names its tool to screen readers and has a remove button (“Remove web search”) in a group named “Attached tools”; removing one returns the focus to the message.
- While dictating, the + button becomes “Cancel dictation” and the microphone “Stop dictation”, in the same places, so the focus stays put; the model picker and chips are inert. Send says it is disabled (aria-disabled) while there is nothing to send.
- A polite live region says what changed: a tool attached or removed, the model or effort chosen, listening, dictation stopped or cancelled, a message sent. Focus rings are visible on every button; the waveform and icons are hidden from screen readers.
- Under reduced motion the shell changes size at once, chips and menus appear without popping, the row swaps without a morph, the waveform stands still at its resting line (the words still arrive), and the demo never plays.

## Source

### composer-states/composer-states.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useId,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  EFFORTS,
  MODELS,
  SAMPLE_DICTATION,
  SAMPLE_MESSAGE,
  TOOLS,
  type ComposerEffort,
  type ComposerModel,
  type ComposerTool,
} from './data';
import {
  ArrowUpIcon,
  CheckIcon,
  ChevronIcon,
  CloseIcon,
  GlobeIcon,
  MicIcon,
  PlusIcon,
  ScanIcon,
  StopIcon,
} from './icons';
import { POP, SHELL } from './spring';
import { useAutoplay } from './use-autoplay';
import { playWave, type Wave } from './waveform';
import styles from './composer-states.module.css';

export type { ComposerEffort, ComposerModel, ComposerTool };

/** A state to open in, held still (it turns the demo off): a menu open, or dictation under way. */
export type ComposerStatesState = {
  /** A menu open at first, its row `active` lit (the first, or the chosen model). */
  menu?: 'tools' | 'model';
  active?: number;
  /** Dictating at first, the waveform's line held still until the reader takes over. */
  dictating?: boolean;
};

/*
 * Composer states: the message box of a chat with an agent, which is a one-line pill while the
 * message fits beside its buttons and grows into a box once it does not.
 *
 * - One shell, two layouts. Inline, the text sits between the + button and the model picker; stacked,
 *   it takes the top and the buttons a row of their own below it. The buttons' row is pinned to the
 *   shell's foot in both, so only the text moves: it slides from one place to the other (a FLIP of
 *   its left edge) while the shell's height follows on a spring. The shell grows upwards, from a
 *   foot that stays put.
 * - The layout is measured, not guessed: a hidden copy of the message in the same type gives its
 *   width on one line (does it fit inline?) and its lines at the stacked width (how tall?). A
 *   ResizeObserver keeps both right as the shell changes width. The box grows to six lines, then
 *   scrolls.
 * - Tools from the + menu ride in the buttons' row as tinted chips, which spring open and close in
 *   their own width; with a chip in the row, the composer stays stacked.
 * - Dictation morphs the row: the + turns a quarter round into the cancel ×, the microphone becomes
 *   the stop square, each on a disc that grows in, and the model picker and chips blur away as a
 *   dotted line opens from its middle. A simulated voice runs along the line (waveform.ts) and
 *   writes what it hears into the message, word by word. Stop keeps the words; cancel takes them
 *   back out.
 * - Sending lifts the message out (a ghost of it floats up and fades), the arrow shoots up and
 *   comes back from below, and the shell springs back down.
 * - While on screen and untouched, a demo plays all four states by itself: it types the message,
 *   attaches two tools, glances through the models, dictates, sends and tidies up. Its changes
 *   never reach the callbacks and are not announced. Any press or key hands it to the reader.
 * - 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.
 */

/** One line of text, in px; the pill is a line plus 13 px above and below. */
const LINE = 26;
/** The most lines the box grows to before the text scrolls. */
const MAX_ROWS = 6;
/** Where the text starts inline (after the + button) and stacked. */
const INLINE_START = 54;
const STACKED_START = 16;
/** The room kept between the text and the model picker, inline. */
const INLINE_GAP = 10;
/** The least room for the text inline; with less, the composer is a box even when empty. */
const MIN_INLINE = 140;

type Menu = 'tools' | 'model' | null;
type ChipState = { id: ComposerTool; leaving: boolean };

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

export function ComposerStates({
  models = MODELS,
  defaultModel = models[0]?.id,
  defaultEffort = 'High',
  onModelChange,
  defaultTools = [],
  onToolsChange,
  defaultValue = '',
  placeholder = 'Ask about anything…',
  listeningLabel = 'Listening…',
  dictation = SAMPLE_DICTATION,
  onSend,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** What the model picker offers: { id, label, note? }. */
  models?: ComposerModel[];
  /** The model chosen at first. */
  defaultModel?: string;
  /** The effort chosen at first, shown after the model's name. */
  defaultEffort?: ComposerEffort;
  /** Called when the reader picks another model or effort. */
  onModelChange?: (model: string, effort: ComposerEffort) => void;
  /** Tools attached at first. */
  defaultTools?: ComposerTool[];
  /** Called with the attached tools whenever one is attached or removed. */
  onToolsChange?: (tools: ComposerTool[]) => void;
  /** The message at first. */
  defaultValue?: string;
  /** The empty composer's words. */
  placeholder?: string;
  /** The empty composer's words while dictating. */
  listeningLabel?: string;
  /** What the simulated dictation hears, word by word (there is no microphone). */
  dictation?: string;
  /** Called with the trimmed message, the model, the effort and the tools when the reader sends. */
  onSend?: (
    message: string,
    options: { model: string; effort: ComposerEffort; tools: ComposerTool[] },
  ) => void;
  /** While on screen and untouched, a demo plays the four states by itself. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in a state, held still: a menu open or dictation under way. Turns the demo off. */
  defaultState?: ComposerStatesState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && !defaultState && playing;

  const [text, setText] = useState(defaultValue);
  const [chips, setChips] = useState<ChipState[]>(() =>
    defaultTools.map((id) => ({ id, leaving: false })),
  );
  const [model, setModel] = useState(defaultModel);
  const [effort, setEffort] = useState<ComposerEffort>(defaultEffort);
  const [dictating, setDictating] = useState(!!defaultState?.dictating);
  const [menu, setMenu] = useState<Menu>(defaultState?.menu ?? null);
  const [active, setActive] = useState(
    () =>
      defaultState?.active ??
      (defaultState?.menu === 'model'
        ? Math.max(
            0,
            models.findIndex((entry) => entry.id === defaultModel),
          )
        : 0),
  );
  /** The menu was opened by the reader, so its items take the focus. */
  const [menuFocus, setMenuFocus] = useState(false);
  const [stacked, setStacked] = useState(false);
  const [rows, setRows] = useState(1);
  const [ghost, setGhost] = useState<{ text: string; key: number } | null>(null);
  const [message, setMessage] = useState('');

  const shell = useRef<HTMLDivElement>(null);
  const textBox = useRef<HTMLDivElement>(null);
  const area = useRef<HTMLTextAreaElement>(null);
  const oneLine = useRef<HTMLSpanElement>(null);
  const mirror = useRef<HTMLDivElement>(null);
  const modelButton = useRef<HTMLButtonElement>(null);
  const leadButton = useRef<HTMLButtonElement>(null);
  const micButton = useRef<HTMLButtonElement>(null);
  const sendButton = useRef<HTMLButtonElement>(null);
  const arrow = useRef<HTMLSpanElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  /** The message as it was before dictation began, for cancel. */
  const before = useRef(defaultValue);
  /** Opened dictating by defaultState: the line holds still until the reader takes over. */
  const held = useRef(!!defaultState?.dictating);
  /** The demo is at the controls: no callbacks, no announcements. */
  const demoOn = useRef(false);
  const heightAnimation = useRef<Animation | null>(null);
  const lastHeight = useRef(0);
  const wasStacked = useRef(stacked);
  const [inlineEnd, setInlineEnd] = useState(260);
  const groupId = useId();

  const tools = chips.filter((chip) => !chip.leaving).map((chip) => chip.id);
  const chosen = models.find((entry) => entry.id === model) ?? models[0];

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

  // Measure the layout: does the message fit inline, and how many lines does it take stacked?
  const measure = useCallback(() => {
    const box = shell.current;
    const line = oneLine.current;
    const copy = mirror.current;
    const picker = modelButton.current;
    if (!box || !line || !copy || !picker) return;
    const width = box.clientWidth;
    // Inline, the text runs from the + button to INLINE_GAP before the model picker.
    const end = width - picker.offsetLeft + INLINE_GAP;
    setInlineEnd(end);
    const room = width - INLINE_START - end;
    const value = area.current?.value ?? '';
    // Too little room beside the buttons (a phone), and the composer stays a box.
    const fits = room >= MIN_INLINE && !value.includes('\n') && line.offsetWidth <= room - 2;
    copy.style.width = `${width - STACKED_START * 2}px`;
    setRows(Math.min(MAX_ROWS, Math.max(1, Math.round(copy.offsetHeight / LINE))));
    setStacked(!fits);
  }, []);

  const mustStack = dictating || chips.length > 0;
  useLayoutEffect(measure, [text, chips.length, model, effort, measure]);
  useEffect(() => {
    const box = shell.current;
    const picker = modelButton.current;
    if (!box || !picker) return;
    const observer = new ResizeObserver(() => measure());
    observer.observe(box);
    observer.observe(picker);
    return () => observer.disconnect();
  }, [measure]);

  const isStacked = stacked || mustStack;
  const lines = isStacked ? rows : 1;

  // The shell's height springs to its new value, and the text slides from where it was.
  useLayoutEffect(() => {
    const box = shell.current;
    const words = textBox.current;
    if (!box || !words) return;
    const running = heightAnimation.current?.playState === 'running';
    const from = running ? box.offsetHeight : lastHeight.current;
    heightAnimation.current?.cancel();
    const to = box.offsetHeight;
    lastHeight.current = to;
    const moved = wasStacked.current !== isStacked;
    wasStacked.current = isStacked;
    if (reducedMotion() || !from) return;
    const { duration, easing } = SHELL();
    if (Math.abs(from - to) > 0.5) {
      heightAnimation.current = box.animate(
        { height: [`${from}px`, `${to}px`] },
        { duration, easing },
      );
    }
    if (moved) {
      const shift = isStacked ? INLINE_START - STACKED_START : STACKED_START - INLINE_START;
      words.animate({ translate: [`${shift}px 0`, '0 0'] }, { duration, easing });
    }
  }, [isStacked, lines]);

  const report = (next: ComposerTool[]) => {
    if (!demoOn.current) onToolsChange?.(next);
  };

  const toggleTool = (id: ComposerTool) => {
    const label = TOOLS.find((tool) => tool.id === id)?.label ?? id;
    if (tools.includes(id)) {
      removeTool(id);
      return;
    }
    setChips((now) => [...now.filter((chip) => chip.id !== id), { id, leaving: false }]);
    report([...tools, id]);
    announce(`${label} attached`);
  };

  const removeTool = (id: ComposerTool) => {
    const label = TOOLS.find((tool) => tool.id === id)?.label ?? id;
    setChips((now) => now.map((chip) => (chip.id === id ? { ...chip, leaving: true } : chip)));
    report(tools.filter((tool) => tool !== id));
    announce(`${label} removed`);
  };

  const dropChip = useCallback((id: ComposerTool) => {
    setChips((now) => now.filter((chip) => !(chip.id === id && chip.leaving)));
  }, []);

  const send = () => {
    const words = text.trim();
    if (!words) return;
    if (dictating) setDictating(false);
    if (!reducedMotion()) setGhost({ text, key: Date.now() });
    setText('');
    if (!demoOn.current) onSend?.(words, { model, effort, tools });
    announce('Message sent');
    const glyph = arrow.current;
    if (glyph && !reducedMotion()) {
      glyph.animate(
        [
          { transform: 'translateY(0)', opacity: 1 },
          { transform: 'translateY(-16px)', opacity: 0, offset: 0.45 },
          { transform: 'translateY(16px)', opacity: 0, offset: 0.46 },
          { transform: 'translateY(0)', opacity: 1 },
        ],
        { duration: 420, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
      );
    }
  };

  const startDictation = () => {
    setMenu(null);
    before.current = text;
    setDictating(true);
    announce(listeningLabel.replace(/…$/, ''));
  };
  const stopDictation = (keep: boolean) => {
    setDictating(false);
    if (!keep) setText(before.current);
    announce(keep ? 'Dictation stopped' : 'Dictation cancelled');
  };

  // The waveform plays while dictating, its voice writing into the message.
  const hear = useEffectEvent((word: string) => {
    setText((now) => {
      const spaced = now && !/\s$/.test(now) && !word.startsWith(' ') ? ` ${word}` : word;
      return now ? now + spaced : spaced.trimStart();
    });
  });
  useEffect(() => {
    const element = canvas.current;
    if (!dictating || !element) return;
    const wave: Wave = playWave(element, {
      // Held still: the line rests and hears nothing, so the message stays as it was given.
      text: held.current ? '' : dictation,
      live: !reducedMotion() && !held.current,
      onWord: hear,
    });
    return () => wave.stop();
  }, [dictating, dictation]);

  // Menus: the reader's open the focus moves in; any press outside closes them.
  useEffect(() => {
    if (!menu || !menuFocus) return;
    const items = menuRef.current?.querySelectorAll<HTMLElement>('[data-item]');
    items?.[active]?.focus();
  }, [menu, menuFocus, active]);
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      const target = event.target as Node;
      if (menuRef.current?.contains(target)) return;
      if (leadButton.current?.contains(target) || modelButton.current?.contains(target)) return;
      setMenu(null);
    };
    // Escape closes a menu opened by the pointer too, whose items never took the focus.
    const escape = (event: KeyboardEvent) => {
      if (event.key === 'Escape') setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    document.addEventListener('keydown', escape);
    return () => {
      document.removeEventListener('pointerdown', close);
      document.removeEventListener('keydown', escape);
    };
  }, [menu]);

  const openMenu = (which: Exclude<Menu, null>, focus: boolean) => {
    if (menu === which) {
      setMenu(null);
      return;
    }
    setMenu(which);
    setMenuFocus(focus);
    if (which === 'model') setActive(Math.max(0, models.indexOf(chosen)));
    else setActive(0);
  };

  const menuItems = menu === 'tools' ? TOOLS.length : models.length + EFFORTS.length;
  const closeMenu = (refocus: boolean) => {
    const which = menu;
    setMenu(null);
    if (refocus) (which === 'model' ? modelButton : leadButton).current?.focus();
  };
  const pickModel = (index: number) => {
    if (index < models.length) {
      const next = models[index];
      setModel(next.id);
      if (!demoOn.current) onModelChange?.(next.id, effort);
      announce(`Model: ${next.label}`);
    } else {
      const next = EFFORTS[index - models.length];
      setEffort(next);
      if (!demoOn.current) onModelChange?.(model, next);
      announce(`Effort: ${next}`);
    }
  };
  const onMenuKey = (event: ReactKeyboardEvent) => {
    const last = menuItems - 1;
    const keys: Record<string, () => void> = {
      ArrowDown: () => setActive((at) => (at >= last ? 0 : at + 1)),
      ArrowRight: () => setActive((at) => (at >= last ? 0 : at + 1)),
      ArrowUp: () => setActive((at) => (at <= 0 ? last : at - 1)),
      ArrowLeft: () => setActive((at) => (at <= 0 ? last : at - 1)),
      Home: () => setActive(0),
      End: () => setActive(last),
      Escape: () => closeMenu(true),
      Tab: () => setMenu(null),
    };
    const action = keys[event.key];
    if (!action) return;
    if (event.key !== 'Tab') event.preventDefault();
    action();
  };

  const onAreaKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
      event.preventDefault();
      send();
    }
  };

  // Tap the bare parts of the composer to write in it.
  const focusText = (event: ReactPointerEvent) => {
    if (event.target === event.currentTarget) {
      event.preventDefault();
      area.current?.focus();
    }
  };

  // The demo. Every step is a state change, as the reader's own would make; it never moves the
  // focus. Its buttons show a press by a data attribute.
  const reset = useEffectEvent(() => {
    setText(defaultValue);
    setChips(defaultTools.map((id) => ({ id, leaving: false })));
    setModel(defaultModel);
    setEffort(defaultEffort);
    setDictating(false);
    setGhost(null);
    setMenu((now) => (menuFocus ? now : null));
  });
  const step = useEffectEvent(
    (
      action:
        | 'type'
        | 'tools'
        | 'pickTool'
        | 'model'
        | 'walk'
        | 'close'
        | 'mic'
        | 'stop'
        | 'send'
        | 'remove',
      value?: number | string,
    ) => {
      if (action === 'type') setText(String(value));
      if (action === 'tools') openMenu('tools', false);
      if (action === 'model') openMenu('model', false);
      if (action === 'walk') setActive(Number(value));
      if (action === 'close') setMenu(null);
      if (action === 'pickTool') {
        toggleTool(TOOLS[Number(value)].id);
        setMenu(null);
      }
      if (action === 'mic') startDictation();
      if (action === 'stop') stopDictation(true);
      if (action === 'send') send();
      if (action === 'remove') removeTool(TOOLS[Number(value)].id);
    },
  );
  useEffect(() => {
    if (!demo) return;
    demoOn.current = true;
    let stopped = false;
    const timers = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          if (stopped) reject(new Error('stopped'));
          else resolve();
        }, ms);
        timers.add(timer);
      });
    const press = async (button: HTMLElement | null | undefined) => {
      button?.setAttribute('data-press', '');
      await wait(140);
      button?.removeAttribute('data-press');
    };
    const chipClose = (index: number) =>
      shell.current?.querySelector<HTMLElement>(`[data-chip="${TOOLS[index].id}"] button`);

    (async () => {
      for (;;) {
        reset();
        await wait(1300);
        // Typed: the pill grows into the box as the words wrap.
        for (let i = 1; i <= SAMPLE_MESSAGE.length; i++) {
          step('type', SAMPLE_MESSAGE.slice(0, i));
          await wait(SAMPLE_MESSAGE[i - 1] === ' ' ? 70 : 34);
        }
        await wait(900);
        // Tools: two chips from the + menu.
        for (const index of [0, 1]) {
          await press(leadButton.current);
          step('tools');
          await wait(520);
          step('walk', index);
          await wait(420);
          step('pickTool', index);
          await wait(760);
        }
        // A look through the models, then back as it was.
        await press(modelButton.current);
        step('model');
        await wait(560);
        for (const index of [1, 2, 3 + 2]) {
          step('walk', index);
          await wait(380);
        }
        step('close');
        await wait(700);
        // Dictation: the row morphs, the voice writes, stop.
        await press(micButton.current);
        step('mic');
        await wait(5600);
        await press(micButton.current);
        step('stop');
        await wait(1100);
        // Send, then tidy the chips away.
        await press(sendButton.current);
        step('send');
        await wait(1100);
        for (const index of [1, 0]) {
          await press(chipClose(index));
          step('remove', index);
          await wait(520);
        }
        await wait(1200);
      }
    })().catch(() => {});

    return () => {
      stopped = true;
      for (const timer of timers) window.clearTimeout(timer);
      demoOn.current = false;
      shell.current
        ?.querySelectorAll('[data-press]')
        .forEach((element) => element.removeAttribute('data-press'));
      reset();
    };
  }, [demo]);

  const empty = !text;
  const showPlaceholder = empty && !ghost;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={() => {
        held.current = false;
        take();
      }}
      onKeyDownCapture={() => {
        held.current = false;
        take();
      }}
    >
      <div className={styles.stage}>
        <div className={styles.frame}>
          <div
            ref={shell}
            className={styles.shell}
            data-part="shell"
            data-stacked={isStacked}
            data-dictating={dictating}
            style={{ '--inline-end': `${inlineEnd}px` } as CSSProperties}
            onPointerDown={focusText}
          >
            <div className={styles.field} onPointerDown={focusText}>
              <div ref={textBox} className={styles.text}>
                <textarea
                  ref={area}
                  data-part="message"
                  className={styles.area}
                  value={text}
                  rows={1}
                  aria-label="Message"
                  readOnly={dictating}
                  spellCheck={false}
                  style={{ height: lines * LINE }}
                  onChange={(event) => setText(event.target.value)}
                  onKeyDown={onAreaKey}
                />
                {showPlaceholder && (
                  <span className={styles.placeholder} data-part="placeholder" aria-hidden="true">
                    {dictating ? listeningLabel : placeholder}
                  </span>
                )}
                {ghost && (
                  <div
                    key={ghost.key}
                    className={styles.ghost}
                    aria-hidden="true"
                    onAnimationEnd={() => setGhost(null)}
                  >
                    {ghost.text}
                  </div>
                )}
              </div>
              {/* The measures: the message on one line, and wrapped at the stacked width. */}
              <span ref={oneLine} className={styles.oneLine} aria-hidden="true">
                {text || ' '}
              </span>
              <div ref={mirror} className={styles.mirror} aria-hidden="true">
                {text}
                {'​'}
              </div>
            </div>

            <div className={styles.bar}>
              <button
                ref={leadButton}
                type="button"
                className={`${styles.round} ${styles.lead}`}
                data-part="lead"
                aria-label={dictating ? 'Cancel dictation' : 'Add tools'}
                aria-haspopup={dictating ? undefined : 'menu'}
                aria-expanded={dictating ? undefined : menu === 'tools'}
                onClick={(event) =>
                  dictating ? stopDictation(false) : openMenu('tools', event.detail === 0)
                }
                onKeyDown={(event) => {
                  if (!dictating && event.key === 'ArrowUp') {
                    event.preventDefault();
                    if (menu !== 'tools') openMenu('tools', true);
                  }
                }}
              >
                <span className={styles.disc} aria-hidden="true" />
                <PlusIcon className={`${styles.icon} ${styles.plus}`} />
                <CloseIcon className={`${styles.icon} ${styles.cross}`} />
              </button>

              <div className={styles.tools} inert={dictating}>
                {chips.length > 0 && (
                  <div className={styles.chips} role="group" aria-label="Attached tools">
                    {chips.map((chip) => (
                      <Chip
                        key={chip.id}
                        id={chip.id}
                        leaving={chip.leaving}
                        onRemove={() => {
                          removeTool(chip.id);
                          area.current?.focus();
                        }}
                        onGone={dropChip}
                      />
                    ))}
                  </div>
                )}
                <button
                  ref={modelButton}
                  type="button"
                  className={styles.model}
                  data-part="model"
                  aria-haspopup="menu"
                  aria-expanded={menu === 'model'}
                  aria-label={`Model: ${chosen?.label}, ${effort} effort`}
                  onClick={(event) => openMenu('model', event.detail === 0)}
                  onKeyDown={(event) => {
                    if (event.key === 'ArrowUp') {
                      event.preventDefault();
                      if (menu !== 'model') openMenu('model', true);
                    }
                  }}
                >
                  <span>
                    {chosen?.label}
                    <span className={styles.modelEffort}> {effort}</span>
                  </span>
                  <ChevronIcon className={`${styles.icon} ${styles.chevron}`} />
                </button>
              </div>

              <div className={styles.wave} data-part="wave" aria-hidden="true">
                <canvas ref={canvas} className={styles.canvas} />
              </div>

              <button
                ref={micButton}
                type="button"
                className={`${styles.round} ${styles.mic}`}
                data-part="mic"
                aria-label={dictating ? 'Stop dictation' : 'Dictate'}
                onClick={() => (dictating ? stopDictation(true) : startDictation())}
              >
                <span className={styles.disc} aria-hidden="true" />
                <MicIcon className={`${styles.icon} ${styles.micGlyph}`} />
                <StopIcon className={`${styles.icon} ${styles.stopGlyph}`} />
              </button>
              <button
                ref={sendButton}
                type="button"
                className={styles.send}
                data-part="send"
                aria-label="Send message"
                aria-disabled={!text.trim() || undefined}
                onClick={send}
              >
                <span ref={arrow} className={styles.arrow}>
                  <ArrowUpIcon className={styles.icon} />
                </span>
              </button>
            </div>
          </div>

          {menu === 'tools' && (
            <div
              ref={menuRef}
              className={`${styles.menu} ${styles.toolsMenu}`}
              data-part="menu"
              role="menu"
              aria-label="Tools"
              onKeyDown={onMenuKey}
            >
              {TOOLS.map((tool, index) => {
                const on = tools.includes(tool.id);
                const Icon = tool.id === 'web' ? GlobeIcon : ScanIcon;
                return (
                  <button
                    key={tool.id}
                    type="button"
                    role="menuitemcheckbox"
                    aria-checked={on}
                    tabIndex={-1}
                    data-item
                    data-part="item"
                    data-active={index === active || undefined}
                    className={styles.item}
                    onPointerMove={() => setActive(index)}
                    onClick={() => {
                      toggleTool(tool.id);
                      closeMenu(menuFocus);
                    }}
                  >
                    <Icon className={`${styles.icon} ${styles.itemIcon}`} />
                    <span className={styles.itemText}>
                      <span className={styles.itemLabel}>{tool.label}</span>
                      <span className={styles.itemNote}>{tool.detail}</span>
                    </span>
                    <CheckIcon className={`${styles.icon} ${styles.check}`} />
                  </button>
                );
              })}
            </div>
          )}

          {menu === 'model' && (
            <div
              ref={menuRef}
              className={`${styles.menu} ${styles.modelMenu}`}
              data-part="menu"
              role="menu"
              aria-label="Model"
              onKeyDown={onMenuKey}
            >
              <div role="group" aria-labelledby={groupId} className={styles.group}>
                <span className={styles.groupLabel} id={groupId} aria-hidden="true">
                  Model
                </span>
                {models.map((entry, index) => (
                  <button
                    key={entry.id}
                    type="button"
                    role="menuitemradio"
                    aria-checked={entry.id === model}
                    tabIndex={-1}
                    data-item
                    data-part="item"
                    data-active={index === active || undefined}
                    className={styles.item}
                    onPointerMove={() => setActive(index)}
                    onClick={() => {
                      pickModel(index);
                      closeMenu(menuFocus);
                    }}
                  >
                    <span className={styles.itemText}>
                      <span className={styles.itemLabel}>{entry.label}</span>
                      {entry.note && <span className={styles.itemNote}>{entry.note}</span>}
                    </span>
                    <CheckIcon className={`${styles.icon} ${styles.check}`} />
                  </button>
                ))}
              </div>
              <div role="group" aria-label="Effort" className={styles.group}>
                <span className={styles.groupLabel} aria-hidden="true">
                  Effort
                </span>
                <div className={styles.efforts}>
                  {EFFORTS.map((level, i) => {
                    const index = models.length + i;
                    return (
                      <button
                        key={level}
                        type="button"
                        role="menuitemradio"
                        aria-checked={level === effort}
                        tabIndex={-1}
                        data-item
                        data-part="effort"
                        data-active={index === active || undefined}
                        className={styles.effort}
                        onPointerMove={() => setActive(index)}
                        onClick={() => {
                          pickModel(index);
                          closeMenu(menuFocus);
                        }}
                      >
                        {level}
                      </button>
                    );
                  })}
                </div>
              </div>
            </div>
          )}
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A tool riding in the buttons' row: it springs open in its own width, and closes the same way. */
function Chip({
  id,
  leaving,
  onRemove,
  onGone,
}: {
  id: ComposerTool;
  leaving: boolean;
  onRemove: () => void;
  onGone: (id: ComposerTool) => void;
}) {
  const slot = useRef<HTMLSpanElement>(null);
  const tool = TOOLS.find((entry) => entry.id === id);
  const Icon = id === 'web' ? GlobeIcon : ScanIcon;

  useLayoutEffect(() => {
    const element = slot.current;
    if (!element || reducedMotion()) return;
    const width = element.offsetWidth;
    const { duration, easing } = POP();
    const chip = element.firstElementChild as HTMLElement;
    element.animate({ width: ['0px', `${width}px`] }, { duration, easing: SHELL().easing });
    chip.animate(
      { transform: ['scale(0.5)', 'scale(1)'], opacity: [0, 1, 1] },
      { duration, easing },
    );
  }, []);

  useEffect(() => {
    const element = slot.current;
    if (!leaving || !element) return;
    if (reducedMotion()) {
      onGone(id);
      return;
    }
    const chip = element.firstElementChild as HTMLElement;
    const timing = {
      duration: 220,
      easing: 'cubic-bezier(0.4, 0, 0.7, 0.2)',
      fill: 'forwards',
    } as const;
    chip.animate({ transform: ['scale(1)', 'scale(0.5)'], opacity: [1, 0] }, timing);
    const closing = element.animate(
      { width: [`${element.offsetWidth}px`, '0px'] },
      { ...timing, duration: 260, easing: 'cubic-bezier(0.5, 0, 0.3, 1)' },
    );
    closing.onfinish = () => onGone(id);
    return () => closing.cancel();
  }, [leaving, id, onGone]);

  return (
    <span ref={slot} className={styles.chipSlot} data-chip={id} data-leaving={leaving || undefined}>
      <span className={styles.chip} data-part="chip">
        <Icon className={`${styles.icon} ${styles.chipIcon}`} />
        <span className={styles.hidden}>{tool?.label}</span>
        <button
          type="button"
          className={styles.chipRemove}
          aria-label={`Remove ${tool?.label.toLowerCase()}`}
          disabled={leaving}
          onClick={onRemove}
        >
          <CloseIcon className={styles.icon} width={1.5} />
        </button>
      </span>
    </span>
  );
}
```

### composer-states/composer-states.module.css

```css
/* Composer states (composer-states.tsx): a near-black stage with the composer low in its middle,
   568 px wide at most, its menus opening above it; by day a soft white stage and a white composer
   with a hairline and a soft lift.

   Sizes: the pill is 52 px tall, a 26 px line with 13 px above and below, and its round buttons
   are 36 px, 8 px in from its ends. Stacked, the text keeps that first line and the buttons' row
   (52 px) sits 11 px under the last one. The corners are 26 px in both, so the pill's ends are
   round and the box's corners keep the same curve as it grows.

   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). Each shadow is one list for both schemes, each layer coloured for one
   of them and clear in the other.

   Motion: the shell's height and the text's slide are springs played from the script (Web
   Animations); the row's morph into dictation is CSS transitions on data-dictating; the chips
   spring in their own width; the waveform is a canvas. */

.root {
  /* The stage, the composer, the menus, and a hovered button. */
  --canvas: light-dark(#f7f7f7, #0a0a0a);
  --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.04));
  --surface: light-dark(#fff, #262626);
  --menu: light-dark(#fff, #262626);
  --hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
  /* The discs under the cancel and stop buttons, and the chosen effort. */
  --disc: light-dark(#ececec, #404040);
  /* Words and icons; quiet words (the placeholder, the model picker). */
  --ink: light-dark(#171717, #fafafa);
  --ink-2: light-dark(#737373, #a3a3a3);
  /* Send, the same violet by day and night. */
  --accent: #615fff;
  --accent-press: #4f4ce8;
  --on-accent: #fff;
  /* The tools' chips: a blue tint, its icon, and its quieter ×. */
  --chip: light-dark(#e4edff, #263a5c);
  --chip-ink: light-dark(#2a5bd7, #c4deff);
  --chip-x: light-dark(#7d9bd0, #7f97b8);
  /* The waveform: speech, and the dots and the dimmer bars. */
  --wave: light-dark(#8c8c8c, #6e6e6e);
  --wave-dim: light-dark(#d2d2d2, #4a4a4a);
  --focus: light-dark(#615fff, #8f8dff);
  /* Shadow layers, each for one scheme only. */
  --ring-day: light-dark(rgb(0 0 0 / 0.07), transparent);
  --lift-day: light-dark(rgb(0 0 0 / 0.05), transparent);
  --ring-night: light-dark(transparent, rgb(255 255 255 / 0.09));
  --lift-night: light-dark(transparent, rgb(0 0 0 / 0.5));
  --shadow-shell: 0 0 0 1px var(--ring-day), 0 1px 2px var(--lift-day), 0 8px 24px var(--lift-day);
  --shadow-menu:
    0 0 0 1px var(--ring-day), 0 0 0 1px var(--ring-night), 0 12px 32px var(--lift-day),
    0 2px 6px var(--lift-day), 0 12px 32px var(--lift-night);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 18px;
  line-height: 26px;
  letter-spacing: -0.1px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) :where(button, textarea) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-align: inherit;
}
:where(.root) :where(button) {
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Every icon is given its size here, whatever the page says about svg. */
.icon {
  display: block;
  flex: none;
  width: 20px;
  height: 20px;
}

/* The composer sits low on the stage, its foot fixed, so it grows upwards and the menus have room
   above it. */
.stage {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  box-sizing: border-box;
  min-height: 440px;
  padding: 20px 20px 104px;
}

.frame {
  position: relative;
  width: 100%;
  max-width: 568px;
}

.shell {
  position: relative;
  overflow: hidden;
  overflow: clip;
  border-radius: 26px;
  background: var(--surface);
  box-shadow: var(--shadow-shell);
  cursor: text;
}

/* The text: inline between the + button and the model picker, stacked above the buttons' row. */
.field {
  padding: 13.75px var(--inline-end, 260px) 12.25px 54px;
}
.shell[data-stacked='true'] .field {
  padding: 13.75px 16px 62.25px;
}
.text {
  position: relative;
}
.area {
  display: block;
  box-sizing: border-box;
  width: 100%;
  min-height: 26px;
  overflow-x: hidden;
  overflow-y: auto;
  resize: none;
  outline: none;
  color: var(--ink);
  caret-color: var(--accent);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  scrollbar-width: none;
}
.placeholder {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  overflow: hidden;
  color: var(--ink-2);
  white-space: nowrap;
  text-overflow: ellipsis;
  pointer-events: none;
}
.shell[data-dictating='true'] .placeholder {
  animation: breathe 1.6s ease-in-out infinite;
}
@keyframes breathe {
  50% {
    opacity: 0.55;
  }
}
/* A sent message lifts out of the box. */
.ghost {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  color: var(--ink);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  pointer-events: none;
  animation: lift 320ms var(--ease-out) forwards;
}
@keyframes lift {
  to {
    opacity: 0;
    transform: translateY(-14px);
    filter: blur(2px);
  }
}
/* The measures, never seen. */
.oneLine,
.mirror {
  position: absolute;
  top: 0;
  left: 0;
  visibility: hidden;
  pointer-events: none;
}
.oneLine {
  white-space: pre;
}
.mirror {
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

/* The buttons' row, pinned to the shell's foot. It lets presses through between its buttons, to
   the text under it. */
.bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  height: 52px;
  padding: 0 8px;
  pointer-events: none;
}
.bar > *,
.tools > * {
  pointer-events: auto;
}

.round {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  color: var(--ink);
  transition:
    background-color 150ms,
    transform 160ms var(--ease-out);
}
.round > * {
  grid-area: 1 / 1;
}
@media (hover: hover) {
  .round:hover {
    background-color: var(--hover);
  }
}
.round:active,
.round[data-press] {
  transform: scale(0.92);
}
/* The disc that grows in under cancel and stop: 35 px. */
.disc {
  width: 35px;
  height: 35px;
  border-radius: 50%;
  background: var(--disc);
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 200ms,
    transform 320ms var(--ease-out);
}
.plus,
.cross,
.micGlyph,
.stopGlyph {
  transition:
    opacity 180ms,
    transform 320ms var(--ease-out);
}
.cross {
  opacity: 0;
  transform: rotate(-45deg);
}
.stopGlyph {
  opacity: 0;
  transform: scale(0.5);
}
.shell[data-dictating='true'] .disc {
  opacity: 1;
  transform: none;
}
.shell[data-dictating='true'] .plus {
  opacity: 0;
  transform: rotate(45deg);
}
.shell[data-dictating='true'] .cross {
  opacity: 1;
  transform: none;
}
.shell[data-dictating='true'] .micGlyph {
  opacity: 0;
  transform: scale(0.5);
}
.shell[data-dictating='true'] .stopGlyph {
  opacity: 1;
  transform: none;
}

/* The chips and the model picker, which blur away while dictating. */
.tools {
  display: flex;
  flex: 1;
  align-items: center;
  min-width: 0;
  height: 100%;
  pointer-events: none;
  transition:
    opacity 200ms,
    filter 240ms,
    transform 320ms var(--ease-out);
}
.shell[data-dictating='true'] .tools {
  opacity: 0;
  filter: blur(4px);
  transform: scale(0.97);
}

/* Chips that outgrow the row scroll sideways under the model picker's edge. */
.chips {
  display: flex;
  flex: 0 1 auto;
  align-items: center;
  min-width: 0;
  margin: 0 6px 0 10px;
  overflow-x: auto;
  scrollbar-width: none;
}
.chips::-webkit-scrollbar {
  display: none;
}
.chipSlot {
  display: flex;
  flex: none;
  padding-right: 5px;
}
.chip {
  display: flex;
  align-items: center;
  gap: 4px;
  box-sizing: border-box;
  height: 31px;
  padding: 0 9px 0 8px;
  border-radius: 16px;
  background: var(--chip);
  color: var(--chip-ink);
  cursor: default;
}
.chipRemove {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  color: var(--chip-x);
  transition: color 150ms;
}
@media (hover: hover) {
  .chipRemove:hover {
    color: var(--chip-ink);
  }
}
.chipRemove[data-press] {
  color: var(--chip-ink);
}

.model {
  display: flex;
  flex: none;
  align-items: center;
  gap: 6.4px;
  height: 36px;
  margin: 0 10.5px 0 auto;
  padding: 0 8px 0 12px;
  border-radius: 18px;
  color: var(--ink-2);
  white-space: nowrap;
  transition:
    background-color 150ms,
    color 150ms;
}
@media (hover: hover) {
  .model:hover {
    background-color: var(--hover);
    color: var(--ink);
  }
}
.model[aria-expanded='true'],
.model[data-press] {
  background-color: var(--hover);
  color: var(--ink);
}
.mic {
  margin-right: 4px;
}

/* Send: the violet disc. Its arrow shoots up and comes back when a message goes. */
.send {
  display: grid;
  place-items: center;
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--on-accent);
  transition:
    background-color 150ms,
    transform 160ms var(--ease-out);
}
.send:active:not([aria-disabled]),
.send[data-press] {
  background-color: var(--accent-press);
  transform: scale(0.9);
}
.send[aria-disabled] {
  cursor: default;
}
.arrow {
  display: grid;
}

/* The waveform, between cancel and stop; it opens from its middle. */
.wave {
  position: absolute;
  top: 14px;
  left: 59px;
  right: 97px;
  height: 24px;
  opacity: 0;
  clip-path: inset(0 50%);
  pointer-events: none;
  transition:
    opacity 160ms,
    clip-path 160ms;
}
.shell[data-dictating='true'] .wave {
  opacity: 1;
  clip-path: inset(0 0);
  transition:
    opacity 240ms 80ms,
    clip-path 520ms 60ms var(--ease-out);
}
.canvas {
  display: block;
  width: 100%;
  height: 24px;
  border: 0 solid var(--wave-dim);
  color: var(--wave);
}

/* Menus open 8 px above their buttons, and pop from that edge. */
.menu {
  position: absolute;
  bottom: 52px;
  z-index: 2;
  box-sizing: border-box;
  width: min(248px, 100%);
  padding: 6px;
  border-radius: 18px;
  background: var(--menu);
  box-shadow: var(--shadow-menu);
  font-size: 14.5px;
  line-height: 20px;
  letter-spacing: -0.1px;
  animation: pop 180ms var(--ease-out);
}
.toolsMenu {
  left: 0;
  transform-origin: 26px 100%;
}
.modelMenu {
  right: max(0px, min(94.5px, 100% - 248px));
  transform-origin: calc(100% - 40px) 100%;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.95);
  }
}
.item {
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  min-height: 40px;
  padding: 6px 10px;
  border-radius: 12px;
  color: var(--ink);
}
.item[data-active] {
  background: var(--hover);
}
.root .item:focus-visible,
.root .effort:focus-visible {
  outline-offset: -2px;
}
.itemIcon {
  color: var(--chip-ink);
}
.itemText {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
}
.itemLabel {
  font-weight: 500;
}
.itemNote {
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 17px;
}
.check {
  width: 16px;
  height: 16px;
  color: var(--accent);
  opacity: 0;
}
[aria-checked='true'] > .check {
  opacity: 1;
}
.group + .group {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--hover);
}
.groupLabel {
  display: block;
  padding: 4px 10px 4px;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 18px;
}
.efforts {
  display: flex;
  gap: 4px;
  padding: 2px 4px 4px;
}
.effort {
  flex: 1;
  height: 32px;
  border-radius: 10px;
  color: var(--ink-2);
  text-align: center;
  transition:
    background-color 150ms,
    color 150ms;
}
.effort[data-active] {
  background: var(--hover);
  color: var(--ink);
}
.effort[aria-checked='true'] {
  background: var(--disc);
  color: var(--ink);
}

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

/* Narrow: a lower stage, and the model picker says only the model's name. */
@container (max-width: 520px) {
  .stage {
    min-height: 420px;
    padding: 16px 12px 96px;
  }
  .modelEffort {
    display: none;
  }
  .model {
    margin-right: 4px;
    padding-left: 10px;
    font-size: 16px;
  }
}

/* A phone: the model picker keeps only the model's name, so a chip fits beside it. */
@container (max-width: 400px) {
  .model {
    margin-right: 2px;
    padding: 0 8px;
    font-size: 15px;
  }
  .chevron {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition-duration: 0s !important;
    transition-delay: 0s !important;
    animation: none !important;
  }
}
```

### composer-states/waveform.ts

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

/*
 * The dictation row's waveform: a dotted line that a voice raises into bars. Slots sit every
 * 6.5 px; silence is a 2.5 px dot, speech a 2.5 px bar up to 20 px tall. The newest slot comes in
 * at the right edge and the line slides left one slot per beat, smoothly between beats.
 *
 * There is no microphone. The voice is simulated from the words it is meant to say: each word
 * gives two bars a syllable, phrases are parted by a run of dots, and each word is handed to
 * `onWord` once its last bar is in view, so the transcript keeps time with the line. Within a phrase
 * the bars are bright, but for the three at either end, which trail in and out dimmer.
 *
 * Drawn on a canvas: one small layer, repainted each frame while it is on screen. Its two colours
 * are read from the canvas's own CSS (color and border-color), so light or dark follows the page.
 */

/** Where the slots fall, in CSS px. */
export const PITCH = 6.5;
const BAR = 2.5;
const DOT = 2.5;
/** The tallest bar; the canvas is drawn a little taller so its round ends are never clipped. */
const TALL = 20.2;
export const WAVE_HEIGHT = 24;
/** One slot per beat. */
const BEAT = 82;
/** Bar heights a syllable draws from, quiet to loud. */
const HEIGHTS = [5, 10, 12.7, 17.7, 20.2];

type Slot = { h: number; bright: boolean };

const silence = (): Slot => ({ h: 0, bright: false });

/**
 * The resting line: a phrase just heard, left of middle, the rest dots. Shown at the start of a
 * still frame, and all the time under reduced motion. Heights by slot, from the left.
 */
const STILL: [number, number, boolean][] = [
  [28, 5, false],
  [29, 10, false],
  [30, 10, false],
  [31, 10, true],
  [32, 17.7, true],
  [33, 19.7, true],
  [34, 17.7, true],
  [35, 12.7, true],
  [36, 12.7, true],
  [37, 12.7, true],
  [38, 17.7, true],
  [39, 12.7, true],
  [40, 20.2, true],
  [41, 12.7, true],
  [42, 12.7, true],
  [43, 12.7, false],
  [44, 12.7, false],
  [45, 5, false],
];

/** The still line for `count` slots, its phrase kept where it sits in a 64-slot row. */
export function stillLine(count: number): Slot[] {
  const line = Array.from({ length: count }, silence);
  const shift = Math.round((count - 64) / 2);
  for (const [at, h, bright] of STILL) {
    const i = at + shift;
    if (i >= 0 && i < count) line[i] = { h, bright };
  }
  return line;
}

/** A tiny seeded random, so every take of the demo sounds the same. */
function random(seed: number) {
  let s = seed >>> 0;
  return () => {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 2 ** 32;
  };
}

/**
 * The voice: the slots to come, built a phrase at a time from the words, each word's last slot
 * carrying the word itself.
 */
function voice(text: string, seed = 7) {
  const rand = random(seed);
  const phrases = text
    .split(/(?<=[,.;:!?])\s+/)
    .map((phrase) => phrase.split(/\s+/).filter(Boolean))
    .filter((words) => words.length);
  const queue: (Slot & { word?: string })[] = [];
  const pause = (n: number) => {
    for (let i = 0; i < n; i++) queue.push(silence());
  };
  pause(5);
  phrases.forEach((words, p) => {
    const start = queue.length;
    words.forEach((word, w) => {
      const syllables = Math.max(1, Math.round(word.replace(/[^a-z]/gi, '').length / 3));
      for (let s = 0; s < syllables * 2; s++) {
        // Louder on a syllable's first bar, never two of the loudest in a row.
        const top = s % 2 === 0 ? HEIGHTS.length : HEIGHTS.length - 1;
        const h = HEIGHTS[Math.min(top - 1, 1 + Math.floor(rand() * (top - 1)))];
        queue.push({ h, bright: true });
      }
      queue[queue.length - 1].word = (w === 0 && p > 0 ? ' ' : w === 0 ? '' : ' ') + word;
    });
    // The phrase trails in and out: its first and last three bars dimmer, its ends quieter.
    const end = queue.length;
    for (let i = start; i < end; i++) {
      if (i - start < 3 || end - i <= 3) queue[i].bright = false;
    }
    queue[start].h = 5;
    queue[end - 1].h = 5;
    pause(p === phrases.length - 1 ? 3 : 6 + Math.floor(rand() * 5));
  });
  return queue;
}

export type Wave = {
  /** Ends the voice and the drawing. */
  stop: () => void;
};

/**
 * Plays the waveform on `canvas`. With `live`, the line slides and the voice speaks `text`,
 * calling `onWord` for each word; without it, the still line is
 * drawn once (reduced motion), the words still arriving on the same beat.
 */
export function playWave(
  canvas: HTMLCanvasElement,
  { text, live, onWord }: { text: string; live: boolean; onWord: (word: string) => void },
): Wave {
  const context = canvas.getContext('2d');
  const queue = voice(text);
  let line: Slot[] = [];
  let count = 0;
  let width = 0;
  let frame = 0;
  let last = performance.now();
  let progress = 0;
  let visible = true;
  const heard: string[] = [];

  const size = () => {
    const ratio = Math.min(2, window.devicePixelRatio || 1);
    width = canvas.clientWidth;
    count = Math.max(1, Math.floor((width - BAR) / PITCH) + 1);
    canvas.width = Math.round(width * ratio);
    canvas.height = Math.round(WAVE_HEIGHT * ratio);
    context?.setTransform(ratio, 0, 0, ratio, 0, 0);
    // A new width keeps the newest slots, at the right.
    const fresh = live ? Array.from({ length: count + 1 }, silence) : stillLine(count);
    line = live ? [...fresh, ...line].slice(-(count + 1)) : fresh;
  };

  const draw = () => {
    if (!context) return;
    const style = getComputedStyle(canvas);
    const strong = style.color;
    const dim = style.borderTopColor;
    context.clearRect(0, 0, width, WAVE_HEIGHT);
    // Live, the line holds one slot more than it shows, which slides in from the right.
    const offset = live ? progress * PITCH : 0;
    const first = live ? line.length - count - 1 : 0;
    for (let i = first; i < line.length; i++) {
      const slot = line[i];
      const x = (i - first) * PITCH - offset;
      if (x > width) break;
      if (x + BAR < 0) continue;
      const h = Math.max(DOT, Math.min(TALL, slot.h));
      context.fillStyle = slot.h > 0 && slot.bright ? strong : dim;
      context.beginPath();
      const y = (WAVE_HEIGHT - h) / 2;
      if (context.roundRect) context.roundRect(x, y, BAR, h, BAR / 2);
      else context.rect(x, y, BAR, h);
      context.fill();
    }
  };

  const advance = () => {
    const next = queue.shift();
    // A word is written once its last bar is well in view, two beats after it comes in.
    heard.push(next?.word ?? '');
    const word = heard.length > 2 ? heard.shift() : '';
    if (word) onWord(word);
    // The still line stays as it is; the live one takes the slot in.
    if (live) line = [...line, next ?? silence()].slice(-(count + 1));
  };

  const tick = (now: number) => {
    frame = requestAnimationFrame(tick);
    // A long gap (a hidden tab) is not caught up: the voice simply resumes.
    const elapsed = Math.min(now - last, 250);
    last = now;
    progress += elapsed / BEAT;
    let moved = false;
    while (progress >= 1) {
      progress -= 1;
      advance();
      moved = true;
    }
    // Live, every frame; still, once a beat, which is enough to follow a change of scheme.
    if (visible && (live || moved)) draw();
  };

  size();
  draw();
  frame = requestAnimationFrame(tick);
  const resize = new ResizeObserver(() => {
    size();
    draw();
  });
  resize.observe(canvas);
  const seen = new IntersectionObserver(([entry]) => {
    visible = entry.isIntersecting;
  });
  seen.observe(canvas);

  return {
    stop() {
      cancelAnimationFrame(frame);
      resize.disconnect();
      seen.disconnect();
    },
  };
}
```

### composer-states/spring.ts

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

/*
 * Springs for the Web Animations API. A damped spring is stepped from 0 to 1 until it settles, and
 * its path is written as a CSS linear() easing, so the browser plays it like any other curve, off
 * the main thread where it can. Where linear() is not understood, a fast-out cubic stands in.
 */

export type Spring = { duration: number; easing: string };

const cache = new Map<string, Spring>();

/** A spring with this stiffness and damping (mass 1), as a duration in ms and an easing. */
export function spring(stiffness: number, damping: number): Spring {
  const key = `${stiffness}/${damping}`;
  const known = cache.get(key);
  if (known) return known;
  const step = 1 / 240;
  let x = 0;
  let v = 0;
  let t = 0;
  const path: number[] = [0];
  // Steps of 1/240 s; one point every four of them (60 a second) is plenty for linear().
  for (let i = 1; i < 240 * 3; i++) {
    const a = -stiffness * (x - 1) - damping * v;
    v += a * step;
    x += v * step;
    t += step;
    if (i % 4 === 0) path.push(x);
    if (Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.01) break;
  }
  path.push(1);
  const supported =
    typeof CSS !== 'undefined' && CSS.supports('transition-timing-function', 'linear(0, 1)');
  const result = {
    duration: Math.round(t * 1000),
    easing: supported
      ? `linear(${path.map((p) => +p.toFixed(4)).join(', ')})`
      : 'cubic-bezier(0.2, 0.9, 0.3, 1)',
  };
  cache.set(key, result);
  return result;
}

/** The composer's height and the text's slide: quick, with a hint of overshoot. */
export const SHELL = () => spring(420, 34);
/** Chips popping in: a livelier bounce. */
export const POP = () => spring(520, 26);
```

### composer-states/icons.tsx

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

/*
 * The composer's icons, drawn on a 20-unit grid and stroked with the text colour. The CSS gives
 * every one of them its size (20 px, or 16 px in the menus), so a page rule about svg never
 * resizes them. All are decorative: the buttons carry the names.
 */

const stroke = {
  fill: 'none',
  stroke: 'currentColor',
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
  viewBox: '0 0 20 20',
  'aria-hidden': true,
  focusable: false,
} as const;

export function PlusIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.75}>
      <path d="M4.3 10h11.4M10 4.3v11.4" />
    </svg>
  );
}

export function CloseIcon({ className, width = 1.75 }: { className?: string; width?: number }) {
  return (
    <svg className={className} {...stroke} strokeWidth={width}>
      <path d="M5 5l10 10M15 5L5 15" />
    </svg>
  );
}

export function ArrowUpIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.75}>
      <path d="M10 15.4V4.5M4.3 9.9L10 4.5l5.7 5.4" />
    </svg>
  );
}

export function MicIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.75}>
      <rect x="7.3" y="1.55" width="5.4" height="10.6" rx="2.7" />
      <path d="M4.1 8.4v0.4a5.9 5.9 0 0 0 11.8 0v-0.4M10 14.7v3.6" />
    </svg>
  );
}

export function StopIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.75}>
      <rect x="2.3" y="2.3" width="15.4" height="15.4" rx="2.6" />
    </svg>
  );
}

export function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.6}>
      <path d="M4.9 7.6L10 12.5l5.1-4.9" />
    </svg>
  );
}

export function CheckIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.75}>
      <path d="M4.5 10.4l3.6 3.6 7.4-7.8" />
    </svg>
  );
}

export function GlobeIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.55}>
      <circle cx="10" cy="10" r="8.3" />
      <ellipse cx="10" cy="10" rx="3.7" ry="8.3" />
      <path d="M1.7 10h16.6" />
    </svg>
  );
}

export function ScanIcon({ className }: { className?: string }) {
  return (
    <svg className={className} {...stroke} strokeWidth={1.55}>
      <path d="M2.4 6.2V4.1q0-1.7 1.7-1.7h2.1M13.8 2.4h2.1q1.7 0 1.7 1.7v2.1M17.6 13.8v2.1q0 1.7-1.7 1.7h-2.1M6.2 17.6H4.1q-1.7 0-1.7-1.7v-2.1" />
      <circle cx="9.6" cy="9.6" r="2.6" />
      <path d="M11.6 11.6l1.4 1.4" />
    </svg>
  );
}
```

### composer-states/data.ts

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

/* The composer's sample models, effort levels and tools, and the words its demo writes. */

export type ComposerModel = { id: string; label: string; note?: string };
export type ComposerEffort = 'Low' | 'Medium' | 'High';
export type ComposerTool = 'web' | 'visual';

export const MODELS: ComposerModel[] = [
  { id: 'nova-3', label: 'Nova 3', note: 'Best for complex work' },
  { id: 'nova-3-mini', label: 'Nova 3 Mini', note: 'Fast for everyday tasks' },
  { id: 'orbit-2', label: 'Orbit 2', note: 'Long documents and code' },
];

export const EFFORTS: ComposerEffort[] = ['Low', 'Medium', 'High'];

export const TOOLS: { id: ComposerTool; label: string; detail: string }[] = [
  { id: 'web', label: 'Web search', detail: 'Look things up online' },
  { id: 'visual', label: 'Visual search', detail: 'Find what is in an image' },
];

/** What the demo types, and what dictation hears when no onDictate is given. */
export const SAMPLE_MESSAGE =
  'Use /release-notes to draft the notes for version 4.2 of the mobile app.';
export const SAMPLE_DICTATION = 'Keep it short, and put the fixes first.';
```

### composer-states/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 };
}
```