# Choice popover

> Multi-select popovers for a CRM, laid out as a board of their states: ticked records pinned on top, searches with their matches in bold, and a row that creates what was typed.

Combobox popovers that add people, companies, feedback and deals to a record. Ticked items are pinned above a separator. A search highlights its matches and offers to create what it did not find, and an empty list says to start typing. Every popover on the board is live.

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

Category: Inputs.

## Anatomy

1. **Popover**: A bordered, lifted card: the search, the list, and the create row under a line.
2. **Search field**: A combobox that filters as you type. The arrow keys light the rows, Enter ticks, Escape clears.
3. **Pinned row**: A ticked record, moved to the top when the list settles. Pinned rows sit a little closer.
4. **Checkbox**: Filled in the accent when ticked; shown on a lit row, and on every row while searching.
5. **Separator**: A hairline between the pinned records and the rest, gone when nothing is ticked.
6. **Lit row**: The row under the pointer or the arrow keys, on a soft fill. Clicking anywhere on it ticks.
7. **Picture**: An avatar, a company mark or a status: decorative, since the row says it in words.
8. **Create row**: Makes a record and ticks it. While searching it names the search: Create: "Darren".

## When to use

- Linking several records to one thing: the people on a deal, the companies behind a request.
- Lists long enough to need a search, short enough to scroll: a few to a few hundred records.
- When what is already linked should stay in view: the ticked ones are pinned above a line.
- When a missing record should be made on the spot, from what was typed, without leaving the list.
- Records told apart by a picture and a detail: a face, a logo, a deal’s amount and stage.

## When not to use

- One answer only. Use a single-select menu or a radio group, which close on the pick.
- Three to five fixed options. Show them as checkboxes or chips, with nothing to search.
- Thousands of records fetched as you type: this one filters the list it holds.
- Filters whose results should update as each box is ticked across a whole page. Use a filter bar.
- Picking tags that are just words. A token field shows the choices where they are used.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { ChoicePopover, type ChoiceSlot } from './choice-popover';

