# Flowchart

> Two workflow steps on a dotted board, a trigger and an If / Else, joined by a curve that follows them as they are dragged.

A trigger card, “New order created”, joined by a soft curve to an If / Else step whose conditions read as a sentence of chips: if order flavor is Rocky Road, and order topping is… Drag either step about the board and the curve follows in the same frame; select the trigger and its ring and the curve turn blue. Every field and value chip opens a small menu over itself, with one highlight gliding between the options.

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

Category: AI.

## Anatomy

1. **Board**: A dotted surface, dots every 22 px. It grows when narrow cards make the conditions wrap.
2. **Step tag**: Names the kind of step over its card, tinted in its hue: violet for a trigger, amber for If / Else.
3. **Trigger**: A card that can be selected: a click turns its ring and the curve to the accent. Dragging it moves it instead.
4. **Curve**: Joins the two cards’ facing edges, its handles standing off by 0.55 of the drop. Redrawn in the same frame as a move.
5. **If / Else card**: The conditions, a row each, read top to bottom as one sentence.
6. **Subject**: The object the condition reads, raised like a button. It does not open a menu.
7. **Field chip**: The field compared. Opens a menu of the other fields; Enter, Space or the arrows open it from the keyboard.
8. **Value chip**: The value it must have, with its colour dot. Drops to a line of its own, under the subject, when the row is full.

## When to use

- Showing how an automation runs: a trigger, then the step that decides what happens next.
- Rules that read best as a sentence: “If order flavor is Rocky Road, and order topping is …”.
- Conditions picked from short, known lists (fields and their values), each a chip with a menu.
- A builder where people arrange the steps themselves, by dragging them or with the arrow keys.

## When not to use

- Long workflows with many branches and loops. Use a full canvas with zoom, pan and auto layout.
- A single rule with no trigger to show. A filter row or a form says it in less room.
- Values typed freely (amounts, dates, text). The chips only pick from lists; use real inputs.
- A read-only diagram that nobody edits. Draw it as a static figure.

## Installation

Install it with the shadcn CLI, into components/flowchart of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/flowchart.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 { Flowchart, type Condition } from './flowchart';

