# Chat composer

> A message field with @-mentions, a tray of attachments, a shortcut tooltip and a menu on the thread’s title.

A chat composer with its tools along the foot and the thread’s title under it. Type @ to pick an app from a searchable list and it becomes a token in the message. Attached files line up in a tray above the field, and the title opens a menu of the person and the thread’s actions.

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

Category: Inputs.

## Anatomy

1. **Attachment tray**: Grey, tucked behind the card’s top edge; it pops in with the first file and scrolls sideways once full.
2. **File card**: Any file that is not a picture: its icon, name and type. A picture shows as a square tile instead.
3. **Card**: The field and its tools on one raised surface. Its ring darkens while anything in it has focus.
4. **Mention**: An app picked from `@`: one token the caret steps over, sent as the app’s id in `mentions`.
5. **Message field**: Plain text that grows to four lines, then scrolls. Enter sends, Shift+Enter breaks the line, pasting keeps the words only.
6. **Tools**: Attach files, Make it a task (a toggle) and New thread, whose tooltip shows ⌘ ⇧ O. They share a round grey patch on hover.
7. **Send**: Sends the words, the mentions and the files, then empties the field and the tray. Nothing to send says so instead.
8. **Thread title**: A pill under the composer that opens the thread’s menu; a star shows before it once the thread is a favourite.

## When to use

- The message box at the foot of a chat with an assistant or a teammate, where a message can name the tools it should use.
- Messages that pull in apps by name: `@` opens a searchable list, and a pick becomes a token sent as an id, not as text.
- Conversations that take files: pictures and documents line up in a tray over the field before they are sent.
- A thread worth managing from where you type: its title, under the composer, opens Rename, Favourite, Share and Delete.

## When not to use

- A single-line search or command box. Use a search field or a command palette.
- Long, formatted writing (headings, lists, links). Use a rich-text editor; this field keeps plain text and mentions.
- Comments on a page or a document, with no thread to manage. A plain text area with a send button is enough.
- Mentioning people from a large directory. The picker filters a short list in memory; a directory needs remote search and paging.
- Forms where Enter should move to the next field: here Enter sends, and Shift+Enter starts a new line.

## Installation

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

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