// One popover alone: <Choice kind="person" items={PEOPLE} onChange={save} /> from './choice'.
export function ChoiceBoard({ save }: { save: (popover: ChoiceSlot, ids: string[]) => void }) {
  return <ChoicePopover onChange={save} autoplay={false} />;
}
```

## States

- **Resting**: The ticked records pinned on top, the rest below the line, nothing lit.
- **Lit row**: Under the pointer or the arrow keys: a fill, and an empty checkbox to say it can be ticked.
- **Ticked, not yet settled**: A new tick stays where it is, under the pointer. The list settles when the pointer leaves, gliding the row to the top in 260 ms.
- **Search with results**: Only the matches show, their match in bold, every row with its checkbox. Create names the search.
- **Search without results**: The list folds away and the create row is lit, so Enter makes the record.
- **Empty list**: Nothing to choose yet: the create row asks for a name and stays disabled until there is one.
- **Record created**: Made from a search: ticked, pinned, its initial standing in for a picture.

## How it works

- Each popover is a search field over a multi-select listbox and a create row. A row draws what its record needs: an avatar, a company mark, a feedback status and count, or a deal’s amount, reference and progress ring.
- Ticked records are pinned above the separator, a little closer together than the rest, but a tick never moves its row at once. The list settles when the pointer leaves, the field loses focus or the search changes, and the rows then glide to their new places in a FLIP step on the Web Animations API.
- The rows keep their place in the page: CSS order lays them out and a search hides the ones it leaves out, so nothing is added, removed or moved in the DOM while the demo plays. A record created from a search draws its initial in HTML.
- A search lights nothing while it has results; when it has none, it lights the create row, which names the search (Create: “Darren”). The deal popover keeps “Create new Deal…” whatever is typed. A row shows its checkbox when lit, when ticked, and on every row while searching.
- The board takes three columns, then two, then one, by container queries, and its text size follows its width.
- Every colour is a light-dark() token on the root, and on a Choice rendered on its own, which takes the colorScheme prop too, so it follows the page’s color-scheme. Avatars and company marks keep their colours, except one mark drawn in black, which turns white at night.

## API reference

- **onChange** (`(popover: ChoiceSlot, selected: string[]) => void`): Called with a popover’s name and its ticked ids whenever the reader changes them. Choice: called with its ticked ids alone.
- **kind** (`'person' | 'company' | 'feedback' | 'deal'`): Choice: what the records are, which names the popover (“Add Person…”) and draws the rows. Required.
- **items** (`ChoiceItem[]`): Choice: the records to choose from, such as PEOPLE, COMPANIES, FEEDBACK or DEALS. Required.
- **defaultSelected** (`string[]`, default `[]`): Choice: the ids ticked at first.
- **defaultQuery** (`string`, default `''`): Choice: the search the popover starts with.
- **rows** (`number`, default `6.7`): Choice: how many rows show before the list scrolls.
- **createFromQuery** (`boolean`, default `kind !== 'deal'`): Choice: whether the create row names the search, as in Create: “Darren”, rather than “Create new Deal…”.
- **ref** (`Ref<ChoiceHandle>`): Choice: drives the popover as a pointer and a keyboard would (light, toggle, search, create, settle, reset); the board’s demo uses it.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer ticks a person and a deal, searches the companies and ticks one, then types a new company’s name and creates it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own; on Choice, for the popover.

## Accessibility

- Each search field is a combobox that owns a multi-select listbox. Options carry aria-selected for their tick, and aria-posinset and aria-setsize for the order they show in, and aria-activedescendant follows the lit row.
- ↑ and ↓ light the rows in order, with the create row last. Enter ticks or creates, and Escape clears the search. The empty state’s hint is marked unavailable.
- Every tick, untick and creation is announced (“Ivo Bell added”, “Created Darren and added it”). Avatars, marks, status icons and rings are decorative; the words are in the row.
- Under reduced motion the rows settle without gliding and the demo does not play.

## Source

### choice-popover/choice-popover.tsx

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

'use client';

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Choice, type ChoiceHandle } from './choice';
import { COMPANIES, DEALS, FEEDBACK, PEOPLE } from './data';
import { DemoPointer, runDemo } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './choice-popover.module.css';

/*
 * Choice popover: multi-select popovers for a CRM, laid out as a board of their states, every
 * one of them live (choice.tsx is the popover itself).
 *
 * - On top, choosing: people, companies and deals, the ticked ones pinned above a separator.
 *   Under them, the search states: no results (the create row names the search), an empty list
 *   (start typing to create) and results with the matches in bold.
 * - The board takes the columns its width allows: three, then two, then one on a phone, each
 *   popover a set width in em of the text, which follows the width too.
 * - While on screen and untouched, a demo pointer ticks a person and a deal, searches the
 *   companies and ticks one, then names a new company and creates it; the board then starts over.
 *   On a narrow board it does the same in the popovers it shows.
 * - Light or dark: every colour of the board and the popovers 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 board's popovers, by place. */
export type ChoiceSlot = 'person' | 'company' | 'deal' | 'darren' | 'empty' | 'valu';

const SLOTS: ChoiceSlot[] = ['person', 'company', 'deal', 'darren', 'empty', 'valu'];

export function ChoicePopover({
  onChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Called with a popover's name and its ticked ids whenever the reader changes them. */
  onChange?: (popover: ChoiceSlot, selected: string[]) => void;
  /** While on screen and untouched, a demo pointer ticks, searches and creates on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [focused, setFocused] = useState(false);
  const stage = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const handles = useRef<Partial<Record<ChoiceSlot, ChoiceHandle | null>>>({});
  const stopDemo = useRef<(() => void) | null>(null);
  const handleOf = (slot: ChoiceSlot) => (handle: ChoiceHandle | null) => {
    handles.current[slot] = handle;
  };
  const changed = (slot: ChoiceSlot) => (selected: string[]) => onChange?.(slot, selected);

  const resetAll = () => SLOTS.forEach((slot) => handles.current[slot]?.reset());
  const actions = useRef({ resetAll });
  useLayoutEffect(() => {
    actions.current = { resetAll };
  });

  const demo = autoplay && playing && !focused;
  useEffect(() => {
    const box = stage.current;
    const arrow = pointer.current;
    if (!demo || !box || !arrow) return;
    const find = (slot: ChoiceSlot, selector: string) =>
      box.querySelector(`[data-slot="${slot}"] ${selector}`);
    const h = (slot: ChoiceSlot) => handles.current[slot];
    const stopRun = runDemo(box, arrow, async ({ wait, moveTo, press, hide }) => {
      /** Types `text` into a popover's field, a letter at a time, from its `from`th letter. */
      const type = async (slot: ChoiceSlot, text: string, from = 0) => {
        for (let i = from + 1; i <= text.length; i += 1) {
          h(slot)?.search(text.slice(0, i));
          await wait(110);
        }
      };
      for (;;) {
        actions.current.resetAll();
        // A narrow board shows fewer popovers: the steps go where they can.
        const shown = (slot: ChoiceSlot) =>
          (box.querySelector(`[data-slot="${slot}"]`)?.getClientRects().length ?? 0) > 0;
        const [searchIn, query, pick] = shown('company')
          ? (['company', 'Fe', 'fernlow'] as const)
          : (['person', 'Da', 'damon'] as const);
        const [createIn, name, typed] = shown('empty')
          ? (['empty', 'Halden', 0] as const)
          : (['valu', 'Valued customers', 4] as const);
        await wait(800);
        // Choosing: a person, then a deal; a list settles as the pointer leaves it.
        await moveTo(find('person', '[data-id="daniela"]'), { x: 0.5, y: 0.5 });
        h('person')?.light('daniela');
        await wait(320);
        await moveTo(find('person', '[data-id="ivo"]'), { x: 0.62, y: 0.55 });
        h('person')?.light('ivo');
        await wait(360);
        await press(() => h('person')?.toggle('ivo'));
        await wait(600);
        if (shown('deal')) {
          await moveTo(find('deal', '[data-id="d215"]'), { x: 0.55, y: 0.5 });
          h('person')?.light(null);
          h('person')?.settle();
          h('deal')?.light('d215');
          await wait(420);
          await press(() => h('deal')?.toggle('d215'));
          await wait(600);
          h('deal')?.light(null);
          h('deal')?.settle();
        }
        // Searching, and ticking a result.
        await moveTo(find(searchIn, 'input'), { x: 0.4, y: 0.5 });
        h('person')?.light(null);
        h('person')?.settle();
        await press(() => undefined);
        await type(searchIn, query);
        await wait(520);
        await moveTo(find(searchIn, `[data-id="${pick}"]`), { x: 0.6, y: 0.5 });
        h(searchIn)?.light(pick);
        await wait(320);
        await press(() => h(searchIn)?.toggle(pick));
        await wait(700);
        h(searchIn)?.search('');
        await wait(900);
        // Creating, from what is typed.
        await moveTo(find(createIn, 'input'), { x: 0.4, y: 0.5 });
        h(searchIn)?.light(null);
        await press(() => undefined);
        await type(createIn, name, typed);
        await wait(360);
        await moveTo(find(createIn, '[data-create]'), { x: 0.55, y: 0.5 });
        h(createIn)?.light('create');
        await wait(360);
        await press(() => h(createIn)?.create());
        await wait(1500);
        hide();
        await wait(1600);
      }
    });
    let stopped = false;
    const stop = () => {
      if (stopped) return;
      stopped = true;
      stopRun();
      actions.current.resetAll();
    };
    stopDemo.current = stop;
    return stop;
  }, [demo]);

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

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onPointerMove={(event) => event.pointerType !== 'touch' && readerActs()}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div ref={stage} className={styles.stage}>
        <div className={styles.board}>
          <div className={styles.slot} data-slot="person">
            <Choice
              ref={handleOf('person')}
              kind="person"
              items={PEOPLE}
              defaultSelected={['dana', 'joel']}
              onChange={changed('person')}
            />
          </div>
          <div className={styles.slot} data-slot="company">
            <Choice
              ref={handleOf('company')}
              kind="company"
              items={COMPANIES}
              defaultSelected={['sorrel', 'duograph']}
              onChange={changed('company')}
            />
          </div>
          <div className={styles.slot} data-slot="deal">
            <Choice
              ref={handleOf('deal')}
              kind="deal"
              items={DEALS}
              defaultSelected={['d88', 'd212']}
              onChange={changed('deal')}
            />
          </div>
          <div className={styles.slot} data-slot="darren">
            <p className={styles.caption}>Search state without results</p>
            <Choice
              ref={handleOf('darren')}
              kind="person"
              items={PEOPLE}
              defaultSelected={['dana', 'joel']}
              defaultQuery="Darren"
              rows={3.5}
              onChange={changed('darren')}
            />
          </div>
          <div className={styles.slot} data-slot="empty">
            <p className={styles.caption}>Empty state with empty items</p>
            <Choice
              ref={handleOf('empty')}
              kind="company"
              items={[]}
              rows={3.5}
              onChange={changed('empty')}
            />
          </div>
          <div className={styles.slot} data-slot="valu">
            <p className={styles.caption}>Search state with results</p>
            <Choice
              ref={handleOf('valu')}
              kind="feedback"
              items={FEEDBACK}
              defaultSelected={['attach', 'bulk']}
              defaultQuery="Valu"
              rows={3.5}
              onChange={changed('valu')}
            />
          </div>
        </div>
        <DemoPointer ref={pointer} />
      </div>
    </div>
  );
}
```

### choice-popover/choice.tsx