export function OrderRule({ rule, onSave }: { rule: Condition[]; onSave: (rule: Condition[]) => void }) {
  return (
    <Flowchart
      trigger={{ tag: 'Trigger', title: 'New order created', description: 'Trigger when a new order is created' }}
      defaultConditions={rule}
      onConditionsChange={onSave}
      autoplay={false}
    />
  );
}
```

## States

- **Resting**: Both steps in place, nothing selected: the curve is a grey hairline.
- **Trigger selected**: A 1.5 px accent ring, and the curve in the accent too, eased over 150 ms.
- **Step moved**: Dragged off its place, the trigger comes to the front and the curve bends to follow.
- **Field menu open**: The chip stays filled while its menu is open; the check marks the current field.
- **Value menu, highlighted**: The highlight glides to the option under the pointer or the arrow keys, 220 ms.
- **Narrow**: A long value gets a line of its own, lined up under the subject, and the board grows.

## How it works

- Each step is placed by its left (its middle, from the board’s centre) and its top. A press anywhere on a step but its chips becomes a drag after 3 px, and only then captures the pointer, so a press that stays put still clicks the card under it. Steps stay 8 px inside the board, and the one last moved comes to the front.
- The curve runs from the middle of the trigger card’s bottom edge to the middle of the If / Else card’s top edge: a cubic Bézier whose two handles stand off vertically by 0.55 of the drop. It is measured from the cards themselves in a layout effect, so it is redrawn before the frame that moved them is painted.
- Selecting the trigger swaps its shadow for a 1.5 px accent ring, and the curve’s stroke eases to the accent in 150 ms. Hovering a card raises its shadow in 150 ms; every shadow is written with the same nine layers, so the browser can ease between them.
- A menu pops in over its chip in 180 ms (a 0.95 scale and a fade, on cubic-bezier(0.23, 1, 0.32, 1)) from the chip’s corner: the left one, or the right one when it would pass the card’s edge; it opens under the chip when the board has no room above. It is placed in a layout effect, before it is ever painted.
- One highlight glides between the options (220 ms on its top) under the pointer or the arrow keys, and fades away when the pointer leaves the menu. The chosen option keeps a check.
- A condition’s value sits on its row when it fits, and otherwise drops to a line of its own, lined up under the subject chip; on a narrow board the card wraps and the board grows to keep 24 px under it.
- While on screen and untouched, a demo pointer selects the trigger, drags it off and back, then picks another flavour and topping and puts the first ones back. Any input of the reader’s hands it over.
- Every colour is a light-dark() token on the root, so the board follows the page’s color-scheme or the colorScheme prop. The two step hues, violet and amber, are mixed into each scheme’s surface with color-mix(), so their tags and icon tile are tuned for both.

## API reference

- **trigger** (`{ tag: string; title: string; description: string }`, default `New order created`): The first step: its tag, its title and the line under it.
- **branchTag** (`string`, default `'If / Else'`): The tag over the conditions.
- **defaultConditions** (`Condition[]`, default `flavour and topping`): The rows it starts with: { id, joiner, subject, field, fields, value, values: { label, note?, color? }[], valuesWidth? }.
- **onConditionsChange** (`(conditions: Condition[]) => void`): Called with every condition after a field or a value is picked.
- **defaultMenu** (`FlowchartMenu | null`): A condition’s menu open from the start: row, slot ('field' or 'value') and active, the option under the highlight. It closes as usual. Setting it turns the demo off.
- **defaultSelected** (`boolean`, default `false`): Whether the trigger starts selected.
- **onSelectedChange** (`(selected: boolean) => void`): Called when the trigger is selected or cleared.
- **draggable** (`boolean`, default `true`): The steps can be dragged about the board, and a focused trigger moved with the arrow keys.
- **defaultOffsets** (`Partial<Record<'trigger' | 'branch', { x: number; y: number }>>`): How far each step starts from its place, in px. Setting it turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer selects and drags the trigger and picks other values.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The trigger is a toggle button (aria-pressed). Focused, the arrow keys move it 4 px at a time, or a dot’s width (22 px) with Shift; a visually hidden hint says so.
- Each field and value chip is a button with aria-haspopup="listbox" and a name that says what it holds (“order flavor: Rocky Road”). Enter, Space or the arrows open its menu, a listbox that takes the focus and marks its active option with aria-activedescendant; Home and End jump, Enter or Space picks, Escape closes and returns to the chip, Tab closes and moves on.
- A press outside an open menu closes it. A choice is announced in a polite live region (“order topping is Hot fudge”).
- The conditions card is a labelled group; the curve, the grips and the icons are decorative (aria-hidden).
- Under reduced motion menus appear at once, the highlight and the shadows do not ease, and the demo never plays.

## Source

### flowchart/flowchart.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './flowchart.module.css';

/*
 * Flowchart: two steps of a workflow on a dotted board. A trigger ("New order created") is joined
 * by a curve to an If / Else step whose conditions read as a sentence of chips: "If order flavor
 * is Rocky Road, and order topping is ...". The field and the value chips open menus.
 *
 * - Each step is placed by its left (its middle, from the board's centre) and its top. Pressing a
 *   step anywhere but on a chip and moving more than 3 px drags it, kept inside the board; the step
 *   last dragged comes to the front. A press that does not move selects or clears the trigger.
 * - The curve runs from the middle of the trigger card's bottom edge to the middle of the If / Else
 *   card's top edge: a cubic whose handles stand off vertically by 0.55 of the drop. It is
 *   measured from the cards themselves and rewritten in the same frame as anything that moves
 *   them, so it never lags. A selected trigger turns its ring and the curve to the accent.
 * - A menu opens over its chip (under it when the board has no room above), from the chip's
 *   corner nearest its text: the left one, or the right one for a chip in the right half of its
 *   card. One highlight glides between the options, under the pointer or the arrow keys.
 * - A condition's value sits on the row when it fits, and otherwise on a line of its own, lined up
 *   under the subject chip. The board grows when narrow cards make the conditions wrap.
 * - While on screen and untouched, a demo pointer selects the trigger, drags it about, and picks
 *   other flavours and toppings, then puts them back.
 * - 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.
 */

export type ConditionOption = {
  label: string;
  /** A word after the label, in a fainter ink (the flavour's range, say). */
  note?: string;
  /** The dot before the label, on the chip. */
  color?: string;
};

export type Condition = {
  id: string;
  /** The first word of the row: "If", then "and" or "or". */
  joiner: string;
  /** The object the condition reads, in the chip after the joiner. */
  subject: string;
  /** The field it compares, one of `fields`. */
  field: string;
  fields: string[];
  /** The value it must have, one of `values` by label. */
  value: string;
  values: ConditionOption[];
  /** The width of the values' menu, in px; by default as wide as its widest option. */
  valuesWidth?: number;
};

export type FlowchartTrigger = { tag: string; title: string; description: string };

const AMBER = '#f09a2f';
const FLAVORS: ConditionOption[] = [
  { label: 'Rocky Road', note: 'Classic', color: AMBER },
  { label: 'Mint Chip', note: 'Classic', color: AMBER },
  { label: 'Pistachio', note: 'Seasonal', color: AMBER },
  { label: 'Bubblegum', note: 'Retro', color: AMBER },
];
const TOPPINGS: ConditionOption[] = [
  { label: 'Brown butter bourbon brittle crunch', color: AMBER },
  { label: 'Rainbow sprinkles', color: AMBER },
  { label: 'Hot fudge', color: AMBER },
  { label: 'Candied pecans', color: AMBER },
];
const FIELDS = ['flavor', 'topping', 'size', 'scoops'];

const TRIGGER: FlowchartTrigger = {
  tag: 'Trigger',
  title: 'New order created',
  description: 'Trigger when a new order is created',
};
const CONDITIONS: Condition[] = [
  {
    id: 'flavor',
    joiner: 'If',
    subject: 'order',
    field: 'flavor',
    fields: FIELDS,
    value: 'Rocky Road',
    values: FLAVORS,
    valuesWidth: 176,
  },
  {
    id: 'topping',
    joiner: 'and',
    subject: 'order',
    field: 'topping',
    fields: FIELDS,
    value: 'Brown butter bourbon brittle crunch',
    values: TOPPINGS,
    valuesWidth: 256,
  },
];

/** The steps' tops on the board, their widths at most, and the board's height at least. */
const TRIGGER_TOP = 24;
const BRANCH_TOP = 174;
const TRIGGER_WIDTH = 300;
const BRANCH_WIDTH = 356;
const BOARD_HEIGHT = 333;
/** Room kept under the last step when the board grows, and around a step on a narrow board. */
const MARGIN = 24;
/** How close to the board's edges a step may be dragged. */
const INSET = 8;
/** How far a press must move to become a drag. */
const SLOP = 3;
/** The handles of the curve stand off by this share of its drop. */
const BEND = 0.55;

type NodeId = 'trigger' | 'branch';
type Slot = 'field' | 'value';
type Menu = {
  row: number;
  slot: Slot;
  /** The option under the highlight, and whether the highlight shows. */
  active: number;
  shown: boolean;
};

const ZERO: Record<NodeId, Point> = { trigger: { x: 0, y: 0 }, branch: { x: 0, y: 0 } };

/** A menu to open with: its row, its chip, and the option under the highlight (none: no highlight). */
export type FlowchartMenu = { row: number; slot: Slot; active?: number };

/** The open menu a FlowchartMenu describes, its highlight on `active` or resting on the choice. */
const menuOf = (conditions: Condition[], start: FlowchartMenu | null | undefined): Menu | null => {
  const condition = start ? conditions[start.row] : undefined;
  if (!start || !condition) return null;
  const labels =
    start.slot === 'field' ? condition.fields : condition.values.map((option) => option.label);
  const current = Math.max(
    0,
    labels.indexOf(start.slot === 'field' ? condition.field : condition.value),
  );
  return {
    row: start.row,
    slot: start.slot,
    active: start.active ?? current,
    shown: start.active !== undefined,
  };
};

export function Flowchart({
  trigger = TRIGGER,
  branchTag = 'If / Else',
  defaultConditions = CONDITIONS,
  onConditionsChange,
  defaultSelected = false,
  onSelectedChange,
  draggable = true,
  autoplay = true,
  colorScheme,
  defaultMenu,
  defaultOffsets,
  className = '',
}: {
  /** The first step: its tag, title and line under it. */
  trigger?: FlowchartTrigger;
  /** The tag over the conditions. */
  branchTag?: string;
  /** The conditions it starts with, a row each. */
  defaultConditions?: Condition[];
  /** Called with every conditions after a field or a value is picked. */
  onConditionsChange?: (conditions: Condition[]) => void;
  /** Whether the trigger starts selected. */
  defaultSelected?: boolean;
  /** Called when the trigger is selected or cleared. */
  onSelectedChange?: (selected: boolean) => void;
  /** The steps can be dragged about the board (and the trigger moved with the arrow keys). */
  draggable?: boolean;
  /** While on screen and untouched, a demo pointer plays with the steps and the chips. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** A menu open from the start (it closes as usual). Setting it turns the demo off. */
  defaultMenu?: FlowchartMenu | null;
  /** How far each step starts from its place, in px. Setting it turns the demo off. */
  defaultOffsets?: Partial<Record<NodeId, Point>>;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  const [conditions, setConditions] = useState(defaultConditions);
  const [selected, setSelected] = useState(defaultSelected);
  const [offsets, setOffsets] = useState(() => ({ ...ZERO, ...defaultOffsets }));
  const [front, setFront] = useState<NodeId>('branch');
  const [dragging, setDragging] = useState(false);
  const [menu, setMenu] = useState<Menu | null>(() => menuOf(defaultConditions, defaultMenu));
  const [demoHover, setDemoHover] = useState(false);
  const [message, setMessage] = useState('');
  const board = useRef<HTMLDivElement>(null);
  const wire = useRef<SVGPathElement>(null);
  const triggerNode = useRef<HTMLDivElement>(null);
  const branchNode = useRef<HTMLDivElement>(null);
  const triggerCard = useRef<HTMLButtonElement>(null);
  const branchCard = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** A press that became a drag: the click that follows it does not select. */
  const dragged = useRef(false);
  const id = useId();
  const hintId = `${id}-hint`;
  const demo = autoplay && !defaultMenu && !defaultOffsets && playing;

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

  // Lays out what CSS cannot: the values that need a line of their own, the board's height, and
  // the curve. Runs after every render, and when anything on the board changes size.
  const measure = useCallback(() => {
    const area = board.current;
    const path = wire.current;
    const top = triggerCard.current;
    const bottom = branchCard.current;
    if (!area || !path || !top || !bottom) return;
    for (const value of bottom.querySelectorAll<HTMLElement>('[data-value]')) {
      const first = value.parentElement?.firstElementChild;
      if (!first) continue;
      value.removeAttribute('data-wrapped');
      const line = first.getBoundingClientRect();
      if (value.getBoundingClientRect().top > line.top + line.height)
        value.setAttribute('data-wrapped', '');
    }
    const below = branchNode.current;
    if (below) {
      area.style.height = `${Math.max(BOARD_HEIGHT, BRANCH_TOP + below.offsetHeight + MARGIN)}px`;
    }
    const frame = area.getBoundingClientRect();
    // Screen px to the board's own, should it sit in a scaled container.
    const scale = frame.width / area.offsetWidth || 1;
    const a = top.getBoundingClientRect();
    const b = bottom.getBoundingClientRect();
    const x1 = (a.left + a.width / 2 - frame.left) / scale;
    const y1 = (a.bottom - frame.top) / scale;
    const x2 = (b.left + b.width / 2 - frame.left) / scale;
    const y2 = (b.top - frame.top) / scale;
    const k = Math.abs(y2 - y1) * BEND;
    const n = (v: number) => Math.round(v * 10) / 10;
    path.setAttribute(
      'd',
      `M ${n(x1)} ${n(y1)} C ${n(x1)} ${n(y1 + k)}, ${n(x2)} ${n(y2 - k)}, ${n(x2)} ${n(y2)}`,
    );
  }, []);

  useLayoutEffect(measure);
  useEffect(() => {
    const area = board.current;
    if (!area) return;
    const observer = new ResizeObserver(() => measure());
    observer.observe(area);
    if (triggerNode.current) observer.observe(triggerNode.current);
    if (branchNode.current) observer.observe(branchNode.current);
    // Webfonts change the chips' widths once they load.
    document.fonts?.ready.then(() => measure());
    return () => observer.disconnect();
  }, [measure]);

  /** How far a step may move from where it is, and stay on the board. */
  const room = (node: NodeId) => {
    const area = board.current;
    const element = node === 'trigger' ? triggerNode.current : branchNode.current;
    if (!area || !element) return null;
    const frame = area.getBoundingClientRect();
    const box = element.getBoundingClientRect();
    const scale = frame.width / area.offsetWidth || 1;
    // Never less than none: a step already past the inset may still move back in.
    const keep = (px: number) => Math.max(0, px / scale - INSET);
    return {
      left: keep(box.left - frame.left),
      right: keep(frame.right - box.right),
      up: keep(box.top - frame.top),
      down: keep(frame.bottom - box.bottom),
    };
  };

  const moveBy = (
    node: NodeId,
    from: Point,
    by: Point,
    space: NonNullable<ReturnType<typeof room>>,
  ) => {
    const x = Math.min(space.right, Math.max(-space.left, by.x));
    const y = Math.min(space.down, Math.max(-space.up, by.y));
    setOffsets((now) => ({ ...now, [node]: { x: from.x + x, y: from.y + y } }));
  };

  // Dragging a step: the pointer is captured by the step, so the drag goes on outside it.
  const startDrag = (node: NodeId) => (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!draggable || event.button !== 0) return;
    if ((event.target as Element).closest('[data-ui]')) return;
    const element = event.currentTarget;
    const space = room(node);
    if (!space) return;
    const origin = { x: event.clientX, y: event.clientY };
    const from = offsets[node];
    const scale =
      (board.current?.getBoundingClientRect().width ?? 1) / (board.current?.offsetWidth || 1);
    let moving = false;
    dragged.current = false;
    // The pointer is captured only once the press becomes a drag: a press that stays put must
    // still click the card under it.
    const move = (next: PointerEvent) => {
      if (next.pointerId !== event.pointerId) return;
      const by = { x: (next.clientX - origin.x) / scale, y: (next.clientY - origin.y) / scale };
      if (!moving && Math.hypot(by.x, by.y) < SLOP) return;
      if (!moving) {
        moving = true;
        dragged.current = true;
        element.setPointerCapture(event.pointerId);
        setDragging(true);
        setFront(node);
        setMenu(null);
      }
      moveBy(node, from, by, space);
    };
    const end = (next: PointerEvent) => {
      if (next.pointerId !== event.pointerId) return;
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', end);
      window.removeEventListener('pointercancel', end);
      setDragging(false);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', end);
    window.addEventListener('pointercancel', end);
  };

  const select = (next: boolean) => {
    setSelected(next);
    onSelectedChange?.(next);
  };

  const onTriggerClick = () => {
    if (dragged.current) {
      dragged.current = false;
      return;
    }
    select(!selected);
  };

  // The arrow keys move a focused trigger: 4 px, or a dot's width (22 px) with Shift.
  const onTriggerKey = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (!draggable) return;
    const step = event.shiftKey ? 22 : 4;
    const by = {
      ArrowLeft: { x: -step, y: 0 },
      ArrowRight: { x: step, y: 0 },
      ArrowUp: { x: 0, y: -step },
      ArrowDown: { x: 0, y: step },
    }[event.key];
    if (!by) return;
    event.preventDefault();
    const space = room('trigger');
    if (!space) return;
    setFront('trigger');
    moveBy('trigger', offsets.trigger, by, space);
  };

  const optionsOf = (row: number, slot: Slot) => {
    const condition = conditions[row];
    return slot === 'field'
      ? condition.fields.map((label) => ({ label }) as ConditionOption)
      : condition.values;
  };

  const openMenu = (row: number, slot: Slot, keyboard: boolean) => {
    const condition = conditions[row];
    const current = slot === 'field' ? condition.field : condition.value;
    const index = Math.max(
      0,
      optionsOf(row, slot).findIndex((option) => option.label === current),
    );
    setMenu({ row, slot, active: index, shown: keyboard });
  };

  const choose = (row: number, slot: Slot, index: number) => {
    const option = optionsOf(row, slot)[index];
    if (!option) return;
    const next = conditions.map((condition, i) =>
      i === row ? { ...condition, [slot]: option.label } : condition,
    );
    setConditions(next);
    onConditionsChange?.(next);
    setMenu(null);
    const changed = next[row];
    announce(`${changed.subject} ${changed.field} is ${changed.value}`);
  };

  // A press outside the open menu and its chip closes it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      const at = (event.target as Element | null)?.closest('[data-select]');
      if (at?.getAttribute('data-select') === `${menu.row}-${menu.slot}`) return;
      setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  // The demo. It works the component through the same handlers as the reader's hands.
  const live = useRef({ openMenu, choose, select, offsets, conditions, selected });
  live.current = { openMenu, choose, select, offsets, conditions, selected };
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    const pick = async (
      steps: Parameters<Parameters<typeof runDemo>[2]>[0],
      row: number,
      slot: Slot,
      label: string,
    ) => {
      const chip = find(`[data-select="${row}-${slot}"] button`);
      await steps.moveTo(chip, { x: 0.45, y: 0.55 });
      await steps.wait(300);
      await steps.press(() => live.current.openMenu(row, slot, false));
      await steps.wait(450);
      const condition = live.current.conditions[row];
      const labels =
        slot === 'field' ? condition.fields : condition.values.map((option) => option.label);
      const index = labels.indexOf(label);
      const options = stage.querySelectorAll(`[data-select="${row}-${slot}"] [role="option"]`);
      await steps.moveTo(options[index], { x: 0.3, y: 0.5 });
      setMenu((now) => (now ? { ...now, active: index, shown: true } : now));
      await steps.wait(350);
      await steps.press(() => live.current.choose(row, slot, index));
    };
    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, moveTo, press, drag } = steps;
      const start = CONDITIONS.map((condition) => condition.value);
      for (;;) {
        await wait(1400);
        // Select the trigger, carry it off to the right and back, and clear it.
        await moveTo(triggerCard.current, { x: 0.62, y: 0.55 });
        setDemoHover(true);
        await wait(350);
        await press(() => live.current.select(true));
        await wait(700);
        const from = live.current.offsets.trigger;
        const space = room('trigger');
        if (space) {
          setFront('trigger');
          await drag(56, 26, 760, (by) => moveBy('trigger', from, by, space));
          await wait(600);
          const back = live.current.offsets.trigger;
          const space2 = room('trigger');
          if (space2)
            await drag(from.x - back.x, from.y - back.y, 760, (by) =>
              moveBy('trigger', back, by, space2),
            );
        }
        await wait(500);
        await press(() => live.current.select(false));
        await wait(400);
        setDemoHover(false);
        // Another flavour and topping, then the first ones again.
        const rows = live.current.conditions;
        if (rows[0]?.values.length > 1)
          await pick(steps, 0, 'value', rows[0].values[2 % rows[0].values.length].label);
        await wait(1300);
        if (rows[1]?.values.length > 1)
          await pick(steps, 1, 'value', rows[1].values[2 % rows[1].values.length].label);
        await wait(1600);
        for (const [row, label] of start.entries()) {
          if (live.current.conditions[row]?.values.some((option) => option.label === label)) {
            await pick(steps, row, 'value', label);
            await wait(700);
          }
        }
        await moveTo(stage, { x: 0.88, y: 0.92 });
        await wait(1800);
      }
    });
    return () => {
      stop();
      setMenu(null);
      setDemoHover(false);
    };
    // Only the demo's being on or off restarts it; it reads the rest from `live`.
  }, [demo, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div
        ref={board}
        className={styles.board}
        data-board=""
        data-part="board"
        data-dragging={dragging || undefined}
      >
        <svg className={styles.wires} aria-hidden="true">
          <path
            ref={wire}
            className={styles.wire}
            data-part="curve"
            data-selected={selected || undefined}
          />
        </svg>

        <div
          ref={triggerNode}
          className={styles.node}
          data-front={front === 'trigger' || undefined}
          style={{
            left: `calc(50% + ${offsets.trigger.x}px)`,
            top: TRIGGER_TOP + offsets.trigger.y,
            width: `min(${TRIGGER_WIDTH}px, 100% - ${MARGIN}px)`,
          }}
          onPointerDown={startDrag('trigger')}
        >
          <span
            className={styles.tag}
            data-part="trigger-tag"
            style={{ ['--hue' as string]: 'var(--violet)' }}
          >
            {trigger.tag}
          </span>
          <button
            ref={triggerCard}
            type="button"
            className={styles.trigger}
            data-part="trigger"
            aria-pressed={selected}
            aria-describedby={draggable ? hintId : undefined}
            data-hover={demoHover || undefined}
            onClick={onTriggerClick}
            onKeyDown={onTriggerKey}
          >
            <span className={styles.icon} data-part="icon" aria-hidden="true">
              <Cone size={16} />
            </span>
            <span className={styles.copy}>
              <span className={styles.title}>{trigger.title}</span>
              <span className={styles.description}>{trigger.description}</span>
            </span>
          </button>
        </div>

        <div
          ref={branchNode}
          className={styles.node}
          data-front={front === 'branch' || undefined}
          data-menu={menu ? '' : undefined}
          style={{
            left: `calc(50% + ${offsets.branch.x}px)`,
            top: BRANCH_TOP + offsets.branch.y,
            width: `min(${BRANCH_WIDTH}px, 100% - ${MARGIN}px)`,
          }}
          onPointerDown={startDrag('branch')}
        >
          <span
            className={styles.tag}
            data-part="branch-tag"
            style={{ ['--hue' as string]: 'var(--amber)' }}
          >
            {branchTag}
          </span>
          <div
            ref={branchCard}
            className={styles.branch}
            data-part="conditions"
            role="group"
            aria-label={`${branchTag}: conditions`}
          >
            {conditions.map((condition, row) => (
              <div key={condition.id} className={styles.row} data-part="condition">
                <Grip />
                <span className={styles.joiner} data-part="joiner">
                  {condition.joiner}
                </span>
                <span className={styles.subject} data-part="subject" data-ui="">
                  <Cone size={12} />
                  {condition.subject}
                </span>
                <Select
                  id={`${id}-${row}-field`}
                  name={`${condition.subject} field`}
                  slot="field"
                  row={row}
                  options={optionsOf(row, 'field')}
                  value={condition.field}
                  menu={menu?.row === row && menu.slot === 'field' ? menu : null}
                  onOpen={openMenu}
                  onClose={() => setMenu(null)}
                  onActive={(active, shown) =>
                    setMenu((now) => (now ? { ...now, active, shown } : now))
                  }
                  onChoose={choose}
                />
                <span className={styles.is}>is</span>
                <span className={styles.value} data-value="">
                  <Select
                    id={`${id}-${row}-value`}
                    name={`${condition.subject} ${condition.field}`}
                    slot="value"
                    row={row}
                    options={condition.values}
                    value={condition.value}
                    width={condition.valuesWidth}
                    menu={menu?.row === row && menu.slot === 'value' ? menu : null}
                    onOpen={openMenu}
                    onClose={() => setMenu(null)}
                    onActive={(active, shown) =>
                      setMenu((now) => (now ? { ...now, active, shown } : now))
                    }
                    onChoose={choose}
                  />
                </span>
              </div>
            ))}
          </div>
        </div>
      </div>

      {draggable && (
        <span id={hintId} className={styles.status}>
          Drag a step to move it, or use the arrow keys.
        </span>
      )}
      <span className={styles.status} aria-live="polite">
        {message}
      </span>
      <DemoPointer ref={pointer} />
    </div>
  );
}

/** A chip that shows a choice, and its menu: a listbox the arrow keys walk through. */
function Select({
  id,
  name,
  slot,
  row,
  options,
  value,
  width,
  menu,
  onOpen,
  onClose,
  onActive,
  onChoose,
}: {
  id: string;
  name: string;
  slot: Slot;
  row: number;
  options: ConditionOption[];
  value: string;
  width?: number;
  menu: Menu | null;
  onOpen: (row: number, slot: Slot, keyboard: boolean) => void;
  onClose: () => void;
  onActive: (active: number, shown: boolean) => void;
  onChoose: (row: number, slot: Slot, index: number) => void;
}) {
  const chip = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const keyboard = useRef(false);
  const current = options.find((option) => option.label === value);
  const open = menu !== null;

  // An opened menu takes the focus, so the arrow keys walk it.
  useEffect(() => {
    if (open) list.current?.focus({ preventScroll: true });
  }, [open]);

  // Where it opens, before it is painted: from the chip's left corner, or from its right one when
  // it would pass the card's right edge; over the chip, or under it when the board has no room.
  useLayoutEffect(() => {
    const menuBox = list.current;
    const button = chip.current;
    const card = button?.closest('[role="group"]');
    const area = button?.closest('[data-board]');
    if (!open || !menuBox || !button || !card || !area) return;
    const frame = area.getBoundingClientRect();
    // Screen px per px of the board's own (the menu is mid-animation, so it is not measured).
    const scale = frame.width / (area as HTMLElement).offsetWidth || 1;
    const box = button.getBoundingClientRect();
    const end = box.left + menuBox.offsetWidth * scale > card.getBoundingClientRect().right;
    const below = box.top - frame.top < (menuBox.offsetHeight + 6) * scale;
    menuBox.setAttribute('data-align', end ? 'end' : 'start');
    menuBox.toggleAttribute('data-below', below);
  }, [open]);

  const close = (refocus: boolean) => {
    onClose();
    if (refocus) chip.current?.focus({ preventScroll: true });
  };

  const onChipKey = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !open) {
      event.preventDefault();
      keyboard.current = true;
      onOpen(row, slot, true);
    }
  };

  const onListKey = (event: ReactKeyboardEvent<HTMLUListElement>) => {
    if (!menu) return;
    const last = options.length - 1;
    const moves: Record<string, number> = {
      ArrowDown: Math.min(last, menu.active + 1),
      ArrowUp: Math.max(0, menu.active - 1),
      Home: 0,
      End: last,
    };
    if (event.key in moves) {
      event.preventDefault();
      onActive(moves[event.key], true);
    } else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      onChoose(row, slot, menu.active);
      chip.current?.focus({ preventScroll: true });
    } else if (event.key === 'Escape') {
      event.preventDefault();
      close(true);
    } else if (event.key === 'Tab') {
      close(false);
    }
  };

  return (
    <span className={styles.select} data-ui="" data-select={`${row}-${slot}`}>
      <button
        ref={chip}
        type="button"
        className={styles.chip}
        data-part={`${slot}-chip`}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={open ? `${id}-list` : undefined}
        aria-label={`${name}: ${value}`}
        onClick={(event) => {
          if (open) close(false);
          // A click from the keyboard (Enter, Space) is handled on key down.
          else if (event.detail > 0) onOpen(row, slot, false);
        }}
        onKeyDown={onChipKey}
      >
        {current?.color && <span className={styles.dot} style={{ background: current.color }} />}
        <span className={styles.label}>{value}</span>
        <Chevron />
      </button>
      {menu && (
        <ul
          ref={list}
          id={`${id}-list`}
          className={styles.menu}
          data-part="menu"
          role="listbox"
          tabIndex={-1}
          aria-label={name}
          aria-activedescendant={`${id}-option-${menu.active}`}
          style={width ? { width } : undefined}
          onKeyDown={onListKey}
          onPointerLeave={() => onActive(menu.active, false)}
        >
          <li
            className={styles.highlight}
            data-part="highlight"
            role="presentation"
            aria-hidden="true"
            style={{ top: 4 + menu.active * 30, opacity: menu.shown ? 1 : 0 }}
          />
          {options.map((option, index) => (
            <li
              key={option.label}
              id={`${id}-option-${index}`}
              className={styles.option}
              data-part="option"
              role="option"
              aria-selected={option.label === value}
              onPointerEnter={() => onActive(index, true)}
              onClick={() => {
                onChoose(row, slot, index);
                chip.current?.focus({ preventScroll: true });
              }}
            >
              <span className={styles.optionLabel}>{option.label}</span>
              {option.note && <span className={styles.note}>{option.note}</span>}
              <Check />
            </li>
          ))}
        </ul>
      )}
    </span>
  );
}

/** An ice-cream cone: a scoop over its rim, and the cone under it. */
function Cone({ size }: { size: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M7.1 7.6a4.9 4.9 0 0 1 9.8 0" />
      <path d="M7.1 7.6h9.8a1.9 1.9 0 0 1 0 3.8H7.1a1.9 1.9 0 0 1 0-3.8Z" />
      <path d="m7.4 11.4 3.7 9.3a1 1 0 0 0 1.8 0l3.7-9.3" />
    </svg>
  );
}

function Chevron() {
  return (
    <svg
      className={styles.chevron}
      width="11"
      height="11"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.4"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6.5 9.5 12 15l5.5-5.5" />
    </svg>
  );
}

function Check() {
  return (
    <svg
      className={styles.check}
      width="13"
      height="13"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M4.5 12.5 9.2 17 19.5 6.5" />
    </svg>
  );
}

/** Six dots: the handle a condition would be reordered by. */
function Grip() {
  return (
    <svg className={styles.grip} data-part="grip" viewBox="0 0 10 16" aria-hidden="true">
      {[3, 8, 13].map((y) =>
        [3, 7.5].map((x) => <circle key={`${x}-${y}`} cx={x} cy={y} r="1.1" fill="currentColor" />),
      )}
    </svg>
  );
}
```