export function ThreadComposer() {
  return (
    <ChatComposer
      autoplay={false}
      person={{ name: 'Rosalyn', role: 'Manager' }}
      onSend={(message) => console.log(message.text, message.mentions, message.files)}
    />
  );
}
```

## States

- **Empty**: The placeholder in the field and the thread’s title underneath. Send just says there is nothing to send.
- **Written**: A mention token, then words. The field grows with them, up to four lines.
- **Mention picker**: `@` at the start of a word opens the apps over it, with focus in its own search box and the first row in view.
- **Searching apps**: Each letter narrows the list to the names that contain it; the arrow keys and the pointer share one highlight.
- **No app found**: The list says so in place of rows. Escape closes the picker and puts the caret back after the `@`.
- **Attachments**: A picture as a tile and a PDF as a card in the tray, with the dashed tile to add more.
- **Attachment pointed at**: Its cross shows at its corner, as under the keyboard’s focus; on touch screens it always shows.
- **Make it a task**: The toggle is pressed and keeps its patch: the message will be sent as a task.
- **New-thread tooltip**: On hover or keyboard focus: who the new thread is with, and its shortcut. It stays inside the stage.
- **Thread menu**: The title raises into a pill; the menu opens over it with the person first, then the thread’s actions.
- **Delete highlighted**: Delete is the only item that turns red under the highlight.
- **Renaming**: The title turns into a field with its words selected. Enter or leaving saves, Escape cancels.
- **Favourite**: A star before the title, and Favourite checked in the menu.

## How it works

- The field is a contentEditable box that React leaves alone. A mention is a small token marked contenteditable false, so the caret steps over it as a whole.
- Typing @ at the start of a word opens a picker over it, with a search box of its own. The arrow keys and the pointer share one highlight, and picking an app turns the @ into its token.
- Attached files sit in a grey tray behind the card’s top edge: a picture as a tile, any other file as a card with its name and type. Each one’s cross shows under the pointer or focus, and always on touch screens.
- The new-thread button shows its name and its keys in a dark tooltip, which keeps itself inside the stage. ⌘⇧O (Ctrl+Shift+O) starts a new thread too.
- The title is quiet grey text that becomes a pill while its menu is open. The menu lines up with the title’s right end and holds the person and a way to their profile, then Rename (the title turns into a field), Favourite, Share and Delete, which turns red when highlighted.
- Every interface colour is a light-dark() token on the root, so the composer follows the page’s color-scheme, or the colorScheme prop; the app glyphs and the attached portrait are pictures and keep their colours, and the demo’s I-beam turns light at night.

## API reference

- **person** (`{ name: string; role: string }`, default `{ name: 'Rosalyn', role: 'Manager' }`): The person the thread is with, shown in the menu and the tooltip.
- **apps** (`MentionApp[]`, default `APPS`): The apps a message can mention, each with an id, a name and a 16 × 16 glyph (apps.tsx).
- **defaultTitle** (`string`, default `'Meeting Preparation and Agenda Planning'`): The thread’s title, under the composer.
- **placeholder** (`string`, default `'Ask anything...'`): Shown in the empty field.
- **defaultState** (`ChatComposerState`): Opens in a given state, with the demo off: mentions and text in the field, the mention picker, attachments (one hovered), the tooltip, the thread’s menu, renaming, favourite or task.
- **onSend** (`(message: ComposerMessage) => void`): Called with the text, the ids mentioned and the files when the message is sent.
- **onThreadAction** (`(action: ThreadAction | 'new', title: string) => void`): Called with the action and the title after Profile, Rename, Favourite, Share or Delete, or with “new” for a new thread.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer goes through the tooltip and the menu, a mention, and the attachments.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the stage.

## Accessibility

- The field is a multiline textbox: Enter sends and Shift+Enter starts a new line. The picker’s search box is a combobox with aria-activedescendant over a listbox, and Escape closes it and puts the caret back after the @.
- The title is a menu button. Enter or the arrow keys open the menu and focus its first item, the arrow keys, Home and End move through it, and Escape returns focus to the title. Favourite is a menuitemcheckbox.
- The new-thread button carries its shortcut in aria-keyshortcuts and a description, and its tooltip shows on keyboard focus as well as hover.
- Sending, mentions, attaching, removing files and thread actions are announced in a polite live region, and removing a file moves focus to the next cross. Under reduced motion the demo never plays.

## Source

### chat-composer/chat-composer.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { APPS, type MentionApp } from './apps';
import { Attachments, type Attachment } from './attachments';
import {
  atBeforeCaret,
  insertMention,
  isBlank,
  lastAt,
  placeCaret,
  readMessage,
  typeAtEnd,
  type MentionAnchor,
} from './editor';
import { GhostPointer, runDemo } from './ghost-pointer';
import { ChevronIcon, ClipIcon, NewThreadIcon, SendIcon, StarIcon, TaskIcon } from './icons';
import { matchesOf, MentionPicker, PICKER_WIDTH } from './mention-picker';
import { MENU_ITEMS, ThreadMenu, type ThreadAction } from './thread-menu';
import { useAutoplay } from './use-autoplay';
import styles from './chat-composer.module.css';

/*
 * Chat composer: a message field with its tools, over the title of the thread it writes in.
 *
 * - The field takes text and mentions. Typing "@" at the start of a word opens a picker of apps
 *   with its own search box (mention-picker.tsx); picking one turns the "@" into a token, which
 *   the caret steps over as a whole (editor.ts). Enter sends, Shift+Enter starts a new line.
 * - Files attached with the clip, or the dashed tile, line up in a grey tray over the field: a
 *   picture as a tile, any other file as a card with its name and type (attachments.tsx).
 * - The new-thread button shows its shortcut in a tooltip (⌘ ⇧ O, or Ctrl+Shift+O).
 * - The thread's title opens its menu (thread-menu.tsx): the person the thread is with, then
 *   Rename (the title turns into a field), Favourite, Share and Delete.
 * - Popovers open on a short pop, and the composer keeps them inside its stage. While on screen
 *   and untouched, a ghost pointer goes through the tooltip and the menu, a mention, and the
 *   attachments in turn.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The apps' glyphs and the portrait keep
 *   their own colours.
 * - defaultState opens it in a given state (a message, the picker, attachments, the tooltip, the
 *   menu…), for documentation and tests; the demo then stays off.
 */

export type ComposerMessage = {
  /** The text, with each mention as "@Name". */
  text: string;
  /** The ids of the apps mentioned, in order. */
  mentions: string[];
  /** The files attached by the reader. */
  files: File[];
};

/** Where the composer opens (defaultState): for documentation and tests. */
export type ChatComposerState = {
  /** The apps mentioned at the start of the message, by id. */
  mentions?: string[];
  /** The words after them. */
  text?: string;
  /** The mention picker open over an "@" at the end, with its search and the row in view. */
  picker?: { query?: string; active?: number };
  /** Files in the tray, shown with the demo's illustrated picture and PDF. */
  attachments?: ('picture' | 'file')[];
  /** The attachment whose cross shows, by index. */
  hovered?: number;
  /** The new-thread tooltip is shown. */
  tooltip?: boolean;
  /** The thread's menu is open, with this item highlighted (-1 for none). */
  menu?: number;
  /** The title is a field, being renamed. */
  renaming?: boolean;
  /** The thread is a favourite: a star before its title. */
  favourite?: boolean;
  /** Make it a task is on. */
  task?: boolean;
};

type Mention = {
  /** The "@" the picker will turn into a token. */
  anchor: MentionAnchor;
  query: string;
  active: number;
  /** Where the picker starts, in px from the field's box. */
  left: number;
};

/** What the demo does, one step at a time. */
type DemoAct =
  | { type: 'reset' }
  | { type: 'tooltip'; shown: boolean }
  | { type: 'menu'; open: boolean }
  | { type: 'menuActive'; index: number }
  | { type: 'caret'; shown: boolean }
  | { type: 'type'; text: string }
  | { type: 'mentionActive'; index: number }
  | { type: 'pick'; app: MentionApp }
  | { type: 'send' }
  | { type: 'attach'; item: Attachment }
  | { type: 'hover'; id: string | null }
  | { type: 'remove'; id: string };

const PERSON = { name: 'Rosalyn', role: 'Manager' };
const TITLE = 'Meeting Preparation and Agenda Planning';
const NEW_TITLE = 'New thread';

/** What the demo attaches: an illustrated picture, then a PDF. */
const DEMO_FILES: Attachment[] = [
  { id: 'demo-picture', name: 'portrait.png', label: 'PNG', picture: true },
  { id: 'demo-pdf', name: 'LDF84ET29EFF.pdf', label: 'PDF', picture: false },
];

/** What the demo writes after its mention. */
const DEMO_TEXT = 'pull the open launch tasks for Friday';

/** "PDF" from "brief.pdf", or the file's kind. */
const labelOf = (file: File) =>
  file.name.includes('.')
    ? String(file.name.split('.').pop()).toUpperCase()
    : file.type.split('/')[0] || 'File';

export function ChatComposer({
  person = PERSON,
  apps = APPS,
  defaultTitle = TITLE,
  placeholder = 'Ask anything...',
  onSend,
  onThreadAction,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The person the thread is with: their name opens the menu and names the new-thread tooltip. */
  person?: { name: string; role: string };
  /** The apps a message can mention (apps.tsx): an id, a name and a 16 × 16 glyph. */
  apps?: MentionApp[];
  /** The thread's title, shown under the composer. */
  defaultTitle?: string;
  /** Shown in the empty field. */
  placeholder?: string;
  /** Called with the message when it is sent. */
  onSend?: (message: ComposerMessage) => void;
  /** Called after a thread action runs, or 'new' for a new thread. */
  onThreadAction?: (action: ThreadAction | 'new', title: string) => void;
  /** While on screen and untouched, a ghost pointer goes through the composer's flows. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens it in a given state, for documentation and tests; the demo stays off. */
  defaultState?: ChatComposerState;
  /** Extra class for the stage. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [title, setTitle] = useState(defaultTitle);
  const [renaming, setRenaming] = useState(!!defaultState?.renaming);
  const [favourite, setFavourite] = useState(!!defaultState?.favourite);
  const [menu, setMenu] = useState<{ active: number } | null>(() =>
    defaultState?.menu === undefined ? null : { active: defaultState.menu },
  );
  const [tooltip, setTooltip] = useState(!!defaultState?.tooltip);
  const [mention, setMention] = useState<Mention | null>(null);
  const [attachments, setAttachments] = useState<Attachment[]>(() =>
    (defaultState?.attachments ?? []).map((kind, index) => ({
      ...DEMO_FILES[kind === 'picture' ? 0 : 1],
      id: `state-${index}`,
    })),
  );
  const [hovered, setHovered] = useState<string | null>(() =>
    defaultState?.hovered === undefined ? null : `state-${defaultState.hovered}`,
  );
  const [blank, setBlank] = useState(true);
  const [task, setTask] = useState(!!defaultState?.task);
  const [demoCaret, setDemoCaret] = useState(false);
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const tooltipId = useId();

  const card = useRef<HTMLDivElement>(null);
  const field = useRef<HTMLDivElement>(null);
  const search = useRef<HTMLInputElement>(null);
  const pill = useRef<HTMLButtonElement>(null);
  const menuBox = useRef<HTMLDivElement | null>(null);
  const tip = useRef<HTMLSpanElement>(null);
  const newThread = useRef<HTMLButtonElement>(null);
  const attachButton = useRef<HTMLButtonElement>(null);
  const sendButton = useRef<HTMLButtonElement>(null);
  const filePicker = useRef<HTMLInputElement>(null);
  const renameField = useRef<HTMLInputElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** Each attachment's tile and cross, by "tile:id" and "remove:id". */
  const parts = useRef(new Map<string, HTMLElement>());
  /** The reader's files, by attachment id, for the message. */
  const files = useRef(new Map<string, File>());
  /** The local addresses of the reader's pictures, given back when they go. */
  const urls = useRef(new Set<string>());
  /** What to focus once the next render is on screen. */
  const focusNext = useRef<(() => HTMLElement | null | undefined) | null>(null);

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

  const syncBlank = () => setBlank(!field.current || isBlank(field.current));

  /** Drops an attachment, and the picture's local address with it. */
  const forget = (item: Attachment) => {
    if (item.url) {
      URL.revokeObjectURL(item.url);
      urls.current.delete(item.url);
    }
    files.current.delete(item.id);
  };

  /** Empties the field and the tray. */
  const clear = () => {
    if (field.current) field.current.textContent = '';
    attachments.forEach(forget);
    setAttachments([]);
    setMention(null);
    setBlank(true);
  };

  // Mentions.
  const openMention = (anchor: MentionAnchor, byReader: boolean) => {
    const box = card.current;
    if (!box) return;
    // The picker starts over the "@", inside the field's box (which may sit in a scaled frame), on
    // a whole pixel, so its layer has the same size wherever it opens.
    const range = document.createRange();
    range.setStart(anchor.node, anchor.offset);
    range.setEnd(anchor.node, anchor.offset + 1);
    const at = range.getBoundingClientRect();
    const frame = box.getBoundingClientRect();
    const scale = frame.width / box.offsetWidth || 1;
    const left = Math.min(
      Math.max(8, (at.left - frame.left) / scale - 1),
      box.offsetWidth - PICKER_WIDTH - 8,
    );
    setMention({ anchor, query: '', active: 0, left: Math.max(0, Math.round(left)) });
    if (byReader) focusNext.current = () => search.current;
  };

  const closeMention = (refocus: boolean) => {
    const anchor = mention?.anchor;
    setMention(null);
    if (refocus && anchor?.node.isConnected && field.current) {
      field.current.focus();
      placeCaret(anchor.node, Math.min(anchor.offset + 1, anchor.node.length));
    }
  };

  const pick = (app: MentionApp, byReader: boolean) => {
    const box = field.current;
    if (!mention || !box) return;
    if (byReader) box.focus();
    insertMention(mention.anchor, app, styles.mention, byReader);
    setMention(null);
    syncBlank();
    if (byReader) announce(`Mentioned ${app.name}.`);
  };

  // Sending.
  const send = (byReader: boolean) => {
    const box = field.current;
    if (!box || (isBlank(box) && !attachments.length)) {
      if (byReader) announce('Write a message or attach a file first.');
      return;
    }
    const { text, mentions } = readMessage(box);
    const sent = attachments.flatMap((item) => files.current.get(item.id) ?? []);
    onSend?.({ text, mentions, files: sent });
    clear();
    if (byReader) announce('Message sent.');
  };

  // Attachments.
  const remember = (url: string) => {
    urls.current.add(url);
    return url;
  };

  const addFiles = (list: FileList | null) => {
    if (!list?.length) return;
    const added = Array.from(list, (file, index): Attachment => {
      const id = `file-${Date.now()}-${index}`;
      files.current.set(id, file);
      const picture = file.type.startsWith('image/');
      return {
        id,
        name: file.name,
        label: labelOf(file),
        picture,
        url: picture ? remember(URL.createObjectURL(file)) : undefined,
      };
    });
    setAttachments((now) => [...now, ...added]);
    announce(`${added.length === 1 ? added[0].name : `${added.length} files`} attached.`);
  };

  const remove = (item: Attachment, byReader: boolean) => {
    forget(item);
    const index = attachments.indexOf(item);
    const rest = attachments.filter((other) => other !== item);
    setAttachments(rest);
    setHovered(null);
    if (byReader) {
      announce(`Removed ${item.name}.`);
      const next = rest[Math.min(index, rest.length - 1)];
      focusNext.current = () =>
        next ? parts.current.get(`remove:${next.id}`) : attachButton.current;
    }
  };

  // The thread.
  const startThread = (byReader: boolean) => {
    clear();
    setTitle(NEW_TITLE);
    setFavourite(false);
    setTooltip(false);
    onThreadAction?.('new', NEW_TITLE);
    if (byReader) announce(`Started a new thread with ${person.name}.`);
  };

  const openMenu = (byReader: boolean) => {
    setTooltip(false);
    setMenu({ active: byReader ? 0 : -1 });
    if (byReader) focusNext.current = () => menuBox.current?.querySelector('button');
  };

  const closeMenu = (refocus: boolean) => {
    setMenu(null);
    if (refocus) focusNext.current = () => pill.current;
  };

  const runAction = (action: ThreadAction) => {
    closeMenu(action !== 'rename');
    if (action === 'profile') announce(`Opening ${person.name}’s profile.`);
    else if (action === 'rename') {
      setRenaming(true);
      focusNext.current = () => renameField.current;
    } else if (action === 'favourite') {
      announce(favourite ? 'Removed from favourites.' : 'Added to favourites.');
      setFavourite(!favourite);
    } else if (action === 'share') {
      navigator.clipboard?.writeText(title).catch(() => {});
      announce('Thread title copied to share.');
    } else {
      clear();
      setTitle(NEW_TITLE);
      setFavourite(false);
      announce('Thread deleted.');
    }
    onThreadAction?.(action, title);
  };

  const finishRename = (save: boolean) => {
    const next = renameField.current?.value.trim();
    setRenaming(false);
    if (save && next && next !== title) {
      setTitle(next);
      announce(`Thread renamed to ${next}.`);
    }
    focusNext.current = () => pill.current;
  };

  // Focus lands where the reader's action leads, once it is on screen.
  useLayoutEffect(() => {
    const next = focusNext.current;
    if (!next) return;
    focusNext.current = null;
    next()?.focus({ preventScroll: true });
  });

  // The tooltip stays inside the stage: it moves over by as much as it would stick out, or sits
  // in the stage's middle when it is wider.
  useLayoutEffect(() => {
    const box = tip.current;
    const stage = root.current;
    if (!tooltip || !box || !stage) return;
    box.style.setProperty('--shift', '0px');
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const own = box.getBoundingClientRect();
    const lowest = frame.left + 8 * scale;
    const highest = frame.right - 8 * scale - own.width;
    const left =
      highest < lowest
        ? (frame.left + frame.right - own.width) / 2
        : Math.min(Math.max(own.left, lowest), highest);
    box.style.setProperty('--shift', `${(left - own.left) / scale}px`);
  }, [tooltip, root]);

  // defaultState's message, written into the field once it is on the page, and its picker.
  const filled = useRef(false);
  const fill = useEffectEvent(() => {
    const box = field.current;
    // Once: effects run twice in development.
    if (!box || !defaultState || filled.current) return;
    filled.current = true;
    for (const id of defaultState.mentions ?? []) {
      const app = apps.find((each) => each.id === id);
      typeAtEnd(box, '@');
      const anchor = lastAt(box);
      if (app && anchor) insertMention(anchor, app, styles.mention, false);
    }
    if (defaultState.text) typeAtEnd(box, defaultState.text);
    const picker = defaultState.picker;
    if (picker) {
      typeAtEnd(box, /\S$/.test(box.textContent ?? '') ? ' @' : '@');
      const anchor = lastAt(box);
      if (anchor) {
        openMention(anchor, false);
        setMention(
          (now) => now && { ...now, query: picker.query ?? '', active: picker.active ?? 0 },
        );
      }
    }
    syncBlank();
  });
  useLayoutEffect(() => fill(), []);

  // Pictures' local addresses go when the composer does.
  useEffect(() => {
    const owned = urls.current;
    return () => owned.forEach((url) => URL.revokeObjectURL(url));
  }, []);

  // The demo: the tooltip and the menu, a mention, then the attachments; each round starts clean.
  const readState = useEffectEvent(() => ({ mention }));
  const demoAct = useEffectEvent((act: DemoAct) => {
    const box = field.current;
    if (act.type === 'reset') {
      clear();
      setTitle(defaultTitle);
      setFavourite(false);
      setMenu(null);
      setTooltip(false);
      setHovered(null);
    } else if (act.type === 'tooltip') setTooltip(act.shown);
    else if (act.type === 'menu') act.open ? openMenu(false) : closeMenu(false);
    else if (act.type === 'menuActive') setMenu((now) => now && { active: act.index });
    else if (act.type === 'caret') setDemoCaret(act.shown);
    else if (act.type === 'type' && box) {
      typeAtEnd(box, act.text);
      syncBlank();
      const anchor = act.text === '@' ? lastAt(box) : null;
      if (anchor) openMention(anchor, false);
    } else if (act.type === 'mentionActive')
      setMention((now) => now && { ...now, active: act.index });
    else if (act.type === 'pick') pick(act.app, false);
    else if (act.type === 'send') send(false);
    else if (act.type === 'attach') setAttachments((now) => [...now, act.item]);
    else if (act.type === 'hover') setHovered(act.id);
    else if (act.type === 'remove') {
      const item = attachments.find((each) => each.id === act.id);
      if (item) remove(item, false);
    }
  });
  const demo = autoplay && !defaultState && playing && !focused && !renaming;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const pickedFirst = apps[apps.length - 1];
    const pickedSecond = apps[Math.min(2, apps.length - 1)];
    const row = (app: MentionApp) => stage.querySelector(`[data-app="${app.id}"]`);
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      for (let round = 0; ; round += 1) {
        await wait(round ? 1200 : 600);
        demoAct({ type: 'reset' });

        // The tooltip, then the thread's menu, item after item.
        await moveTo(newThread.current, { shape: 'hand', at: { x: 0.72, y: 0.72 } });
        demoAct({ type: 'tooltip', shown: true });
        await wait(1500);
        demoAct({ type: 'tooltip', shown: false });
        await moveTo(pill.current, { shape: 'hand', at: { x: 0.6, y: 0.5 } });
        await press(() => demoAct({ type: 'menu', open: true }));
        await wait(500);
        for (let index = 1; index < MENU_ITEMS.length; index += 1) {
          const item = menuBox.current?.querySelectorAll('button')[index];
          await moveTo(item, { at: { x: 0.72, y: 0.5 } });
          demoAct({ type: 'menuActive', index });
          await wait(index === MENU_ITEMS.length - 1 ? 1100 : 420);
        }
        await moveTo(pill.current, { at: { x: 0.6, y: 0.5 } });
        await press(() => demoAct({ type: 'menu', open: false }));
        await wait(600);

        // A mention: "@" opens the picker, the pointer runs down it and picks an app; twice.
        await moveTo(field.current, { shape: 'text', at: { x: 0.18, y: 0.5 } });
        await press(() => demoAct({ type: 'caret', shown: true }));
        for (const [turn, app] of [pickedFirst, pickedSecond].entries()) {
          await wait(turn ? 300 : 500);
          demoAct({ type: 'type', text: '@' });
          await wait(650);
          if (!readState().mention) return;
          const index = apps.indexOf(app);
          if (index > 3) {
            await moveTo(row(apps[0]), { shape: 'hand', at: { x: 0.4, y: 0.5 } });
            demoAct({ type: 'mentionActive', index: 0 });
            await wait(300);
            demoAct({ type: 'mentionActive', index });
            await wait(250);
          } else {
            for (let step = 0; step <= index; step += 1) {
              await moveTo(row(apps[step]), { shape: 'hand', at: { x: 0.4, y: 0.5 } });
              demoAct({ type: 'mentionActive', index: step });
              await wait(260);
            }
          }
          await moveTo(row(app), { shape: 'hand', at: { x: 0.4, y: 0.5 } });
          await wait(250);
          await press(() => demoAct({ type: 'pick', app }));
        }
        let typed = '';
        for (const key of DEMO_TEXT) {
          typed += key;
          demoAct({ type: 'type', text: key });
          await wait(45 + ((typed.length * 37) % 50));
        }
        await wait(900);
        await moveTo(sendButton.current, { shape: 'hand' });
        await press(() => demoAct({ type: 'send' }));
        demoAct({ type: 'caret', shown: false });
        await wait(700);

        // Attachments: a picture and a PDF come in; the PDF's cross removes it, then the picture.
        for (const item of DEMO_FILES) {
          await moveTo(attachButton.current, { shape: 'hand' });
          await press(() => demoAct({ type: 'attach', item }));
          await wait(700);
        }
        for (const item of [...DEMO_FILES].reverse()) {
          await moveTo(parts.current.get(`tile:${item.id}`), {
            shape: 'hand',
            at: { x: item.picture ? 0.5 : 0.7, y: 0.6 },
          });
          demoAct({ type: 'hover', id: item.id });
          await wait(700);
          await moveTo(parts.current.get(`remove:${item.id}`), { shape: 'hand' });
          await press(() => demoAct({ type: 'remove', id: item.id }));
          await wait(600);
        }
        await moveTo(
          { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.5 },
          { shape: 'arrow' },
        );
      }
    });
    return () => {
      stop();
      setDemoCaret(false);
      setTooltip(false);
      setHovered(null);
    };
  }, [demo, root, apps]);

  const tooltipText = `Start new thread with ${person.name}`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      // While the demo plays, the picker's list and the tray clip rather than scroll (see the
      // stylesheet).
      data-demo={demo || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={(event) => {
        take();
        const key = event.key.toLowerCase();
        if (key === 'o' && event.shiftKey && (event.metaKey || event.ctrlKey)) {
          event.preventDefault();
          startThread(true);
        }
      }}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.dock}>
        <div className={styles.composer}>
          {attachments.length > 0 && (
            <Attachments
              items={attachments}
              hovered={hovered}
              partRef={(key, element) => {
                if (element) parts.current.set(key, element);
                else parts.current.delete(key);
              }}
              onRemove={(item) => remove(item, true)}
              onAdd={() => filePicker.current?.click()}
            />
          )}
          <div ref={card} className={styles.card} data-part="card">
            <div
              ref={field}
              className={styles.field}
              data-part="field"
              contentEditable
              suppressContentEditableWarning
              role="textbox"
              aria-multiline="true"
              aria-label="Message"
              aria-placeholder={placeholder}
              data-placeholder={placeholder}
              data-blank={blank || undefined}
              data-caret={demoCaret || undefined}
              onInput={(event) => {
                syncBlank();
                const typed = (event.nativeEvent as InputEvent).data;
                const anchor = typed?.endsWith('@') ? atBeforeCaret(event.currentTarget) : null;
                if (anchor) openMention(anchor, true);
              }}
              onKeyDown={(event) => {
                if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
                  event.preventDefault();
                  send(true);
                }
              }}
              onPaste={(event) => {
                // Text only: no styles or pictures from elsewhere.
                event.preventDefault();
                const text = event.clipboardData.getData('text/plain');
                const selection = window.getSelection();
                if (!text || !selection?.rangeCount) return;
                const range = selection.getRangeAt(0);
                range.deleteContents();
                const node = document.createTextNode(text);
                range.insertNode(node);
                placeCaret(node, node.length);
                syncBlank();
              }}
            />
            {mention && (
              <MentionPicker
                apps={apps}
                query={mention.query}
                active={mention.active}
                left={mention.left}
                demoCaret={demoCaret}
                searchRef={search}
                onQuery={(query) => setMention((now) => now && { ...now, query, active: 0 })}
                onActive={(active) => setMention((now) => now && { ...now, active })}
                onPick={(app) => pick(app, true)}
                onClose={closeMention}
              />
            )}
            <div className={styles.toolbar} data-part="toolbar">
              <button
                ref={attachButton}
                type="button"
                className={styles.tool}
                data-part="attach"
                aria-label="Attach files"
                onClick={() => filePicker.current?.click()}
              >
                <ClipIcon />
              </button>
              <button
                type="button"
                className={styles.tool}
                data-part="task"
                aria-label="Make it a task"
                aria-pressed={task}
                onClick={() => {
                  setTask(!task);
                  announce(task ? 'Task off.' : 'This message will be a task.');
                }}
              >
                <TaskIcon />
              </button>
              <span className={styles.tipAnchor}>
                <button
                  ref={newThread}
                  type="button"
                  className={styles.tool}
                  data-part="new-thread"
                  aria-label="New thread"
                  aria-describedby={tooltipId}
                  aria-keyshortcuts="Meta+Shift+O Control+Shift+O"
                  data-hovered={tooltip || undefined}
                  onPointerEnter={() => setTooltip(true)}
                  onPointerLeave={() => setTooltip(false)}
                  onFocus={(event) => setTooltip(event.currentTarget.matches(':focus-visible'))}
                  onBlur={() => setTooltip(false)}
                  onClick={() => startThread(true)}
                >
                  <NewThreadIcon />
                </button>
                <span id={tooltipId} className={styles.hidden}>
                  {tooltipText}, Command Shift O
                </span>
                {tooltip && (
                  <span ref={tip} className={styles.tooltip} data-part="tooltip" aria-hidden="true">
                    {tooltipText}
                    <span className={styles.keys}>
                      <kbd className={styles.key}>⌘</kbd>
                      <kbd className={styles.key}>⇧</kbd>
                      <kbd className={styles.key}>O</kbd>
                    </span>
                  </span>
                )}
              </span>
              <button
                ref={sendButton}
                type="button"
                className={styles.send}
                data-part="send"
                aria-label="Send"
                onClick={() => send(true)}
              >
                <SendIcon />
              </button>
            </div>
          </div>
        </div>

        <div className={styles.titleRow}>
          <div className={styles.titleAnchor}>
            {renaming ? (
              <input
                ref={renameField}
                className={styles.renameField}
                data-part="rename"
                defaultValue={title}
                aria-label="Thread title"
                style={{ '--chars': title.length } as CSSProperties}
                onKeyDown={(event) => {
                  if (event.key !== 'Enter' && event.key !== 'Escape') return;
                  // The key ends here, or it would press the title once focus is back on it.
                  event.preventDefault();
                  event.stopPropagation();
                  finishRename(event.key === 'Enter');
                }}
                onBlur={() => finishRename(true)}
                onFocus={(event) => event.currentTarget.select()}
              />
            ) : (
              <button
                ref={pill}
                type="button"
                className={styles.pill}
                data-part="title"
                aria-haspopup="menu"
                aria-expanded={menu !== null}
                data-open={menu !== null || undefined}
                onClick={() => (menu ? closeMenu(true) : openMenu(true))}
                onKeyDown={(event) => {
                  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
                    event.preventDefault();
                    openMenu(true);
                  }
                }}
              >
                {favourite && (
                  <span className={styles.pillStar}>
                    <StarIcon />
                  </span>
                )}
                {title}
                <span className={styles.pillChevron}>
                  <ChevronIcon />
                </span>
              </button>
            )}
            {menu && (
              <ThreadMenu
                person={person}
                favourite={favourite}
                active={menu.active}
                menuRef={(element) => {
                  menuBox.current = element;
                }}
                onActive={(active) => setMenu({ active })}
                onAction={runAction}
                onClose={closeMenu}
              />
            )}
          </div>
        </div>
      </div>
      {/* Kept out of the dock, whose layer has no pixels of its own. */}
      <input
        ref={filePicker}
        className={styles.hidden}
        type="file"
        multiple
        tabIndex={-1}
        aria-hidden="true"
        onChange={(event) => {
          addFiles(event.target.files);
          event.target.value = '';
        }}
      />
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### chat-composer/chat-composer.module.css

```css
/* Chat composer (chat-composer.tsx): a stage with the composer near its foot and the thread's
   title under it; menus, the picker and the tooltip open upward over the stage.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). The apps' glyphs and the attached portrait are
   pictures, and keep their colours on both.

   Memory. The stage is one layout boundary of a fixed height. A phone keeps a page's pixels in
   tiles the width of the screen, about 7 MB each, and a tile painted again gets more buffers, so
   nothing the demo changes paints on the page itself. The dock is a layer that paints nothing of
   its own, and each part of it paints on a layer of its own: the card (which typing repaints),
   the title, the tray, the tooltip, the menu and the picker; the carets blink on tiny layers.
   Each keeps one size while it is shown, since a layer that changes size gets new buffers. While
   the demo plays, the picker's list and the tray clip rather than scroll: on a phone a box that
   scrolls gets layers of its own, drawn again at every highlight. No style reaches an icon while
   the demo plays: highlights change backgrounds and text colours, never the colour an icon
   inherits, since Safari lays out every drawing a style change reaches, and two at once make it
   lay out the whole page. */