```tsx
'use client';

import {
  useId,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
  type Ref,
} from 'react';
import { NOUNS, createItem, type ChoiceItem, type Kind } from './data';
import { Avatar, CompanyMark, PlusCircle, Ring, StatusIcon } from './visuals';
import styles from './choice-popover.module.css';

/*
 * One choice popover: a search field over a list of records to tick, and a row that creates one.
 *
 * - The ticked records are pinned at the top, above a separator. A tick does not move its row at
 *   once, which would pull it from under the pointer: the list settles when the pointer leaves,
 *   when the field loses focus or when the search changes, and the rows glide to their places.
 * - The rows keep their order in the page and are placed by CSS `order`, and a search hides the
 *   ones it leaves out: nothing is added, removed or moved while the demo plays. A record made
 *   from a search draws its initial in HTML.
 * - A combobox for assistive technology: the field owns a multi-select listbox, the arrow keys
 *   light the options in the order they show, Enter ticks or creates, Escape clears the search,
 *   and every change is announced.
 * - Light or dark: its colours are light-dark() tokens (choice-popover.module.css), which a
 *   popover on its own carries itself; the scheme is the page's, or its colorScheme prop's.
 */

/** What the board's demo can do, as a pointer and a keyboard would. */
export type ChoiceHandle = {
  /** Lights a row, or the create row with 'create', or nothing. */
  light: (id: string | null) => void;
  toggle: (id: string) => void;
  /** Types into the search field. */
  search: (query: string) => void;
  create: () => void;
  /** Settles the list: the ticked records go to the top. */
  settle: () => void;
  /** Back to how it started. */
  reset: () => void;
};

const CREATE = 'create';

/** What a search lights at first: the create row when nothing matches, otherwise nothing. */
const firstLit = (query: string, items: ChoiceItem[]) => {
  const needle = query.trim().toLowerCase();
  return needle && !items.some((item) => item.label.toLowerCase().includes(needle)) ? CREATE : null;
};

/** The label with the first match of the search drawn in bold. */
function highlight(label: string, q: string): ReactNode {
  const at = q ? label.toLowerCase().indexOf(q) : -1;
  if (at < 0) return label;
  return (
    <>
      {label.slice(0, at)}
      <mark className={styles.match}>{label.slice(at, at + q.length)}</mark>
      {label.slice(at + q.length)}
    </>
  );
}

/** A record's picture: an avatar, a mark, a status; a made one shows its initial. */
function Visual({ item }: { item: ChoiceItem }) {
  const initial = item.label.trim().charAt(0).toUpperCase();
  switch (item.kind) {
    case 'person':
      return item.avatar ? (
        <Avatar look={item.avatar} className={styles.avatar} />
      ) : (
        <span className={`${styles.avatar} ${styles.initial}`} aria-hidden="true">
          {initial}
        </span>
      );
    case 'company':
    case 'deal':
      return item.mark ? (
        <CompanyMark name={item.mark} className={styles.mark} />
      ) : (
        <span className={`${styles.mark} ${styles.initial}`} aria-hidden="true">
          {initial}
        </span>
      );
    case 'feedback':
      return item.count ? (
        <StatusIcon status={item.status} className={styles.status} />
      ) : (
        <span className={`${styles.status} ${styles.dot}`} aria-hidden="true" />
      );
  }
}

export function Choice({
  kind,
  items,
  defaultSelected = [],
  defaultQuery = '',
  rows = 6.7,
  createFromQuery = kind !== 'deal',
  onChange,
  ref,
  colorScheme,
  className = '',
}: {
  /** What the records are: it names the popover ("Add Person…") and draws the rows. */
  kind: Kind;
  /** The records to choose from. */
  items: ChoiceItem[];
  /** The ids ticked at first. */
  defaultSelected?: string[];
  /** The search the popover starts with. */
  defaultQuery?: string;
  /** How many rows show before the list scrolls. */
  rows?: number;
  /** Whether the create row names the search, as in Create: "Darren". */
  createFromQuery?: boolean;
  /** Called with the ticked ids whenever the reader changes them. */
  onChange?: (selected: string[]) => void;
  /** Handles for a demo to drive the popover. */
  ref?: Ref<ChoiceHandle>;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const noun = NOUNS[kind];
  const [list, setList] = useState(items);
  const [selected, setSelected] = useState(defaultSelected);
  /** The ticked records as of the last settle, in their pinned order. */
  const [pinned, setPinned] = useState(defaultSelected);
  const [query, setQuery] = useState(defaultQuery);
  const [active, setActive] = useState<string | null>(() => firstLit(defaultQuery, items));
  const [message, setMessage] = useState('');
  const id = useId();
  const listRef = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  /** The rows' places before a settle, for them to glide from. */
  const flip = useRef<Map<string, number> | null>(null);
  /** The latest state, for handles called between renders. */
  const live = useRef({ list, selected, pinned, query });
  useLayoutEffect(() => {
    live.current = { list, selected, pinned, query };
  });
  const made = useRef(0);

  const q = query.trim().toLowerCase();
  const matches = (item: ChoiceItem) => !q || item.label.toLowerCase().includes(q);
  const top = pinned.flatMap((pin) => list.filter((item) => item.id === pin && matches(item)));
  const rest = list.filter((item) => !pinned.includes(item.id) && matches(item));
  const shown = [...top, ...rest];
  /** Where each record shows, by id; a record not in it is hidden by the search. */
  const place = new Map(shown.map((item, index) => [item.id, index]));
  const empty = list.length === 0 && !q;
  const order = [...shown.map((item) => item.id), ...(empty ? [] : [CREATE])];
  const createLabel = q && createFromQuery ? `Create: "${query.trim()}"` : `Create new ${noun}…`;

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

  /** Notes where the rows are, so the next render can glide them from there. */
  const remember = () => {
    const box = listRef.current;
    if (!box) return;
    flip.current = new Map(
      Array.from(box.querySelectorAll<HTMLElement>('[data-id]')).map((row) => [
        row.dataset.id ?? '',
        row.offsetTop,
      ]),
    );
  };

  const settle = () => {
    const now = live.current;
    const next = [
      ...now.pinned.filter((pin) => now.selected.includes(pin)),
      ...now.list
        .map((item) => item.id)
        .filter((pin) => now.selected.includes(pin) && !now.pinned.includes(pin)),
    ];
    if (next.join() === now.pinned.join()) return;
    remember();
    setPinned(next);
  };

  const toggle = (itemId: string, reader: boolean) => {
    const now = live.current;
    const item = now.list.find((entry) => entry.id === itemId);
    if (!item) return;
    const on = !now.selected.includes(itemId);
    const next = on ? [...now.selected, itemId] : now.selected.filter((pin) => pin !== itemId);
    live.current.selected = next;
    setSelected(next);
    if (!reader) return;
    onChange?.(next);
    announce(`${item.label} ${on ? 'added' : 'removed'}`);
  };

  const search = (text: string) => {
    settle();
    live.current.query = text;
    setQuery(text);
    setActive(firstLit(text, live.current.list));
  };

  const create = (reader: boolean) => {
    const now = live.current;
    const text = now.query.trim();
    if (createFromQuery && !text) {
      // Nothing to name it yet: the field takes the focus for the name.
      if (reader) input.current?.focus();
      return;
    }
    made.current += 1;
    const label = createFromQuery ? text : `New ${noun.toLowerCase()}`;
    const item = createItem(kind, label, `${kind}-made-${made.current}`);
    const selectedNext = [...now.selected, item.id];
    live.current = {
      list: [...now.list, item],
      selected: selectedNext,
      pinned: [...now.pinned, item.id],
      query: '',
    };
    setList(live.current.list);
    setSelected(selectedNext);
    setPinned(live.current.pinned);
    setQuery('');
    setActive(null);
    if (!reader) return;
    onChange?.(selectedNext);
    announce(`Created ${label} and added it`);
  };

  const press = (target: string, reader: boolean) =>
    target === CREATE ? create(reader) : toggle(target, reader);

  useImperativeHandle(ref, () => ({
    light: setActive,
    toggle: (target) => toggle(target, false),
    search,
    create: () => create(false),
    settle,
    reset: () => {
      flip.current = null;
      live.current = {
        list: items,
        selected: defaultSelected,
        pinned: defaultSelected,
        query: defaultQuery,
      };
      setList(items);
      setSelected(defaultSelected);
      setPinned(defaultSelected);
      setQuery(defaultQuery);
      setActive(firstLit(defaultQuery, items));
    },
  }));

  // After a settle, each row glides from where it was to where it is now.
  useLayoutEffect(() => {
    const before = flip.current;
    const box = listRef.current;
    flip.current = null;
    if (!before || !box || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    box.querySelectorAll<HTMLElement>('[data-id]').forEach((row) => {
      const from = before.get(row.dataset.id ?? '');
      if (from === undefined || row.hidden) return;
      const delta = from - row.offsetTop;
      if (Math.abs(delta) < 1) return;
      row.animate([{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }], {
        duration: 260,
        easing: 'cubic-bezier(0.25, 0.8, 0.3, 1)',
      });
    });
  });

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    const index = active ? order.indexOf(active) : -1;
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      if (!order.length) return;
      const step = event.key === 'ArrowDown' ? 1 : -1;
      const next =
        order[
          index < 0
            ? step > 0
              ? 0
              : order.length - 1
            : (index + step + order.length) % order.length
        ];
      setActive(next);
      listRef.current?.querySelector(`[data-id="${next}"]`)?.scrollIntoView({ block: 'nearest' });
    } else if (event.key === 'Enter') {
      event.preventDefault();
      if (active) press(active, true);
      else if (!shown.length && !empty) create(true);
    } else if (event.key === 'Escape' && query) {
      event.preventDefault();
      search('');
    }
  };

  const optionId = (target: string) => `${id}-${target}`;
  const searching = q.length > 0;

  return (
    <div
      className={`${styles.popover} ${className}`}
      data-part="popover"
      data-color-scheme={colorScheme}
      data-searching={searching || undefined}
      onPointerLeave={(event) => {
        if (event.pointerType === 'touch') return;
        if (document.activeElement !== input.current) setActive(null);
        settle();
      }}
    >
      <input
        ref={input}
        className={styles.search}
        data-part="search"
        type="text"
        role="combobox"
        aria-expanded="true"
        aria-controls={`${id}-list`}
        aria-autocomplete="list"
        aria-activedescendant={active ? optionId(active) : undefined}
        aria-label={`Add ${noun}`}
        placeholder={`Add ${noun}…`}
        autoComplete="off"
        spellCheck={false}
        value={query}
        onChange={(event) => search(event.target.value)}
        onKeyDown={onKeyDown}
        onBlur={settle}
      />
      <div
        id={`${id}-list`}
        role="listbox"
        aria-multiselectable="true"
        aria-label={`${noun} choices`}
      >
        <div
          ref={listRef}
          className={styles.list}
          data-part="list"
          style={{ '--rows': rows } as CSSProperties}
          hidden={shown.length === 0}
        >
          {list.map((item) => {
            const at = place.get(item.id);
            const on = selected.includes(item.id);
            return (
              <div
                key={item.id}
                id={optionId(item.id)}
                className={styles.row}
                data-part="row"
                role="option"
                aria-selected={on}
                aria-posinset={at === undefined ? undefined : at + 1}
                aria-setsize={order.length}
                data-id={item.id}
                data-active={active === item.id || undefined}
                data-checked={on || undefined}
                data-pinned={(at !== undefined && at < top.length) || undefined}
                hidden={at === undefined}
                style={{ order: at === undefined ? 0 : at < top.length ? at : at + 1 }}
                onPointerEnter={(event) => event.pointerType !== 'touch' && setActive(item.id)}
                onClick={() => toggle(item.id, true)}
              >
                <span className={styles.box} data-part="checkbox" aria-hidden="true" />
                <Visual item={item} />
                <span className={styles.label} data-part="label">
                  {highlight(item.label, q)}
                </span>
                {item.kind === 'feedback' && item.count > 0 && (
                  <span className={styles.meta} data-part="meta">
                    · {item.count}
                  </span>
                )}
                {item.kind === 'deal' && (
                  <span className={styles.meta} data-part="meta">
                    {item.amount}
                  </span>
                )}
                {item.kind === 'deal' && (
                  <span className={styles.end} data-part="end">
                    <span className={styles.ref}>{item.ref}</span>
                    <Ring progress={item.progress} className={styles.ring} />
                  </span>
                )}
              </div>
            );
          })}
          <div
            className={styles.pinLine}
            data-part="separator"
            role="presentation"
            hidden={top.length === 0 || shown.length === top.length}
            style={{ order: top.length }}
          />
        </div>
        <div
          id={optionId(CREATE)}
          className={styles.create}
          data-part="create"
          role="option"
          aria-selected="false"
          aria-disabled={empty || undefined}
          aria-posinset={empty ? undefined : order.length}
          aria-setsize={empty ? undefined : order.length}
          data-create=""
          data-active={active === CREATE || undefined}
          data-ruled={shown.length > 0 || undefined}
          onPointerEnter={(event) => !empty && event.pointerType !== 'touch' && setActive(CREATE)}
          onClick={() => !empty && create(true)}
        >
          <PlusCircle className={styles.plus} />
          <span className={styles.label}>{empty ? 'Start typing to create' : createLabel}</span>
        </div>
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### choice-popover/choice-popover.module.css

```css
/* Choice popover (choice-popover.tsx, choice.tsx): a stage with a board of popovers, three
   columns wide, then two, then one on a phone. Sizes are in em of the stage's text: 14px wherever
   the columns fit, a little less on the way down. A popover's geometry is in em of 0.93 of it,
   and its words are a touch smaller again (--words).

   The stage is one layout boundary of a set height, so what the demo changes (rows lit, ticked,
   filtered or settling) lays out the stage alone; the rows glide by their transform and the
   pointer moves on a small layer of its own.

   Colours are the tokens below, light, then light-dark(light, dark) pairs: 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 pairs sit in @supports, which a build that
   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light
   colours stay. The avatars and the company marks keep their own colours. A popover on its own
   (Choice, outside the board) carries the tokens itself. */