### flowchart/flowchart.module.css

```css
/* Flowchart (flowchart.tsx): a soft grey stage, and in its middle a board 480 px wide at most,
   dotted every 22 px, on which two steps sit: the trigger, a card that can be selected, and the
   If / Else step, its conditions in rows of chips. A curve joins them.

   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). Shadows that differ between the schemes are
   written once with every layer, the layers a scheme does not use being transparent.

   Motion. Hovers ease shadows and fills (150 and 100 ms); a menu pops in from its chip's corner
   (180 ms, a 0.95 scale and a fade) and its highlight glides between options (220 ms). Steps move
   under the pointer by their left and top, so the curve is redrawn in the same frame. Under
   reduced motion all of it is instant. */

.root {
  /* The stage, the board, a card and the ink in three strengths. */
  --canvas: oklch(96.1% 0.002 247.84);
  --page: oklch(98.5% 0.001 286.376);
  --surface: oklch(100% 0 0);
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* Hairlines; the stronger one draws the dots and the curve. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* A chip's fill, its hover (and an open chip), and a menu's highlight. */
  --field: oklch(96.1% 0.001 286.375);
  --hover-2: oklch(93.3% 0.003 247.86);
  --hover: oklch(97% 0.002 247.839);
  /* A selected step, its curve, and the focus ring. */
  --accent: oklch(62.6% 0.205 254.947);
  /* A card's edge and lift: a long soft shadow by day, a short deep one at night. */
  --card-edge: oklch(94.6% 0.003 264.542);
  --raised-edge: oklch(94.6% 0.003 264.542);
  --soft-1: #00000008;
  --soft-2: #00000005;
  --soft-3: #00000003;
  --soft-4: #0000000a;
  --deep-1: transparent;
  --deep-2: transparent;
  /* A subject chip's edge and lift. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  /* The steps' hues: the trigger's violet, the branch's amber. */
  --violet: #9a5cff;
  --amber: #f09a2f;
  /* The same on both schemes: the demo's pointer, which looks like the system's. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);

  /* What the tokens make. */
  --shadow-card:
    0 0 0 1px var(--card-edge), 0 18px 47px 0 var(--soft-1), 0 7.5px 19px 0 var(--soft-2),
    0 4px 10.5px 0 var(--soft-2), 0 2.3px 5.8px 0 var(--soft-3), 0 1.2px 3.1px 0 var(--soft-3),
    0 0.5px 1.3px 0 var(--soft-3), 0 1px 2px var(--deep-1), 0 2px 6px var(--deep-1);
  --shadow-raised:
    0 0 0 1px var(--raised-edge), 0 17.54px 23.39px 0 var(--soft-4), 0 9.4px 12.5px 0 var(--soft-1),
    0 5.25px 7px 0 var(--soft-2), 0 2.79px 3.72px -2px var(--soft-3),
    0 1.16px 1.5px 0 var(--soft-3), 0 2px 10px 0 var(--deep-2), 0 0 0 0 transparent,
    0 0 0 0 transparent;
  --shadow-selected:
    0 0 0 1.5px var(--accent), 0 2px 10px 0 rgb(0 0 0 / 0.045), 0 0 0 0 transparent,
    0 0 0 0 transparent, 0 0 0 0 transparent, 0 0 0 0 transparent, 0 0 0 0 transparent,
    0 0 0 0 transparent, 0 0 0 0 transparent;
  --shadow-button:
    0 0 0 1px var(--button-edge), 0 0 4px 0 var(--button-halo), 0 1px 2px var(--button-drop);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 362px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --page: light-dark(oklch(98.5% 0.001 286.376), oklch(20.9% 0.004 264.477));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --card-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --raised-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.13));
    --soft-1: light-dark(#00000008, transparent);
    --soft-2: light-dark(#00000005, transparent);
    --soft-3: light-dark(#00000003, transparent);
    --soft-4: light-dark(#0000000a, transparent);
    --deep-1: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --deep-2: light-dark(transparent, oklch(0% 0 0 / 0.22));
    --button-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-halo: light-dark(#0000000a, transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
  }
}
/* 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;
}

/* The board: dots every 22 px, centred, so they stay put whatever its size. Its height is the
   script's: 333 px, or more when a narrow board makes the conditions wrap. */
.board {
  position: relative;
  flex: none;
  width: 100%;
  max-width: 480px;
  height: 333px;
  overflow: hidden;
  overflow: clip;
  border-radius: 10px;
  background-color: var(--page);
  background-image: radial-gradient(var(--line-strong) 1px, transparent 1.25px);
  background-size: 22px 22px;
  background-position: center center;
  box-shadow: 0 0 0 1px var(--line);
  user-select: none;
  -webkit-user-select: none;
}
.board[data-dragging] {
  cursor: grabbing;
}

.wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
.wire {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1.25;
  transition: stroke 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.wire[data-selected] {
  stroke: var(--accent);
}

/* A step: its tag over its card, centred on its left. Pressing anywhere but a chip drags it. */
.node {
  position: absolute;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  box-sizing: border-box;
  transform: translateX(-50%);
  touch-action: none;
}
.node[data-front] {
  z-index: 2;
}
.node[data-menu] {
  z-index: 3;
}

.tag {
  display: inline-flex;
  align-items: center;
  height: 24px;
  padding: 0 8px;
  border-radius: 6px;
  font-size: 11.5px;
  font-weight: 500;
  white-space: nowrap;
  background: color-mix(in srgb, var(--hue) 14%, var(--page));
  color: color-mix(in srgb, var(--hue) 80%, var(--ink));
}

.trigger {
  --hue: var(--violet);
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  margin: 0;
  padding: 10px;
  border: 0;
  border-radius: 18px;
  background: var(--surface);
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-align: left;
  cursor: pointer;
  outline: none;
  box-shadow: var(--shadow-card);
  transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.trigger[data-hover] {
  box-shadow: var(--shadow-raised);
}
@media (hover: hover) {
  .trigger:hover {
    box-shadow: var(--shadow-raised);
  }
}
.trigger:focus-visible,
.trigger[aria-pressed='true'] {
  box-shadow: var(--shadow-selected);
}

.icon {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--hue) 12%, var(--surface));
  color: var(--hue);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--hue) 20%, var(--surface));
}
.icon svg {
  width: 16px;
  height: 16px;
}
.copy {
  display: block;
  min-width: 0;
}
.title {
  display: block;
  overflow: hidden;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.25;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.description {
  display: block;
  margin-top: 2px;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 1.375;
}

.branch {
  --hue: var(--amber);
  display: flex;
  flex-direction: column;
  gap: 6px;
  box-sizing: border-box;
  width: 100%;
  padding: 10px 12px;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: var(--shadow-card);
  transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .branch:hover {
    box-shadow: var(--shadow-raised);
  }
}

/* A condition: grip, joiner, subject, field, "is", value. The value drops to a line of its own,
   under the subject, when the row has no room for it. */
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.grip {
  flex: none;
  width: 10px;
  height: 16px;
  color: color-mix(in srgb, var(--ink-3) 70%, transparent);
  cursor: grab;
}
.joiner {
  flex: none;
  width: 28px;
  color: var(--ink-2);
  font-size: 12.5px;
}
.is {
  color: var(--ink-2);
  font-size: 12.5px;
}
.subject {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 4px;
  height: 24px;
  padding: 0 6px;
  border-radius: 6px;
  background: var(--surface);
  box-shadow: var(--shadow-button);
  font-size: 12px;
  font-weight: 500;
}
.subject svg {
  flex: none;
  width: 12px;
  height: 12px;
  color: var(--ink-2);
}
.value {
  display: flex;
  min-width: 0;
  max-width: 100%;
}
.value[data-wrapped] {
  padding: 1px 0 0 49px;
  box-sizing: border-box;
}

/* A chip that opens a menu of choices. */
.select {
  position: relative;
  display: inline-flex;
  min-width: 0;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
  height: 24px;
  margin: 0;
  padding: 0 6px;
  border: 0;
  border-radius: 6px;
  background: var(--field);
  color: var(--ink);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: inherit;
  cursor: pointer;
  outline: none;
  transition:
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .chip:hover {
    background: var(--hover-2);
  }
}
.chip[aria-expanded='true'] {
  background: var(--hover-2);
}
.chip:focus-visible {
  box-shadow: 0 0 0 1.5px var(--accent);
}
.label {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.dot {
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
}
.chevron {
  flex: none;
  width: 11px;
  height: 11px;
  color: var(--ink-3);
}

/* The menu, over its chip (or under it, near the board's top), from the chip's corner. */
.menu {
  position: absolute;
  bottom: 100%;
  left: 0;
  z-index: 20;
  box-sizing: border-box;
  width: max-content;
  min-width: 144px;
  max-width: 280px;
  margin: 0 0 6px;
  padding: 4px;
  list-style: none;
  border-radius: 10px;
  background: var(--surface);
  box-shadow: var(--shadow-raised);
  outline: none;
  transform-origin: left bottom;
  animation: pop 180ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.menu[data-align='end'] {
  right: 0;
  left: auto;
  transform-origin: right bottom;
}
.menu[data-below] {
  top: 100%;
  bottom: auto;
  margin: 6px 0 0;
  transform-origin: left top;
}
.menu[data-below][data-align='end'] {
  transform-origin: right top;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
.highlight {
  position: absolute;
  right: 4px;
  left: 4px;
  height: 30px;
  border-radius: 6px;
  background: var(--hover);
  pointer-events: none;
  transition:
    top 220ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 150ms ease;
}
.option {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  height: 30px;
  padding: 0 8px;
  border-radius: 6px;
  cursor: pointer;
}
.optionLabel {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 12.5px;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.note {
  flex: none;
  color: var(--ink-3);
  font-size: 11px;
}
.check {
  flex: none;
  width: 13px;
  height: 13px;
  visibility: hidden;
}
.option[aria-selected='true'] .check {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .wire,
  .trigger,
  .branch,
  .chip,
  .highlight {
    transition: none;
  }
  .menu {
    animation: none;
  }
}
```