.root {
  /* The stage and its edge; the card, a step up, its ring (darker while it has focus) and its
     lift; the popovers, another step up, their ring and their shadows. */
  --surface: #fbfbfb;
  --edge: rgb(0 0 0 / 0.06);
  --surface-2: #fff;
  --line: #ebebeb;
  --line-focus: #dcdcdc;
  --shadow-near: rgb(0 0 0 / 0.07);
  --shadow: rgb(0 0 0 / 0.18);
  --surface-3: #fff;
  --popover-ring: rgb(0 0 0 / 0.06);
  --popover-near: rgb(0 0 0 / 0.08);
  --popover-far: rgb(0 0 0 / 0.12);
  /* The words: typed and picked; menu items and options; quiet lines; the field's placeholder. */
  --ink: #1c1c1c;
  --ink-soft: #5c5c5c;
  --ink-muted: #7a7a7a;
  --faint: #ababab;
  /* The tools: their icons, a hovered one's patch, and Make it a task while on. */
  --tool: #787878;
  --hover: #eeeeee;
  --tool-on: #fbe9fb;
  /* A mention's token. */
  --mention: #ecf4fe;
  --mention-ink: #3a82dc;
  /* The send button: a magenta drop, the same on both schemes, and its arrow. */
  --accent-top: #ff6cfb;
  --accent: #ff2ff3;
  --accent-end: #f400e9;
  --accent-glow: rgb(214 0 200 / 0.4);
  --accent-shine: rgb(255 255 255 / 0.22);
  --accent-ink: #fff;
  /* The tooltip, dark on both schemes, and its keys. */
  --tip: #333;
  --tip-ink: #f2f2f2;
  --tip-shadow: rgb(0 0 0 / 0.3);
  --key: #3a3a3a;
  --key-edge: #575757;
  --key-ink: #e0e0e0;
  /* The title: its words and chevron; hovered, then open (a raised pill); renamed (a field). */
  --title: #737373;
  --title-chevron: #8f8f8f;
  --title-hover: #f2f2f2;
  --title-hover-ink: #4d4d4d;
  --title-open-edge: #e6e6e6;
  --title-open-shadow: rgb(0 0 0 / 0.05);
  --field-edge: #cfcfcf;
  --field-glow: rgb(0 0 0 / 0.05);
  /* The star of a favourite. */
  --star: #f2a20c;
  /* In the menu and the picker: the person's card (lit while active), the groups' label, the
     icons, a lit row's fill, ring and lift, and the search box. */
  --person: #f5f5f5;
  --person-active: #eeeeee;
  --magenta: #d23ad6;
  --menu-label: #707070;
  --menu-icon: #777;
  --lit-top: #fbfbfb;
  --lit-bottom: #f4f4f4;
  --lit-edge: #ebebeb;
  --option-edge: #e9e9e9;
  --lit-shadow: rgb(0 0 0 / 0.04);
  --option-shadow: rgb(0 0 0 / 0.05);
  --search: #f5f5f5;
  --search-edge: #ebebeb;
  --search-focus: #d9d9d9;
  --search-placeholder: #b3b3b3;
  --tag-edge: #e8e8e8;
  --tag-ink: #7f7f7f;
  /* Delete: its words, and lit, its fill, ring and words. */
  --red: #e5484d;
  --red-top: #fff5f4;
  --red-bottom: #feeded;
  --red-edge: #f8e1df;
  --red-ink: #d1343c;
  /* The attachments: the tray, a file's card, its page icon, a tile's cross, the dashed tile. */
  --tray: #f8f8f8;
  --tray-edge: #e6e6e6;
  --file-icon: #f4f4f4;
  --file-fill: #dedede;
  --file-line: #9c9c9c;
  --remove: #fff;
  --remove-edge: #dcdcdc;
  --remove-shadow: rgb(0 0 0 / 0.08);
  --remove-ink: #333;
  --add: #f2f2f2;
  --add-edge: #d4d4d4;
  --add-ink: #8a8a8a;
  /* The focus ring, and the demo's pointer: an arrow and a hand that look like the system's on
     both schemes, and an I-beam that turns light at night, as over dark text fields. */
  --focus: #3b82f6;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-hand: #fff;
  --pointer-hand-edge: #141414;
  --pointer-beam: #141414;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  /* The stage's height; a page that shows it shorter (the docs) sets the variable. */
  height: var(--chat-composer-height, 440px);
  contain: size layout;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 14px/20px Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fbfbfb, #121212);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface-2: light-dark(#fff, #1e1e1e);
    --line: light-dark(#ebebeb, #2e2e2e);
    --line-focus: light-dark(#dcdcdc, #424242);
    --shadow-near: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.4));
    --shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --surface-3: light-dark(#fff, #252525);
    --popover-ring: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --popover-near: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.35));
    --popover-far: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
    --ink: light-dark(#1c1c1c, #ededed);
    --ink-soft: light-dark(#5c5c5c, #b0b0b0);
    --ink-muted: light-dark(#7a7a7a, #969696);
    --faint: light-dark(#ababab, #8a8a8a);
    --tool: light-dark(#787878, #949494);
    --hover: light-dark(#eeeeee, #2c2c2c);
    --tool-on: light-dark(#fbe9fb, #3a1f39);
    --mention: light-dark(#ecf4fe, #1f2c3d);
    --mention-ink: light-dark(#3a82dc, #6aa6f0);
    --tip-shadow: light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.5));
    --title: light-dark(#737373, #9a9a9a);
    --title-chevron: light-dark(#8f8f8f, #7a7a7a);
    --title-hover: light-dark(#f2f2f2, #232323);
    --title-hover-ink: light-dark(#4d4d4d, #c8c8c8);
    --title-open-edge: light-dark(#e6e6e6, #363636);
    --title-open-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --field-edge: light-dark(#cfcfcf, #4a4a4a);
    --field-glow: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --person: light-dark(#f5f5f5, #2e2e2e);
    --person-active: light-dark(#eeeeee, #353535);
    --magenta: light-dark(#d23ad6, #e86ae9);
    --menu-label: light-dark(#707070, #969696);
    --menu-icon: light-dark(#777, #9a9a9a);
    --lit-top: light-dark(#fbfbfb, #323232);
    --lit-bottom: light-dark(#f4f4f4, #2c2c2c);
    --lit-edge: light-dark(#ebebeb, #3a3a3a);
    --option-edge: light-dark(#e9e9e9, #3a3a3a);
    --lit-shadow: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --option-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --search: light-dark(#f5f5f5, #2c2c2c);
    --search-edge: light-dark(#ebebeb, #383838);
    --search-focus: light-dark(#d9d9d9, #4a4a4a);
    --search-placeholder: light-dark(#b3b3b3, #949494);
    --tag-edge: light-dark(#e8e8e8, #3a3a3a);
    --tag-ink: light-dark(#7f7f7f, #9a9a9a);
    --red: light-dark(#e5484d, #ff6369);
    --red-top: light-dark(#fff5f4, #3a2021);
    --red-bottom: light-dark(#feeded, #341c1d);
    --red-edge: light-dark(#f8e1df, #552a2b);
    --red-ink: light-dark(#d1343c, #ff7b7f);
    --tray: light-dark(#f8f8f8, #181818);
    --tray-edge: light-dark(#e6e6e6, #2a2a2a);
    --file-icon: light-dark(#f4f4f4, #2c2c2c);
    --file-fill: light-dark(#dedede, #4a4a4a);
    --file-line: light-dark(#9c9c9c, #8a8a8a);
    --remove: light-dark(#fff, #2e2e2e);
    --remove-edge: light-dark(#dcdcdc, #474747);
    --remove-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.4));
    --remove-ink: light-dark(#333, #d6d6d6);
    --add: light-dark(#f2f2f2, #222);
    --add-edge: light-dark(#d4d4d4, #474747);
    --add-ink: light-dark(#8a8a8a, #949494);
    --pointer-beam: light-dark(#141414, #f2f2f2);
  }
}
/* 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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(button, input) {
  margin: 0;
  font: inherit;
  color: inherit;
}
.root :where(button) {
  cursor: pointer;
}
.root :where(button, input, [role='option']):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The composer and the title under it, anchored to the stage's foot: the tray grows upward. A
   layer without pixels of its own, so that it grows and shrinks without repainting the page. */
.dock {
  position: absolute;
  right: 0;
  bottom: 34px;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: min(762px, 100% - 24px);
  margin: 0 auto;
  will-change: transform;
}
.composer {
  position: relative;
  width: 100%;
}

/* The field's card. */
.card {
  position: relative;
  z-index: 1;
  padding: 11px 16px 8px;
  border-radius: 18px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--line),
    0 1.5px 2px var(--shadow-near),
    0 7px 10px -5px var(--shadow);
  will-change: transform;
  transition: box-shadow 0.15s ease;
}
/* An empty box as tall as the card with two lines of text and its edge: the card's layer keeps
   its size while the message grows to a second line. */
.card::before {
  content: '';
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 118px;
  pointer-events: none;
}
.card:focus-within {
  box-shadow:
    0 0 0 1px var(--line-focus),
    0 1.5px 2px var(--shadow-near),
    0 7px 10px -5px var(--shadow);
}
.field {
  position: relative;
  min-height: 24px;
  max-height: 96px;
  overflow-y: auto;
  font-size: 14.5px;
  line-height: 24px;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  outline: none;
  cursor: text;
}
.field[data-blank]::before {
  content: attr(data-placeholder);
  position: absolute;
  top: 0;
  left: 0;
  color: var(--faint);
  pointer-events: none;
}
/* The demo's caret: the field shows where it types without holding focus. It blinks on a layer
   of its own, so the card is not drawn again at every blink. */
.field[data-caret]::after {
  content: '';
  display: inline-block;
  width: 1px;
  height: 18px;
  margin-left: 1px;
  vertical-align: -4px;
  background: var(--ink);
  animation: blink 1.06s steps(1) infinite;
  will-change: opacity;
}
@keyframes blink {
  50% {
    opacity: 0;
  }
}

/* A mention, as a token the caret steps over. */
.mention {
  display: inline-block;
  margin: 0 5px 0 0;
  padding: 0 5.3px;
  border-radius: 4px;
  background: var(--mention);
  color: var(--mention-ink);
  font-size: 10.3px;
  font-weight: 500;
  line-height: 17px;
  vertical-align: 1px;
  white-space: nowrap;
}

/* The tools along the card's foot, and the round send button at its right. */
.toolbar {
  display: flex;
  align-items: center;
  height: 29px;
  margin: 19.4px -8px 0 -1px;
}
/* The tools sit closer than their round highlight is wide: the ring of the highlight overlaps. */
.tool {
  display: grid;
  flex: none;
  place-items: center;
  width: 28.6px;
  height: 28.6px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--tool);
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.tool:hover,
.tool[data-hovered] {
  background: var(--hover);
  box-shadow: 0 0 0 1.2px var(--hover);
}
.tool[aria-pressed='true'] {
  background: var(--tool-on);
  box-shadow: 0 0 0 1.2px var(--tool-on);
}
.tool svg {
  display: block;
  width: 18.5px;
  height: 18.5px;
  stroke-width: 1.1;
}
.send {
  display: grid;
  place-items: center;
  width: 28.4px;
  height: 28.4px;
  margin-left: auto;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: radial-gradient(
    120% 100% at 50% 0%,
    var(--accent-top) 0%,
    var(--accent) 50%,
    var(--accent-end) 100%
  );
  box-shadow:
    inset 0 1px 0 var(--accent-shine),
    0 1px 3px var(--accent-glow);
  color: var(--accent-ink);
  transition: transform 0.15s ease;
}
.send:active {
  transform: scale(0.94);
}
.send svg {
  display: block;
  width: 17px;
  height: 17px;
}

/* The new-thread button's tooltip: dark, with its shortcut in small keys. */
.tipAnchor {
  position: relative;
  display: flex;
}
.tooltip {
  --shift: 0px;
  position: absolute;
  z-index: 3;
  bottom: calc(100% + 8px);
  left: 50%;
  display: flex;
  align-items: center;
  gap: 9.5px;
  height: 26.25px;
  padding: 0 5px 0 9.5px;
  border-radius: 7px;
  background: var(--tip);
  box-shadow: 0 6px 16px -4px var(--tip-shadow);
  color: var(--tip-ink);
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
  translate: calc(-50% + var(--shift)) 0;
  transform-origin: 50% 100%;
  animation: pop 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
  pointer-events: none;
  will-change: transform;
}
.keys {
  display: flex;
  gap: 3.5px;
}
.key {
  display: grid;
  place-items: center;
  min-width: 14.7px;
  height: 15px;
  padding: 0 3px;
  border-radius: 3.5px;
  background: var(--key);
  box-shadow: inset 0 0 0 1px var(--key-edge);
  color: var(--key-ink);
  font:
    500 9.5px/1 Inter,
    -apple-system,
    BlinkMacSystemFont,
    sans-serif;
}

@keyframes pop {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.97);
  }
}

/* The thread's title, as a pill that opens its menu. */
/* The thread's title: grey text that turns into a raised pill while its menu is open. */
.titleRow {
  display: flex;
  justify-content: center;
  margin-top: 12.5px;
}
/* The title and its menu, which lines up with the title's right end. */
.titleAnchor {
  position: relative;
  display: flex;
  max-width: 100%;
}
/* The pill paints on a small layer of its own. Its shadows are there while it is closed too, in
   transparent colours, so the layer keeps one size. */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  height: 21px;
  padding: 0 9.5px 0 8.4px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  box-shadow:
    inset 0 0 0 1px transparent,
    0 1px 2px transparent;
  color: var(--title);
  font-size: 10.2px;
  line-height: 21px;
  white-space: nowrap;
  will-change: transform;
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease,
    color 0.15s ease;
}
.pill:hover {
  background: var(--title-hover);
  color: var(--title-hover-ink);
}
.pill[data-open] {
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px var(--title-open-edge),
    0 1px 2px var(--title-open-shadow);
  color: var(--ink);
}
.pillChevron,
.pillStar {
  display: grid;
  flex: none;
}
.pillChevron svg {
  width: 15px;
  height: 15px;
  margin: 0 -4px;
  color: var(--title-chevron);
}
.pillChevron {
  transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pill[data-open] .pillChevron {
  transform: rotate(180deg);
}
.pillStar svg {
  width: 10px;
  height: 10px;
  color: var(--star);
  fill: var(--star);
}
.renameField {
  width: calc(var(--chars) * 1ch + 32px);
  max-width: 100%;
  height: 21px;
  padding: 0 9px;
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px var(--field-edge),
    0 0 0 3px var(--field-glow);
  color: var(--ink);
  font-size: 10.2px;
  text-align: center;
  outline: none;
  will-change: transform;
}

/* Popovers: the thread's menu and the mention picker, each on a layer of its own while open. */
.menu,
.picker {
  position: absolute;
  z-index: 4;
  padding: 4px;
  border-radius: 11px;
  background: var(--surface-3);
  box-shadow:
    0 0 0 1px var(--popover-ring),
    0 4px 8px var(--popover-near),
    0 8px 24px var(--popover-far);
  animation: pop 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
  will-change: transform;
}
.menu {
  right: 0;
  bottom: calc(100% + 8.4px);
  width: 190px;
  transform-origin: 100% 100%;
}
.person {
  display: block;
  width: 100%;
  padding: 8px 10.5px 5px;
  border: 0;
  border-radius: 7px;
  background: var(--person);
  text-align: left;
  transition: background-color 0.12s ease;
}
.person[data-active] {
  background: var(--person-active);
}
.personLine {
  display: block;
  overflow: hidden;
  color: var(--ink-muted);
  font-size: 11.5px;
  line-height: 18px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.personName {
  color: var(--ink);
  font-weight: 600;
}
.profileLink {
  display: block;
  margin-top: 2px;
  color: var(--magenta);
  font-size: 10.3px;
  line-height: 18px;
}
.menuGroup {
  display: grid;
  gap: 1.5px;
}
.menuLabel {
  margin: 0;
  padding: 11px 7px 2.3px;
  color: var(--menu-label);
  font-size: 10.6px;
  line-height: 16px;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 8.9px;
  width: 100%;
  height: 28.9px;
  padding: 0 7.1px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--ink-soft);
  font-size: 12.3px;
  font-weight: 500;
  text-align: left;
  transition:
    background-color 0.12s ease,
    color 0.12s ease;
}
.menuItem[data-active] {
  background: linear-gradient(var(--lit-top), var(--lit-bottom));
  box-shadow:
    inset 0 0 0 1px var(--lit-edge),
    0 1px 2px var(--lit-shadow);
  color: var(--ink);
}
.menuItem[data-action='delete'] {
  color: var(--red);
}
.menuItem[data-action='delete'][data-active] {
  background: linear-gradient(var(--red-top), var(--red-bottom));
  box-shadow:
    inset 0 0 0 1px var(--red-edge),
    0 1px 2px var(--lit-shadow);
  color: var(--red-ink);
}
.menuIcon {
  display: grid;
  flex: none;
  color: var(--menu-icon);
}
.menuItem[data-action='delete'] .menuIcon {
  color: var(--red);
}
.menuIcon[data-on] {
  color: var(--star);
  fill: var(--star);
}
.menuIcon svg {
  display: block;
  width: 16px;
  height: 16px;
}

/* The mention picker: a search box and the apps that match it, in a short list that scrolls. */
.picker {
  bottom: calc(100% - 4.2px);
  padding: 3.5px;
  transform-origin: 0 100%;
}
.search {
  display: block;
  width: 100%;
  height: 28px;
  padding: 0 6.3px;
  border: 0;
  border-radius: 6.3px;
  background: var(--search);
  box-shadow: inset 0 0 0 1px var(--search-edge);
  color: var(--ink);
  font-size: 12.3px;
}
.root .search:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 1px var(--search-focus);
}
.search::placeholder {
  color: var(--search-placeholder);
}
.searchCaret {
  position: absolute;
  top: 10px;
  left: 9.8px;
  width: 1px;
  height: 15px;
  background: var(--ink);
  visibility: hidden;
  will-change: opacity;
}
.searchCaret[data-shown] {
  visibility: visible;
  animation: blink 1.06s steps(1) infinite;
}
.options {
  max-height: 133px;
  margin: 6.8px 0 0;
  padding: 0;
  overflow-y: auto;
  list-style: none;
  scrollbar-width: none;
}
.options::-webkit-scrollbar {
  display: none;
}
/* While the demo plays, the list clips rather than scrolls; the demo moves it itself. */
.root[data-demo] .options {
  overflow-y: hidden;
}
.option {
  display: flex;
  align-items: center;
  gap: 8.4px;
  height: 27.3px;
  margin-bottom: 3.15px;
  padding: 0 6.8px;
  border-radius: 6.3px;
  color: var(--ink-soft);
  font-size: 12.4px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.12s ease,
    color 0.12s ease;
}
.option[data-active] {
  background: linear-gradient(var(--lit-top), var(--lit-bottom));
  box-shadow:
    inset 0 0 0 1px var(--option-edge),
    0 1px 2px var(--option-shadow);
  color: var(--ink);
}
/* The glyph keeps its own colour, so a row's highlight never restyles its drawing. */
.glyph {
  display: grid;
  flex: none;
  color: var(--ink-soft);
}
.glyph svg {
  display: block;
  width: 16.8px;
  height: 16.8px;
}
.appName {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tag {
  flex: none;
  height: 15.75px;
  margin-left: 2px;
  padding: 0 5.5px;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--tag-edge);
  color: var(--tag-ink);
  font-size: 10.7px;
  font-weight: 500;
  line-height: 15.75px;
}
.noMatch {
  padding: 6px 8px;
  color: var(--ink-muted);
  font-size: 13px;
}

/* The attachments' tray, behind the card's top edge. */
.tray {
  display: flex;
  gap: 8.4px;
  margin-bottom: -20px;
  padding: 15.75px 15.75px 34.7px;
  overflow-x: auto;
  border-radius: 18px 18px 0 0;
  background: var(--tray);
  box-shadow: inset 0 0 0 1px var(--tray-edge);
  scrollbar-width: none;
  animation: pop 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
  will-change: transform;
}
.root[data-demo] .tray {
  overflow-x: hidden;
}
.picture,
.fileCard {
  position: relative;
  flex: none;
  height: 61px;
  animation: tile 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes tile {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
}
.picture {
  width: 61px;
}
.pictureImage {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 8.4px;
  object-fit: cover;
}
.fileCard {
  display: flex;
  align-items: center;
  gap: 8.4px;
  width: 189px;
  padding: 0 10px 0 6.8px;
  border-radius: 10.5px;
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--tray-edge);
}
.fileIcon {
  display: grid;
  flex: none;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: 6.3px;
  background: var(--file-icon);
}
.fileIcon svg {
  width: 31px;
  height: 31px;
  fill: var(--file-fill);
  stroke: var(--file-line);
}
.fileText {
  display: grid;
  min-width: 0;
}
.fileName {
  overflow: hidden;
  color: var(--ink);
  font-size: 12.25px;
  font-weight: 500;
  line-height: 18px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fileType {
  color: var(--ink-muted);
  font-size: 11.8px;
  line-height: 16px;
}
/* A tile's cross, at its corner: under the pointer or the focus, always on touch screens. */
.remove {
  position: absolute;
  top: -1px;
  right: -2px;
  display: grid;
  place-items: center;
  width: 12.6px;
  height: 12.6px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--remove);
  box-shadow:
    inset 0 0 0 1px var(--remove-edge),
    0 1px 2px var(--remove-shadow);
  color: var(--remove-ink);
  opacity: 0;
  transition: opacity 0.15s ease;
}
.remove svg {
  display: block;
  width: 11px;
  height: 11px;
}
:is(.picture, .fileCard):is(:hover, :focus-within, [data-hovered]) .remove {
  opacity: 1;
}
@media (hover: none) {
  .remove {
    opacity: 1;
  }
}
.addMore {
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 61px;
  padding: 0;
  border: 1px dashed var(--add-edge);
  border-radius: 8.4px;
  background: var(--add);
  color: var(--add-ink);
}
.addMore svg {
  width: 16px;
  height: 16px;
}

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

@media (prefers-reduced-motion: reduce) {
  .card,
  .tool,
  .send,
  .pill,
  .pillChevron,
  .person,
  .menuItem,
  .option,
  .remove {
    transition: none;
  }
}
```

### chat-composer/editor.ts

```tsx
/*
 * The message field is a contenteditable box: plain text, plus mentions, which are small tokens
 * the caret steps over as a whole (contenteditable="false"). These helpers read and change it
 * directly; React does not render its content.
 */

/** Where an "@" was typed: its text node and its offset in it. */
export type MentionAnchor = { node: Text; offset: number };

/** The "@" just before the caret, if the caret sits right after one at the start of a word. */
export function atBeforeCaret(field: HTMLElement): MentionAnchor | null {
  const selection = window.getSelection();
  const node = selection?.anchorNode;
  if (!selection?.isCollapsed || !node || node.nodeType !== Node.TEXT_NODE) return null;
  if (!field.contains(node)) return null;
  const offset = selection.anchorOffset - 1;
  const text = node.textContent ?? '';
  if (offset < 0 || text[offset] !== '@') return null;
  if (offset > 0 && !/\s/.test(text[offset - 1])) return null;
  return { node: node as Text, offset };
}

/** Puts the caret at `offset` in `node`. */
export function placeCaret(node: Node, offset: number) {
  const selection = window.getSelection();
  if (!selection) return;
  const range = document.createRange();
  range.setStart(node, offset);
  range.collapse(true);
  selection.removeAllRanges();
  selection.addRange(range);
}

/** A mention token: "@Name", which the caret steps over. */
function token(name: string, id: string, className: string) {
  const chip = document.createElement('span');
  chip.className = className;
  chip.contentEditable = 'false';
  chip.dataset.mention = id;
  chip.textContent = `@${name}`;
  return chip;
}

/**
 * Turns the "@" at `anchor` into a mention token followed by a space, and puts the caret after it
 * when `caret` is set.
 */
export function insertMention(
  anchor: MentionAnchor,
  mention: { name: string; id: string },
  className: string,
  caret: boolean,
) {
  const { node, offset } = anchor;
  if (!node.isConnected || node.textContent?.[offset] !== '@') return;
  const rest = node.splitText(offset);
  rest.deleteData(0, 1);
  const chip = token(mention.name, mention.id, className);
  const space = document.createTextNode(' ');
  rest.before(chip, space);
  if (caret) placeCaret(space, 1);
}

/** Adds `text` at the end of the field, as typing at its end would, without moving focus. */
export function typeAtEnd(field: HTMLElement, text: string) {
  const last = field.lastChild;
  if (last?.nodeType === Node.TEXT_NODE) (last as Text).appendData(text);
  else field.append(document.createTextNode(text));
}

/** The "@" the demo just typed at the end of the field. */
export function lastAt(field: HTMLElement): MentionAnchor | null {
  const last = field.lastChild;
  if (last?.nodeType !== Node.TEXT_NODE) return null;
  const node = last as Text;
  const offset = node.data.lastIndexOf('@');
  return offset < 0 ? null : { node, offset };
}

/** Puts the caret at the end of the field. */
export function caretAtEnd(field: HTMLElement) {
  const last = field.lastChild;
  if (last?.nodeType === Node.TEXT_NODE) placeCaret(last, (last as Text).length);
  else placeCaret(field, field.childNodes.length);
}

/** The field's message: its text with each mention as "@Name", and the mentions' ids. */
export function readMessage(field: HTMLElement) {
  const mentions = Array.from(field.querySelectorAll<HTMLElement>('[data-mention]'), (chip) =>
    String(chip.dataset.mention),
  );
  const text = (field.textContent ?? '').replace(/ /g, ' ').trim();
  return { text, mentions };
}

/** Whether the field holds nothing but blank space. */
export function isBlank(field: HTMLElement) {
  return !field.querySelector('[data-mention]') && !(field.textContent ?? '').trim();
}
```

### chat-composer/mention-picker.tsx

```tsx
'use client';

import { useEffect, useId, useRef, type Ref } from 'react';
import type { MentionApp } from './apps';
import styles from './chat-composer.module.css';

/*
 * The mention picker: a small popover over the message field with a search box and the apps that
 * match it. The search box is a combobox: the arrow keys move through the list, Enter mentions
 * the app in view, Escape closes the picker and puts the caret back after the "@". A row the
 * pointer moves over is the one in view, so the pointer and the keys share one highlight.
 */

/** The picker's width, in px; the composer keeps it inside the field's box. */
export const PICKER_WIDTH = 201;

/** The apps whose names contain `query`, ignoring case. */
export const matchesOf = (apps: MentionApp[], query: string) => {
  const needle = query.trim().toLowerCase();
  return needle ? apps.filter((app) => app.name.toLowerCase().includes(needle)) : apps;
};

export function MentionPicker({
  apps,
  query,
  active,
  left,
  demoCaret,
  searchRef,
  onQuery,
  onActive,
  onPick,
  onClose,
}: {
  apps: MentionApp[];
  /** The text in the search box. */
  query: string;
  /** The row in view, by index in the matches. */
  active: number;
  /** Where the picker starts, in px from the field's left edge. */
  left: number;
  /** The demo is typing: a caret shows in the search box, which does not hold focus. */
  demoCaret: boolean;
  searchRef: Ref<HTMLInputElement>;
  onQuery: (query: string) => void;
  onActive: (index: number) => void;
  onPick: (app: MentionApp) => void;
  /** Closes the picker; `refocus` puts the caret back in the field. */
  onClose: (refocus: boolean) => void;
}) {
  const listId = useId();
  const list = useRef<HTMLUListElement>(null);
  const matches = matchesOf(apps, query);
  const current = matches[Math.min(active, matches.length - 1)];

  // The row in view stays inside the list's scroll, without scrolling the page.
  useEffect(() => {
    const box = list.current;
    const row = box?.querySelector<HTMLElement>('[data-active]');
    if (!box || !row) return;
    if (row.offsetTop < box.scrollTop) box.scrollTop = row.offsetTop - 4;
    else if (row.offsetTop + row.offsetHeight > box.scrollTop + box.clientHeight) {
      box.scrollTop = row.offsetTop + row.offsetHeight - box.clientHeight + 4;
    }
  }, [active, query]);

  return (
    <div className={styles.picker} data-part="picker" style={{ left, width: PICKER_WIDTH }}>
      <span className={styles.searchCaret} data-shown={(demoCaret && !query) || undefined} />
      <input
        ref={searchRef}
        className={styles.search}
        data-part="search"
        role="combobox"
        aria-label="Search apps to mention"
        aria-expanded="true"
        aria-controls={listId}
        aria-autocomplete="list"
        aria-activedescendant={current ? `${listId}-${current.id}` : undefined}
        placeholder="Search..."
        value={query}
        autoComplete="off"
        spellCheck={false}
        onChange={(event) => onQuery(event.target.value)}
        onBlur={(event) => {
          if (!event.currentTarget.parentElement?.contains(event.relatedTarget)) onClose(false);
        }}
        onKeyDown={(event) => {
          const count = matches.length;
          if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            if (count) onActive((active + (event.key === 'ArrowDown' ? 1 : count - 1)) % count);
          } else if (event.key === 'Home' || event.key === 'End') {
            event.preventDefault();
            if (count) onActive(event.key === 'Home' ? 0 : count - 1);
          } else if (event.key === 'Enter') {
            event.preventDefault();
            if (current) onPick(current);
          } else if (event.key === 'Escape' || event.key === 'Tab') {
            if (event.key === 'Escape') event.preventDefault();
            event.stopPropagation();
            onClose(event.key === 'Escape');
          }
        }}
      />
      <ul ref={list} id={listId} className={styles.options} role="listbox" aria-label="Apps">
        {matches.map((app, index) => (
          <li
            key={app.id}
            id={`${listId}-${app.id}`}
            className={styles.option}
            data-part="option"
            role="option"
            aria-selected={app === current}
            data-active={app === current || undefined}
            data-app={app.id}
            onPointerMove={() => {
              if (index !== active) onActive(index);
            }}
            // Keeps the focus in the search box, so the pick does not close the picker first.
            onPointerDown={(event) => event.preventDefault()}
            onClick={() => onPick(app)}
          >
            <span className={styles.glyph}>{app.glyph}</span>
            <span className={styles.appName}>{app.name}</span>
            <span className={styles.tag}>App</span>
          </li>
        ))}
        {!matches.length && (
          <li className={styles.noMatch} data-part="no-match" role="presentation">
            No apps found
          </li>
        )}
      </ul>
    </div>
  );
}
```

### chat-composer/thread-menu.tsx

```tsx
'use client';

import { useId, useRef, type ReactNode } from 'react';
import { RenameIcon, ShareIcon, StarIcon, TrashIcon } from './icons';
import styles from './chat-composer.module.css';

/*
 * The thread's menu, opened from its title: the person the thread is with (a way to their
 * profile), then the thread's actions. It is a menu: the arrow keys, Home and End move between
 * its items, Enter or Space runs one, Escape closes it. One highlight serves the keys and the
 * pointer, and Delete turns red under it.
 */

export type ThreadAction = 'profile' | 'rename' | 'favourite' | 'share' | 'delete';

/** The menu's items, in order: the person first. */
export const MENU_ITEMS: ThreadAction[] = ['profile', 'rename', 'favourite', 'share', 'delete'];

const ACTIONS: { action: Exclude<ThreadAction, 'profile'>; label: string; icon: ReactNode }[] = [
  { action: 'rename', label: 'Rename', icon: <RenameIcon /> },
  { action: 'favourite', label: 'Favourite', icon: <StarIcon /> },
  { action: 'share', label: 'Share', icon: <ShareIcon /> },
  { action: 'delete', label: 'Delete', icon: <TrashIcon /> },
];

export function ThreadMenu({
  person,
  favourite,
  active,
  menuRef,
  onActive,
  onAction,
  onClose,
}: {
  person: { name: string; role: string };
  /** The thread is a favourite: its item is checked. */
  favourite: boolean;
  /** The highlighted item, by index in MENU_ITEMS; -1 for none. */
  active: number;
  menuRef: (element: HTMLDivElement | null) => void;
  onActive: (index: number) => void;
  onAction: (action: ThreadAction) => void;
  /** Closes the menu; `refocus` sends focus back to the title. */
  onClose: (refocus: boolean) => void;
}) {
  const labelId = useId();
  const items = useRef<(HTMLButtonElement | null)[]>([]);

  /** Highlights item `index` and focuses it, for the keys. */
  const move = (index: number) => {
    const count = MENU_ITEMS.length;
    const next = (index + count) % count;
    onActive(next);
    items.current[next]?.focus();
  };

  const item = (action: ThreadAction) => {
    const index = MENU_ITEMS.indexOf(action);
    return {
      ref: (element: HTMLButtonElement | null) => {
        items.current[index] = element;
      },
      type: 'button' as const,
      tabIndex: index === Math.max(0, active) ? 0 : -1,
      'data-active': index === active || undefined,
      onPointerMove: () => {
        if (index !== active) onActive(index);
      },
      onFocus: () => {
        if (index !== active) onActive(index);
      },
      onClick: () => onAction(action),
    };
  };

  return (
    <div
      ref={menuRef}
      className={styles.menu}
      data-part="menu"
      role="menu"
      aria-label="Thread"
      onPointerLeave={() => onActive(-1)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) onClose(false);
      }}
      onKeyDown={(event) => {
        const at = Math.max(0, active);
        if (event.key === 'ArrowDown') move(active < 0 ? 0 : at + 1);
        else if (event.key === 'ArrowUp') move(active < 0 ? -1 : at - 1);
        else if (event.key === 'Home') move(0);
        else if (event.key === 'End') move(-1);
        else if (event.key === 'Escape') onClose(true);
        else if (event.key === 'Tab') onClose(false);
        else return;
        if (event.key !== 'Tab') event.preventDefault();
      }}
    >
      <button {...item('profile')} className={styles.person} data-part="person" role="menuitem">
        <span className={styles.personLine}>
          <span className={styles.personName}>{person.name}</span> {person.role}
        </span>
        <span className={styles.profileLink}>View profile</span>
      </button>
      <div className={styles.menuGroup} role="group" aria-labelledby={labelId}>
        <p id={labelId} className={styles.menuLabel}>
          Thread actions
        </p>
        {ACTIONS.map(({ action, label, icon }) => (
          <button
            key={action}
            {...item(action)}
            className={styles.menuItem}
            data-part="menu-item"
            data-action={action}
            role={action === 'favourite' ? 'menuitemcheckbox' : 'menuitem'}
            aria-checked={action === 'favourite' ? favourite : undefined}
          >
            <span
              className={styles.menuIcon}
              data-on={(action === 'favourite' && favourite) || undefined}
            >
              {icon}
            </span>
            {label}
          </button>
        ))}
      </div>
    </div>
  );
}
```

### chat-composer/attachments.tsx

```tsx
'use client';

import { AvatarPicture } from './avatar';
import { CrossIcon, FileIcon, PlusIcon } from './icons';
import styles from './chat-composer.module.css';

/*
 * The files attached to the message, in a grey tray over the field: a picture shows as a square
 * tile, any other file as a card with its name and type. Each one's cross, at its corner, shows
 * under the pointer or the keyboard's focus (always, on touch screens); the dashed tile adds more.
 */

export type Attachment = {
  id: string;
  name: string;
  /** "PDF", "PNG"…: shown under a file's name. */
  label: string;
  /** A picture's address; a picture without one shows the illustrated portrait. */
  url?: string;
  picture: boolean;
};

export function Attachments({
  items,
  hovered,
  partRef,
  onRemove,
  onAdd,
}: {
  items: Attachment[];
  /** The tile the demo's pointer is over: its cross shows. */
  hovered: string | null;
  /** Hands each tile and its cross to the composer, which points the demo at them. */
  partRef: (key: string, element: HTMLElement | null) => void;
  onRemove: (item: Attachment) => void;
  onAdd: () => void;
}) {
  return (
    <div className={styles.tray} data-part="tray" role="group" aria-label="Attachments">
      {items.map((item) => (
        <div
          key={item.id}
          ref={(element) => partRef(`tile:${item.id}`, element)}
          className={item.picture ? styles.picture : styles.fileCard}
          data-part={item.picture ? 'picture' : 'file'}
          data-hovered={hovered === item.id || undefined}
        >
          {item.picture ? (
            item.url ? (
              <img className={styles.pictureImage} src={item.url} alt={item.name} />
            ) : (
              <AvatarPicture className={styles.pictureImage} />
            )
          ) : (
            <>
              <span className={styles.fileIcon}>
                <FileIcon />
              </span>
              <span className={styles.fileText}>
                <span className={styles.fileName}>{item.name}</span>
                <span className={styles.fileType}>{item.label}</span>
              </span>
            </>
          )}
          <button
            ref={(element) => partRef(`remove:${item.id}`, element)}
            type="button"
            className={styles.remove}
            data-part="remove"
            aria-label={`Remove ${item.name}`}
            onClick={() => onRemove(item)}
          >
            <CrossIcon />
          </button>
        </div>
      ))}
      <button
        type="button"
        className={styles.addMore}
        data-part="add"
        aria-label="Attach more files"
        onClick={onAdd}
      >
        <PlusIcon />
      </button>
    </div>
  );
}
```

### chat-composer/apps.tsx

```tsx
import type { ReactNode } from 'react';

/*
 * The apps a message can mention, each with a small square glyph drawn here. The names and the
 * glyphs are made up for the demo; pass your own through the composer's `apps` prop.
 */

export type MentionApp = {
  id: string;
  name: string;
  /** A 16 × 16 glyph. */
  glyph: ReactNode;
};

const square = { viewBox: '0 0 16 16', 'aria-hidden': true } as const;

/** Two nodes on a looping branch, dark on white. */
function LoomGlyph() {
  return (
    <svg {...square}>
      <rect x=".5" y=".5" width="15" height="15" rx="3.5" fill="#fff" stroke="#e4e4e4" />
      <path
        d="M5.4 4.6v6.8M5.4 8.8c0-2 1.4-2.8 3.2-2.8h1.2"
        fill="none"
        stroke="#1f1f1f"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
      <circle cx="5.4" cy="11.6" r="1.4" fill="#1f1f1f" />
      <circle cx="10.8" cy="6" r="1.4" fill="#1f1f1f" />
    </svg>
  );
}

/** A paper plane in warm colours, on white. */
function PostGlyph() {
  return (
    <svg {...square}>
      <rect x=".5" y=".5" width="15" height="15" rx="3.5" fill="#fff" stroke="#e4e4e4" />
      <path d="m3.2 7.6 9.6-4.2-3.4 9.4-1.8-3.6z" fill="#f2603b" />
      <path d="m7.6 9.2 5.2-5.8-5 4.4z" fill="#f9b233" />
    </svg>
  );
}

/** Three lanes of different lengths, white on black. */
function LanesGlyph() {
  return (
    <svg {...square}>
      <rect width="16" height="16" rx="3.8" fill="#181818" />
      <path
        d="M4.2 5.2h7.6M4.2 8h5M4.2 10.8h6.4"
        stroke="#fff"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

/** Two nested squares, white on violet. */
function NestGlyph() {
  return (
    <svg {...square}>
      <rect width="16" height="16" rx="3.8" fill="#6c5ce7" />
      <rect x="4" y="4" width="8" height="8" rx="2" fill="none" stroke="#fff" strokeWidth="1.5" />
      <rect x="6.6" y="6.6" width="2.8" height="2.8" rx=".8" fill="#fff" />
    </svg>
  );
}

/** A hexagon, white on black. */
function HostGlyph() {
  return (
    <svg {...square}>
      <rect width="16" height="16" rx="3.8" fill="#111" />
      <path
        d="m8 3.6 3.8 2.2v4.4L8 12.4l-3.8-2.2V5.8z"
        fill="none"
        stroke="#fff"
        strokeWidth="1.4"
      />
    </svg>
  );
}

/** A folded note, white on amber. */
function NoteGlyph() {
  return (
    <svg {...square}>
      <rect width="16" height="16" rx="3.8" fill="#f5b70a" />
      <path d="M5 5.4h6M5 8h6M5 10.6h3.6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

/** A speech bubble, white on green. */
function MurmurGlyph() {
  return (
    <svg {...square}>
      <rect width="16" height="16" rx="3.8" fill="#18a46b" />
      <path
        d="M4 5.6c0-.9.7-1.6 1.6-1.6h4.8c.9 0 1.6.7 1.6 1.6v3.2c0 .9-.7 1.6-1.6 1.6H7.4L5 12.2v-1.8A1.6 1.6 0 0 1 4 8.8z"
        fill="#fff"
      />
    </svg>
  );
}

export const APPS: MentionApp[] = [
  { id: 'gitloom', name: 'Gitloom', glyph: <LoomGlyph /> },
  { id: 'postly', name: 'Postly', glyph: <PostGlyph /> },
  { id: 'lanes', name: 'Lanes', glyph: <LanesGlyph /> },
  { id: 'paynest', name: 'Paynest', glyph: <NestGlyph /> },
  { id: 'hostora', name: 'Hostora', glyph: <HostGlyph /> },
  { id: 'notely', name: 'Notely', glyph: <NoteGlyph /> },
  { id: 'murmur', name: 'Murmur', glyph: <MurmurGlyph /> },
];
```

### chat-composer/avatar.tsx

```tsx
import { useId } from 'react';

/*
 * A picture attached to a message: an illustrated portrait (a man with glasses, a short beard and
 * a dark jumper on a sand-coloured ground), drawn once with soft gradients.
 */

export function AvatarPicture({ className }: { className?: string }) {
  const id = useId();
  return (
    <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}-ground`} cx="0.5" cy="0.35" r="0.75">
          <stop offset="0" stopColor="#f1ece2" />
          <stop offset="1" stopColor="#d9d0c0" />
        </radialGradient>
        <radialGradient id={`${id}-skin`} cx="0.42" cy="0.35" r="0.7">
          <stop offset="0" stopColor="#ffd9a6" />
          <stop offset="0.65" stopColor="#efb878" />
          <stop offset="1" stopColor="#d89a5c" />
        </radialGradient>
        <linearGradient id={`${id}-jumper`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#3a342e" />
          <stop offset="1" stopColor="#1b1815" />
        </linearGradient>
      </defs>
      <rect width="64" height="64" fill={`url(#${id}-ground)`} />
      {/* Jumper and neck. */}
      <path d="M10 64c1-9.6 9.2-15.4 22-15.4S53 54.4 54 64z" fill={`url(#${id}-jumper)`} />
      <path d="M26.4 44h11.2v6.4c-1.6 2-3.6 3-5.6 3s-4-1-5.6-3z" fill="#d89a5c" />
      <path
        d="M24.2 51.2c2.2 2.4 4.8 3.6 7.8 3.6s5.6-1.2 7.8-3.6"
        fill="none"
        stroke="#4a423a"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      {/* Head, ears and beard. */}
      <ellipse cx="17.6" cy="30.6" rx="2.6" ry="3.6" fill="#e3a868" />
      <ellipse cx="46.4" cy="30.6" rx="2.6" ry="3.6" fill="#e3a868" />
      <path
        d="M18 27c0-9 6-14.6 14-14.6S46 18 46 27v6.4c0 7.6-6.4 13.4-14 13.4S18 41 18 33.4z"
        fill={`url(#${id}-skin)`}
      />
      <path
        d="M18.6 33.2c1 1.4 2.4 2 4 2.2 2.4 3.4 5.8 3.6 9.4 3.6s7-.2 9.4-3.6c1.6-.2 3-.8 4-2.2-.6 7.6-6.6 13.6-13.4 13.6S19.2 40.8 18.6 33.2z"
        fill="#5a3f2b"
        opacity="0.9"
      />
      <path
        d="M28.6 40.2c2.2 1.2 4.6 1.2 6.8 0"
        fill="none"
        stroke="#f3c08a"
        strokeWidth="1.2"
        strokeLinecap="round"
      />
      {/* Hair. */}
      <path
        d="M17.6 28.4c-.8-8.6 3.6-16 12.6-17.4 4.8-.8 9.8.4 12.8 3.6 2.6 2.6 3.6 6.6 3.4 10.6l-.6 3c-1.2-3.6-3.4-6.2-6.6-7.4-4.4 1.8-10.6 2-15.6.4-2.6 1.8-4.6 4.4-6 7.2z"
        fill="#2e2119"
      />
      {/* Glasses. */}
      <g fill="none" stroke="#141210" strokeWidth="2">
        <rect x="20.6" y="25.4" width="9.6" height="7.4" rx="2.4" />
        <rect x="33.8" y="25.4" width="9.6" height="7.4" rx="2.4" />
        <path d="M30.2 28.4c1.2-.8 2.4-.8 3.6 0M20.6 27.6h-2.4M43.4 27.6h2.4" />
      </g>
      <circle cx="25.4" cy="29.2" r="1.4" fill="#2a1f18" />
      <circle cx="38.6" cy="29.2" r="1.4" fill="#2a1f18" />
      <path
        d="M22.2 23.2c1.6-1 3.6-1.2 5.6-.6M36.2 22.6c2-.6 4-.4 5.6.6"
        fill="none"
        stroke="#2e2119"
        strokeWidth="1.4"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### chat-composer/icons.tsx

```tsx
/*
 * The composer's icons: outlined, drawn on a 16 × 16 grid with round caps, in the current colour.
 * They are decorative; the controls that hold them carry the labels.
 */

type IconProps = { className?: string };

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

/** A paper clip. */
export function ClipIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m13.2 7.4-5.4 5.4a3.3 3.3 0 0 1-4.7-4.7l5.6-5.6a2.2 2.2 0 0 1 3.1 3.1l-5.5 5.5a1.1 1.1 0 0 1-1.6-1.6l5-5" />
    </svg>
  );
}

/** A ticked box: a task. */
export function TaskIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="2.6" y="2.6" width="10.8" height="10.8" rx="2.2" />
      <path d="m5.7 8.2 1.6 1.6 3-3.3" />
    </svg>
  );
}

/** A speech bubble with a plus: a new thread. */
export function NewThreadIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M13.6 8.4a5.6 5.6 0 1 0-10.3 3L2.6 13.6l2.2-.6a5.6 5.6 0 0 0 3.4 1.2" />
      <path d="M12.4 10.4v4M10.4 12.4h4" />
    </svg>
  );
}

/** An arrow pointing up: send. */
export function SendIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline} strokeWidth={1.6}>
      <path d="M8 12.6V3.6M4.4 7.2 8 3.6l3.6 3.6" />
    </svg>
  );
}

/** A chevron pointing down. */
export function ChevronIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m4.6 6.4 3.4 3.4 3.4-3.4" />
    </svg>
  );
}

/** A letter T beside a text cursor: rename. */
export function RenameIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M2.2 3.8h6M5.2 3.8v8.6M11.4 3.2v9.6M10 3.2h2.8M10 12.8h2.8" />
    </svg>
  );
}

/** A star. */
export function StarIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m8 2.5 1.7 3.5 3.8.5-2.8 2.6.7 3.8L8 11.1l-3.4 1.8.7-3.8-2.8-2.6 3.8-.5z" />
    </svg>
  );
}

/** A box with an arrow leaving it: share. */
export function ShareIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M2.8 7.2v5.4c0 .5.4.9.9.9h8.8" />
      <path d="M5.4 10.6c.6-2.8 2.8-4.6 5.8-4.6h1.8" />
      <path d="m11.2 3.6 2.4 2.4-2.4 2.4" />
    </svg>
  );
}

/** A bin. */
export function TrashIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M2.8 4.4h10.4M6.2 4.4V3.2c0-.4.3-.7.7-.7h2.2c.4 0 .7.3.7.7v1.2" />
      <path d="M4 4.4l.6 8.2c0 .5.5.9 1 .9h4.8c.5 0 1-.4 1-.9l.6-8.2M6.7 7v4M9.3 7v4" />
    </svg>
  );
}

/** A page with a folded corner, in the fill and stroke its box gives it (the stylesheet's). */
export function FileIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M4.4 1.8h4.6l3.4 3.4v8a1.4 1.4 0 0 1-1.4 1.4H4.4A1.4 1.4 0 0 1 3 13.2V3.2a1.4 1.4 0 0 1 1.4-1.4z"
        strokeWidth="1"
        strokeLinejoin="round"
      />
      <path d="M9 1.8v2.4c0 .6.4 1 1 1h2.4" fill="none" strokeWidth="1" />
    </svg>
  );
}

/** A plus. */
export function PlusIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M8 3.4v9.2M3.4 8h9.2" />
    </svg>
  );
}

/** A cross. */
export function CrossIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline} strokeWidth={1.6}>
      <path d="m4.6 4.6 6.8 6.8M11.4 4.6l-6.8 6.8" />
    </svg>
  );
}
```

### chat-composer/ghost-pointer.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './ghost-pointer.module.css';

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an
 * I-beam over text. Its shapes are drawn once, each in a small layer of its own, and swapped by
 * scaling those layers to nothing and back: a change of opacity or of a rule matched through the
 * pointer would reach the drawings, and Safari would lay out the whole page again. Stopping the
 * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise
 * rejects, which ends the script right there, and the pointer fades.
 */

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

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

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

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

/** Plays `script` with `pointer` over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  const shapes = Array.from(pointer.querySelectorAll<HTMLElement>('[data-shape-of]'));
  let current: Shape | null = null;
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.92 };

  /** Shows `shape` and hides the others. */
  const become = (shape: Shape) => {
    if (shape === current) return;
    current = shape;
    for (const box of shapes) {
      box.style.transform = box.dataset.shapeOf === shape ? 'scale(1)' : 'scale(0)';
    }
  };

  /** 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);
      }),
    );

  /** A point of `target`, in the stage's own pixels (the stage may sit in a scaled container). */
  const locate = (target: Element, share: Point = { x: 0.5, y: 0.5 }) => {
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
  };

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

  const moveTo: Demo['moveTo'] = (target, options = {}) => {
    if (options.shape) become(options.shape);
    if (!target) return step(Promise.resolve());
    const to = target instanceof Element ? locate(target, options.at) : target;
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.14, y: (from.y + to.y) / 2 + dx * 0.14 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(900, Math.max(340, Math.hypot(dx, dy) * 1.5)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const shape = shapes.find((box) => box.dataset.shapeOf === current);
    const tap = shape?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.84)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      {
        duration: 260,
        easing: 'ease-in-out',
      },
    );
    await wait(90);
    action();
    if (tap) await step(tap.finished);
  };

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

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

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

### chat-composer/ghost-pointer.module.css

```css
/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its
   tip sits at the point it is moved to. Each shape sits in a small layer of its own, inside a layer
   that paints nothing, so moving or fading the pointer never repaints the page. Its colours are
   the stage's tokens (chat-composer.module.css). */

.ghost {
  position: absolute;
  z-index: 40;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  will-change: transform;
}
.shape {
  position: absolute;
  width: 22px;
  height: 22px;
  /* The script shows one shape at a time by its scale. */
  transform: scale(0);
  will-change: transform;
}
.shape svg {
  display: block;
  width: 22px;
  height: 22px;
  overflow: visible;
  filter: drop-shadow(0 1px 1.2px var(--pointer-shadow));
}
.shape[data-shape-of='arrow'] {
  top: -2.2px;
  left: -3.3px;
  transform-origin: 3.3px 2.2px;
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shape[data-shape-of='hand'] {
  top: -1.3px;
  left: -8.4px;
  transform-origin: 8.4px 1.3px;
  fill: var(--pointer-hand);
  stroke: var(--pointer-hand-edge);
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.shape[data-shape-of='text'] {
  top: -11px;
  left: -11px;
  transform-origin: 11px 11px;
  fill: none;
  stroke: var(--pointer-beam);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
  paint-order: stroke;
}

@media (prefers-reduced-motion: reduce) {
  .ghost {
    transition: none;
  }
}
```

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