.root,
.popover:not(.root *) {
  /* The stage and its edge; a popover, its border and its lift; a lit row (under the pointer or
     the keys). */
  --stage: #fafafa;
  --stage-edge: rgb(0 0 0 / 0.06);
  --surface: #fff;
  --border: #e3e3e3;
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow-far: rgb(0 0 0 / 0.1);
  --shadow: 0 1px 2px var(--shadow-near), 0 6px 14px -3px var(--shadow-far);
  --surface-2: #f2f2f2;
  /* The lines in a popover (under the search, over create, the pinned ones' separator), and the
     rule after a caption. */
  --line: #efefef;
  --rule: #ebebeb;
  /* The words; captions, placeholders, prices and counts; a deal's ref and a create row that
     cannot create yet. */
  --ink: #1f2a26;
  --ink-muted: #8a908e;
  --ink-faint: #a4aaa7;
  /* A checkbox's edge; ticked, its fill and its tick (dark by day, light at night). */
  --box-edge: #cdd2d0;
  --accent: #17342e;
  --accent-ink: #fff;
  /* A record made from a search: its initial's disc and letter. */
  --initial: #dfe5e2;
  --initial-ink: #4b5b55;
  /* Feedback's status; a deal's progress (and its tick once won), and none yet. */
  --purple: #6c60d3;
  --progress: #2b7a39;
  --progress-none: #a7c6ad;
  --progress-ink: #fff;
  /* A company mark drawn in black alone turns white at night, as a mark does on a dark ground. */
  --mark-ink: #151515;
  --focus: #2f6fdf;
}
@supports (color: light-dark(#fff, #000)) {
  .root,
  .popover:not(.root *) {
    --stage: light-dark(#fafafa, #111113);
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface: light-dark(#fff, #1c1c1f);
    --border: light-dark(#e3e3e3, #323236);
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow-far: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --surface-2: light-dark(#f2f2f2, #2a2a2d);
    --line: light-dark(#efefef, #2a2a2e);
    --rule: light-dark(#ebebeb, #26262a);
    --ink: light-dark(#1f2a26, #e8ecea);
    --ink-muted: light-dark(#8a908e, #a6adaa);
    --ink-faint: light-dark(#a4aaa7, #8f9693);
    --box-edge: light-dark(#cdd2d0, #5c6361);
    --accent: light-dark(#17342e, #dce5e1);
    --accent-ink: light-dark(#fff, #17342e);
    --initial: light-dark(#dfe5e2, #34403b);
    --initial-ink: light-dark(#4b5b55, #c4d0cb);
    --purple: light-dark(#6c60d3, #9d92f2);
    --progress: light-dark(#2b7a39, #3a9a4c);
    --progress-none: light-dark(#a7c6ad, #46634c);
    --mark-ink: light-dark(#151515, #ececec);
    --focus: light-dark(#2f6fdf, #5b93f0);
  }
}
.root {
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge, or a
     white hand edged in dark. */
  --pointer: #111;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
/* The colorScheme prop (on the board, or on a popover alone). Without it the root sets nothing
   and inherits the page's color-scheme. */
:is(.root, .popover)[data-color-scheme='light'] {
  color-scheme: light;
}
:is(.root, .popover)[data-color-scheme='dark'] {
  color-scheme: dark;
}
:is(.root, .popover)[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root [hidden],
.popover [hidden] {
  display: none !important;
}

.stage {
  --pad-x: clamp(14px, 3cqi, 26px);
  --pad-y: clamp(16px, 3cqi, 26px);
  position: relative;
  height: calc(2 * var(--pad-y) + 36.42em + 8px);
  padding: var(--pad-y) var(--pad-x);
  contain: layout;
  font-size: clamp(12px, calc((100cqi - 2 * var(--pad-x)) / 51.4), 14px);
  line-height: 1.25;
}

/* The board: choosing on top, the search states under it. */
.board {
  display: grid;
  grid-template-columns: 14em 14em 21em;
  /* The top row keeps a full popover's height, so a search there never moves the row under it. */
  grid-template-rows: calc(20.1em + 4px) auto;
  grid-template-areas:
    'person company deal'
    'darren empty valu';
  justify-content: center;
  align-items: start;
  gap: 1.6em 1.15em;
}
.slot[data-slot='person'] {
  grid-area: person;
}
.slot[data-slot='company'] {
  grid-area: company;
}
.slot[data-slot='deal'] {
  grid-area: deal;
}
.slot[data-slot='darren'] {
  grid-area: darren;
}
.slot[data-slot='empty'] {
  grid-area: empty;
}
.slot[data-slot='valu'] {
  grid-area: valu;
}
.caption {
  display: flex;
  align-items: center;
  gap: 0.7em;
  height: 1.45em;
  margin: 0 0 0.62em;
  color: var(--ink-muted);
  font-size: 0.86em;
  white-space: nowrap;
}
.caption::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--rule);
}

/* A popover. */
.popover {
  --words: 0.954em;
  position: relative;
  font-size: 0.93em;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 0.537em;
  background: var(--surface);
  box-shadow: var(--shadow);
  color: var(--ink);
}
.search {
  display: block;
  width: 100%;
  height: 2.673em;
  margin: 0;
  padding: 0 1.07em;
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0;
  outline: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: var(--words);
}
.search::placeholder {
  color: var(--ink-muted);
  opacity: 1;
}
.search:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.list {
  position: relative;
  display: flex;
  flex-direction: column;
  max-height: calc(var(--rows) * 2.5em - 0.15em);
  padding: 0.161em 0.236em;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.list::-webkit-scrollbar {
  display: none;
}
.row {
  display: flex;
  flex: none;
  align-items: center;
  height: 2.5em;
  padding: 0 0.615em 0 0.847em;
  border-radius: 0.301em;
  white-space: nowrap;
  cursor: pointer;
}
/* The ticked records above the separator sit a little closer together. */
.row[data-pinned] {
  height: 2.29em;
}
.row[data-active] {
  background: var(--surface-2);
}
/* The checkbox: shown when ticked, when lit, and for every row while searching. */
.box {
  position: relative;
  flex: none;
  width: 1.07em;
  height: 1.07em;
  margin-right: 0.937em;
  border: 1.25px solid var(--box-edge);
  border-radius: 0.269em;
  background: var(--surface);
  visibility: hidden;
}
.row[data-active] .box,
.row[data-checked] .box,
.popover[data-searching] .box {
  visibility: visible;
}
.row[data-checked] .box {
  border-color: var(--accent);
  background: var(--accent);
}
.box::after {
  content: '';
  position: absolute;
  top: 0.218em;
  left: 0.31em;
  width: 0.3em;
  height: 0.506em;
  border: solid var(--accent-ink);
  border-width: 0 1.5px 1.5px 0;
  transform: rotate(45deg);
  visibility: hidden;
}
.row[data-checked] .box::after {
  visibility: visible;
}
.avatar {
  flex: none;
  width: 1.612em;
  height: 1.612em;
  margin: 0 0.4em 0 -0.107em;
  clip-path: circle(50%);
}
.mark {
  flex: none;
  width: 1.387em;
  height: 1.387em;
  margin-right: 0.545em;
}
.status {
  flex: none;
  width: 1.236em;
  height: 1.236em;
  margin: 0 0.59em 0 0.075em;
  color: var(--purple);
}
/* A record made from a search: its initial on a disc or a tile, drawn in HTML. */
.initial {
  display: grid;
  place-items: center;
  background: var(--initial);
  color: var(--initial-ink);
  font-size: 0.68em;
  font-weight: 600;
}
.avatar.initial {
  width: 2.365em;
  height: 2.365em;
  margin: 0 0.588em 0 -0.161em;
  border-radius: 50%;
  clip-path: none;
}
.mark.initial {
  width: 2.042em;
  height: 2.042em;
  margin-right: 0.8em;
  border-radius: 0.322em;
}
.dot {
  border: 1.5px dashed currentColor;
  border-radius: 50%;
  transform: scale(0.8);
}
.label {
  min-width: 0;
  overflow: hidden;
  font-size: var(--words);
  text-overflow: ellipsis;
}
.match {
  background: none;
  color: inherit;
  font-weight: 600;
}
.meta {
  flex: none;
  margin-left: 0.47em;
  color: var(--ink-muted);
  font-size: var(--words);
}
.end {
  display: flex;
  flex: none;
  align-items: center;
  gap: 0.43em;
  margin-left: auto;
  padding-left: 0.645em;
}
.ref {
  color: var(--ink-faint);
  font-size: var(--words);
}
.ring {
  width: 1.236em;
  height: 1.236em;
}
.pinLine {
  flex: none;
  height: 0.4em;
  margin: 0 -0.236em;
  background: linear-gradient(var(--line), var(--line)) bottom / 100% 1px no-repeat;
}
.create {
  display: flex;
  align-items: center;
  gap: 0.87em;
  height: 2.612em;
  padding: 0 1.021em 0 1.137em;
  white-space: nowrap;
  cursor: pointer;
}
.create[data-ruled] {
  border-top: 1px solid var(--line);
}
.create[data-active] {
  background: var(--surface-2);
}
.create[aria-disabled] {
  color: var(--ink-faint);
  cursor: default;
}
.plus {
  flex: none;
  width: 1em;
  height: 1em;
  color: var(--ink-muted);
}

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

/* Two columns, then one. */
@container (max-width: 679px) {
  .stage {
    font-size: clamp(12px, calc((100cqi - 2 * var(--pad-x)) / 36.2), 14px);
  }
  .board {
    grid-template-columns: 14em 21em;
    grid-template-areas:
      'person deal'
      'darren valu';
  }
  .slot[data-slot='company'],
  .slot[data-slot='empty'] {
    display: none;
  }
}
@container (max-width: 459px) {
  .stage {
    font-size: clamp(11px, calc((100cqi - 2 * var(--pad-x)) / 21), 14px);
  }
  .board {
    grid-template-columns: 21em;
    grid-template-areas:
      'person'
      'valu';
  }
  .slot[data-slot='deal'],
  .slot[data-slot='darren'] {
    display: none;
  }
}
```

### choice-popover/data.ts

```tsx
/*
 * What the popovers choose from: people, companies, feedback and deals of a small CRM. Every
 * record has an id and the label a search matches; the rest is what its row draws.
 */

export type AvatarLook = {
  /** The circle behind. */
  bg: string;
  skin: string;
  hair: string;
  /** The hair's cut. */
  cut: 'short' | 'long' | 'bun' | 'curly' | 'crop';
  shirt: string;
};

export type MarkName =
  'sorrel' | 'duograph' | 'farwater' | 'corlit' | 'fenwild' | 'inlet' | 'fernlow' | 'waypost';

export type FeedbackStatus = 'synced' | 'progress' | 'open';

export type ChoiceItem =
  | { kind: 'person'; id: string; label: string; avatar: AvatarLook | null }
  | { kind: 'company'; id: string; label: string; mark: MarkName | null }
  | { kind: 'feedback'; id: string; label: string; status: FeedbackStatus; count: number }
  | {
      kind: 'deal';
      id: string;
      /** The company's name. */
      label: string;
      mark: MarkName | null;
      amount: string;
      /** The deal's reference. */
      ref: string;
      /** How far along it is: 0 is open, 1 is won. */
      progress: number;
    };

export type Kind = ChoiceItem['kind'];

/** How each kind is named in its popover: "Add Person…", "Create new Person…". */
export const NOUNS: Record<Kind, string> = {
  person: 'Person',
  company: 'Company',
  feedback: 'Feedback',
  deal: 'Deal',
};

const person = (id: string, label: string, avatar: AvatarLook): ChoiceItem => ({
  kind: 'person',
  id,
  label,
  avatar,
});

export const PEOPLE: ChoiceItem[] = [
  person('dana', 'Dana Whitlow', {
    bg: '#cfe3f1',
    skin: '#e9b48f',
    hair: '#3b2a22',
    cut: 'short',
    shirt: '#4f6d8a',
  }),
  person('joel', 'Joel Marchetti', {
    bg: '#e9dccb',
    skin: '#c98d68',
    hair: '#6b4a32',
    cut: 'crop',
    shirt: '#8c5a3c',
  }),
  person('daniela', 'Daniela Villanueva', {
    bg: '#d9d4ce',
    skin: '#b97c5a',
    hair: '#1f1a17',
    cut: 'curly',
    shirt: '#2f3437',
  }),
  person('ivo', 'Ivo Bell', {
    bg: '#e8d6b8',
    skin: '#f0c4a0',
    hair: '#a26b3c',
    cut: 'short',
    shirt: '#7a8c5e',
  }),
  person('noa', 'Noa Amari', {
    bg: '#d6dad8',
    skin: '#8d5b42',
    hair: '#2a2421',
    cut: 'bun',
    shirt: '#6f7a78',
  }),
  person('avery', 'Avery Lindholm', {
    bg: '#cbdcea',
    skin: '#efc6a6',
    hair: '#d6a64f',
    cut: 'long',
    shirt: '#5a7fa8',
  }),
  person('oscar', 'Oscar Villalobos', {
    bg: '#e3d3c4',
    skin: '#a8714f',
    hair: '#3a2b24',
    cut: 'curly',
    shirt: '#9a6a48',
  }),
  person('damon', 'Damon Okoye', {
    bg: '#d5dfd2',
    skin: '#6e4632',
    hair: '#1c1715',
    cut: 'crop',
    shirt: '#4c6650',
  }),
];

const company = (id: string, label: string, mark: MarkName): ChoiceItem => ({
  kind: 'company',
  id,
  label,
  mark,
});

export const COMPANIES: ChoiceItem[] = [
  company('sorrel', 'Sorrel', 'sorrel'),
  company('duograph', 'Duograph', 'duograph'),
  company('farwater', 'Farwater', 'farwater'),
  company('corlit', 'Corlit', 'corlit'),
  company('fenwild', 'Fenwild', 'fenwild'),
  company('inlet', 'Inlet', 'inlet'),
  company('fernlow', 'Fernlow', 'fernlow'),
];

const feedback = (
  id: string,
  label: string,
  status: FeedbackStatus,
  count: number,
): ChoiceItem => ({ kind: 'feedback', id, label, status, count });

export const FEEDBACK: ChoiceItem[] = [
  feedback('attach', 'Attach images to replies', 'synced', 34),
  feedback('bulk', 'Bulk edit owners across every list view', 'progress', 18),
  feedback('remind', 'Remind me before a contract renews', 'open', 11),
  feedback('value', 'Show deal value in the sidebar', 'open', 19),
  feedback('valuation', 'Valuation fields on companies', 'progress', 18),
  feedback('inbox', 'Group the inbox by account owner', 'progress', 9),
  feedback('snooze', 'Keyboard shortcut to snooze a task', 'progress', 13),
];

const deal = (
  id: string,
  label: string,
  mark: MarkName,
  amount: string,
  ref: string,
  progress: number,
): ChoiceItem => ({ kind: 'deal', id, label, mark, amount, ref, progress });

export const DEALS: ChoiceItem[] = [
  deal('d88', 'Waypost', 'waypost', '$4.5k', 'DEAL-88', 1),
  deal('d212', 'Waypost', 'waypost', '$8.2k', 'DEAL-212', 0.45),
  deal('d214', 'Farwater', 'farwater', '$8.2k', 'DEAL-214', 0),
  deal('d215', 'Fenwild', 'fenwild', '$6.5k', 'DEAL-215', 0.5),
  deal('d219', 'Corlit', 'corlit', '$12.2k', 'DEAL-219', 0.9),
  deal('d221', 'Inlet', 'inlet', '$3.7k', 'DEAL-221', 0.4),
  deal('d230', 'Farwater', 'farwater', '$8.2k', 'DEAL-230', 0.3),
];

/** A record made from a search: no picture yet, the name's initial stands in. */
export function createItem(kind: Kind, label: string, id: string): ChoiceItem {
  switch (kind) {
    case 'person':
      return { kind, id, label, avatar: null };
    case 'company':
      return { kind, id, label, mark: null };
    case 'feedback':
      return { kind, id, label, status: 'open', count: 0 };
    case 'deal':
      return { kind, id, label, mark: null, amount: '$0', ref: 'New', progress: 0 };
  }
}
```

### choice-popover/visuals.tsx

```tsx
import type { CSSProperties, ReactNode } from 'react';
import type { AvatarLook, FeedbackStatus, MarkName } from './data';

/*
 * The rows' small pictures: illustrated avatars, company marks, the status of feedback, the
 * progress ring of a deal and the create row's plus. All decorative: every row says in words
 * what they show.
 *
 * The avatars and the marks keep their colours in both schemes, but for the one mark drawn in
 * black alone, which takes --mark-ink (white at night). The ring's colours are the stage's
 * tokens (choice-popover.module.css).
 */

/** A person, drawn: a coloured disc (cut round by its class), the shoulders, the face, the hair. */
export function Avatar({ look, className }: { look: AvatarLook; className?: string }) {
  const hair: Record<AvatarLook['cut'], ReactNode> = {
    short: (
      <path d="M6.6 9.6c-.3-3 1.6-5 4.4-5s4.8 1.9 4.5 5c-.9-1.4-2.6-2.2-4.5-2.3-1.9.1-3.5.9-4.4 2.3z" />
    ),
    crop: (
      <path d="M7 8.6c.1-2.6 1.8-3.9 4-3.9s3.9 1.3 4 3.9c-1.1-.9-2.4-1.3-4-1.3s-2.9.4-4 1.3z" />
    ),
    long: (
      <path d="M6.2 15.2c-.9-1.9-.8-4.7-.3-6.6.7-2.6 2.6-3.9 5.1-3.9s4.4 1.3 5.1 3.9c.5 1.9.6 4.7-.3 6.6-.4-1.6-.8-3.8-.9-5.5-1.1-.9-2.5-1.5-3.9-1.5S8.2 8.8 7.1 9.7c-.1 1.7-.5 3.9-.9 5.5z" />
    ),
    bun: (
      <>
        <circle cx="11" cy="4.1" r="1.9" />
        <path d="M6.7 9.4c-.2-2.8 1.7-4.6 4.3-4.6s4.5 1.8 4.3 4.6c-1-1.2-2.5-1.9-4.3-1.9s-3.3.7-4.3 1.9z" />
      </>
    ),
    curly: (
      <path d="M6.3 10.2c-1-1-.7-2.6.4-3.1-.2-1.4 1-2.5 2.3-2.2.6-1 2.2-1.2 3.1-.4 1.3-.4 2.6.6 2.5 1.9 1.2.4 1.6 2 .7 3-.6-1.2-1.9-2-4.3-2.1-2.4.1-3.9.9-4.7 2.9z" />
    ),
  };
  return (
    <svg className={className} viewBox="0 0 22 22" aria-hidden="true">
      <rect width="22" height="22" fill={look.bg} />
      <path d="M3 22.5c.6-4.4 3.8-6.6 8-6.6s7.4 2.2 8 6.6z" fill={look.shirt} />
      <rect x="9.4" y="12.6" width="3.2" height="3.6" rx="1.2" fill={look.skin} />
      <ellipse cx="11" cy="9.9" rx="3.9" ry="4.3" fill={look.skin} />
      <g fill={look.hair}>{hair[look.cut]}</g>
    </svg>
  );
}

const MARKS: Record<MarkName, ReactNode> = {
  sorrel: (
    <>
      <rect width="18" height="18" rx="3" fill="#fbf3e2" />
      <path
        d="M9 3.6v10.8M4.3 6.3l9.4 5.4M4.3 11.7l9.4-5.4"
        stroke="#111"
        strokeWidth="2.4"
        strokeLinecap="round"
      />
    </>
  ),
  duograph: (
    <>
      <path d="M2 16 6.2 2h3.6L5.6 16z" fill="#8f80e3" />
      <path d="M8.4 16 12.6 2H16l-4.2 14z" fill="#a99cf0" />
    </>
  ),
  farwater: (
    <>
      <rect width="18" height="18" rx="3.2" fill="#14161a" />
      <path
        d="M4.2 7.2c1.2-1 2.4-1 3.6 0s2.4 1 3.6 0 1.8-.9 2.4-.5M4.2 11.2c1.2-1 2.4-1 3.6 0s2.4 1 3.6 0 1.8-.9 2.4-.5"
        fill="none"
        stroke="#fff"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </>
  ),
  corlit: (
    <>
      <rect width="18" height="18" rx="1.2" fill="#0d0d0d" />
      <path
        d="M12.9 6.6A4.3 4.3 0 1 0 13.2 11"
        fill="none"
        stroke="#fff"
        strokeWidth="2"
        strokeLinecap="round"
      />
      <path d="m13.8 3.8.1 3.6-3.5-.4z" fill="#fff" />
    </>
  ),
  fenwild: (
    <>
      <rect width="18" height="18" rx="1.6" fill="#33461f" />
      <path
        d="M9 14.5V5M9 8.8c-1.8-.2-3-1.4-3.2-3.2 1.8.2 3 1.4 3.2 3.2zm0 2.4c1.8-.2 3-1.4 3.2-3.2-1.8.2-3 1.4-3.2 3.2z"
        fill="#eef2e4"
        stroke="#eef2e4"
        strokeWidth="1.2"
        strokeLinejoin="round"
      />
    </>
  ),
  inlet: (
    <>
      <rect width="18" height="18" rx="4" fill="#161616" />
      <path d="M7.2 13.4 11.8 4.6" stroke="#fff" strokeWidth="3.2" strokeLinecap="round" />
    </>
  ),
  fernlow: (
    <path
      d="M4 15.5c0-4.2 2.6-6 4.8-8.6.3 1.7 1 2.6 2.3 3.3.6-1.3.7-2.6.4-4.1 2 1.8 3 4.2 2.4 7-.5 2.4-2.3 3.4-4.6 3.4-1.7 0-3.6-.3-5.3-1z"
      style={{ fill: 'var(--mark-ink)' }}
    />
  ),
  waypost: (
    <>
      <path d="M8.2 2v14" stroke="#f05a28" strokeWidth="2.2" strokeLinecap="round" />
      <path
        d="M8.2 3.6h6.4l1.9 1.9-1.9 1.9H8.2zM8.2 9.4H3.4L1.6 11.2l1.8 1.8h4.8z"
        fill="#f05a28"
      />
    </>
  ),
};

/** A company's mark. */
export function CompanyMark({ name, className }: { name: MarkName; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 18 18" aria-hidden="true">
      {MARKS[name]}
    </svg>
  );
}

/** Feedback's status: synced, in progress, or open. */
export function StatusIcon({ status, className }: { status: FeedbackStatus; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.3"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {status === 'synced' && (
        <path d="M12.6 6.1A5 5 0 0 0 3.7 5M3.4 9.9a5 5 0 0 0 8.9 1.1M3.2 2.6v2.7h2.7M12.8 13.4v-2.7h-2.7" />
      )}
      {status === 'progress' && (
        <>
          <circle cx="8" cy="8" r="5.9" />
          <path d="M8 4.7V8.4l2.5 1.2" />
        </>
      )}
      {status === 'open' && (
        <>
          <circle cx="7.4" cy="8.6" r="5.2" strokeDasharray="0 2.04" strokeWidth="1.5" />
          <path d="M13 1.6v3.2M11.4 3.2h3.2" />
        </>
      )}
    </svg>
  );
}

const WON: CSSProperties = { fill: 'var(--progress)' };
const TICK: CSSProperties = { stroke: 'var(--progress-ink)' };
const NONE: CSSProperties = { stroke: 'var(--progress-none)' };
const DONE: CSSProperties = { stroke: 'var(--progress)' };

/** A deal's progress: a solid arc for the way done, dots for the rest; a filled tick once won. */
export function Ring({ progress, className }: { progress: number; className?: string }) {
  const r = 5.6;
  const length = 2 * Math.PI * r;
  const done = Math.min(1, Math.max(0, progress)) * length;
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      strokeLinecap="round"
      aria-hidden="true"
    >
      {progress >= 1 ? (
        <>
          <circle cx="8" cy="8" r="7" style={WON} />
          <path d="m5.2 8.2 1.9 1.9 3.8-4" style={TICK} strokeWidth="1.6" strokeLinejoin="round" />
        </>
      ) : progress <= 0 ? (
        <circle cx="8" cy="8" r={r} style={NONE} strokeWidth="1.8" />
      ) : (
        <g transform="rotate(-90 8 8)" style={DONE}>
          <circle cx="8" cy="8" r={r} strokeWidth="1.7" strokeDasharray={`0 ${length / 12}`} />
          <circle cx="8" cy="8" r={r} strokeWidth="1.8" strokeDasharray={`${done} ${length}`} />
        </g>
      )}
    </svg>
  );
}

export function PlusCircle({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.1"
      strokeLinecap="round"
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="6.4" />
      <path d="M8 5.2v5.6M5.2 8h5.6" />
    </svg>
  );
}
```

### choice-popover/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a target, press, calling the board's own handlers as a hand would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer moves by animations the compositor plays, so the glides cost no layout, and it is
 * an HTML box around a still drawing: a change of style inside an SVG lays the SVG out again.
 */

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

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

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

/** Where the pointer points, in its 20 × 20 drawings: the arrow's tip, the hand's fingertip. */
const TIP: Point = { x: 3, y: 2 };

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

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

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

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

  /** Shows the hand over what can be picked, as a page's pointer cursor does, else the arrow. */
  const look = (hand: boolean) =>
    pointer.querySelectorAll<HTMLElement>('[data-look]').forEach((glyph) => {
      glyph.style.visibility = (glyph.dataset.look === 'hand') === hand ? 'visible' : 'hidden';
    });

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const hand = !!target.closest('[role="option"]');
    // Screen px to the stage's own: the stage may sit in a scaled container.
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.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();
    pointer.style.opacity = '1';
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(900, Math.max(320, Math.hypot(dx, dy) * 1.5)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished.then(() => look(hand)));
  };

  const press: Demo['press'] = async (action) => {
    // The arrow dips towards its tip.
    const dip = pointer.firstElementChild?.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 (dip) await step(dip.finished);
  };

  const hide = () => {
    pointer.style.opacity = '0';
  };

  pointer.style.transform = place(at);
  look(false);
  script({ wait, moveTo, press, hide }).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's box: it moves, on a small layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 20,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

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

/** The two drawings share one box; the one not in use is hidden. */
const DRAWING: CSSProperties = {
  position: 'absolute',
  inset: 0,
  display: 'block',
  width: 22,
  height: 22,
  overflow: 'visible',
};

/** Its colours, the stage's tokens (choice-popover.module.css): as the system's, on both schemes. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const PALM: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };
const CREASES: CSSProperties = { stroke: 'var(--pointer)' };

/** The pointer the demo moves around: an arrow, or a hand over rows. It is decorative. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={{ ...PRESS, position: 'relative' }}>
        <svg viewBox="0 0 20 20" style={DRAWING} data-look="arrow">
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
        <svg viewBox="0 0 20 20" style={{ ...DRAWING, visibility: 'hidden' }} data-look="hand">
          <path
            d="M1.8 9.4V3.2a1.2 1.2 0 0 1 2.4 0v3.9a1.15 1.15 0 0 1 2.3 0v1.1a1.15 1.15 0 0 1 2.3 0V9a1.1 1.1 0 0 1 2.2 0v4.3c0 2.7-1.7 4.7-4.5 4.7H5.9c-1.8 0-2.9-.8-3.9-2.1l-3.1-4.3a1.15 1.15 0 0 1 1.75-1.5l1.15 1.2z"
            style={PALM}
            strokeWidth="1.1"
            strokeLinejoin="round"
          />
          <path
            d="M4.2 8.4v3M6.5 9.2v2.4M8.8 9.8v1.9"
            style={CREASES}
            strokeWidth="0.9"
            strokeLinecap="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### choice-popover/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 };
}
```