### flowchart/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 control, press it and call the component's own handler, as a click would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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>;
  /**
   * Holds the button down and moves the pointer by `dx`, `dy` over `ms`, easing in and out;
   * `onMove` is told how far it has come, in px, every frame, so what it holds moves with it.
   */
  drag: (dx: number, dy: number, ms: number, onMove: (at: Point) => void) => Promise<void>;
};

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

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.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.86, y: stage.offsetHeight * 0.96 };

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

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const drag: Demo['drag'] = (dx, dy, ms, onMove) => {
    const from = at;
    const held = pointer.firstElementChild as HTMLElement | null;
    if (held) held.style.transform = 'scale(0.9)';
    flight?.cancel();
    let frame = 0;
    const done = new Promise<void>((resolve) => {
      const start = performance.now();
      const tick = (now: number) => {
        const t = Math.min(1, (now - start) / ms);
        // Ease in and out, as a hand starts and stops a drag.
        const k = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
        at = { x: from.x + dx * k, y: from.y + dy * k };
        pointer.style.transform = place(at);
        onMove({ x: dx * k, y: dy * k });
        if (t < 1 && alive) frame = requestAnimationFrame(tick);
        else resolve();
      };
      frame = requestAnimationFrame(tick);
    });
    pending.add(() => cancelAnimationFrame(frame));
    return step(done).finally(() => {
      if (held) held.style.transform = 'scale(1)';
    });
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press, drag }).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 layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  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}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (flowchart.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <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>
      </span>
    </span>
  );
}
```

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