# Action bar

> Floating bars for bulk actions, a plan notice and undo toasts, whose Delete morphs the bar into its own undo toast.

Four bars stacked on a sheet of dashed guides: a bulk-action bar for the rows selected in a table, a notice about the plan, and two toasts that offer to undo. Change status opens a menu upward, while Duplicate and Export confirm with a check. Delete reshapes the bar into a toast that offers to undo it, and the row count rolls digit by digit.

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

Category: Feedback.

## Anatomy

1. **Bulk bar**: Segments on one plate, split by hairlines. When it turns into its toast, the plate morphs to the new width on a slightly bouncy spring (a clip-path) while the rows cross-fade.
2. **Selection count**: How many are picked of how many there are. The total rolls digit by digit when rows are added or deleted.
3. **Change status**: Opens the menu upward from the bar; its chevron turns while open. Arrow keys open it from the keyboard.
4. **Status menu**: Four statuses with their marks, the current one ticked. Picking closes it and shows a check on the button.
5. **Action**: An icon and a word. Pressed, the icon gives way to a check for 1.5 s; Duplicate also adds the rows to the total.
6. **Delete**: The last segment, in the danger colour. It turns the bar into a toast with Undo instead of asking first.
7. **Notice**: A bar with a message and one filled button, the only filled thing on the sheet.
8. **Toast**: What was just done, its subject in bold, and Undo. Undo sends it away on a short fall.

## When to use

- Rows picked in a table or a list, and a few things to do to all of them at once: move, copy, export, delete.
- A bar that floats over the content only while there is a selection, so the page keeps its toolbar for everything else.
- An action that is easy to take back: delete with an Undo in a toast, rather than a dialog asking first.
- A short notice about the account or the plan, with one way forward, that should stay in view without blocking anything.

## When not to use

- One row at a time. Put its actions on the row, or in a menu at its end.
- More than four or five bulk actions. Keep the common ones on the bar and the rest under a More menu, or use a toolbar.
- A deletion that can’t be undone. Ask first in a confirmation that names what goes and how many.
- Errors that need fixing before going on. Show them by the field or in the page, not in a toast that leaves.
- News that matters for a while (billing, outages). Use a banner across the page that stays until it is dealt with.

## Installation

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

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

export function TableActions({ count, onDelete }: { count: number; onDelete: () => void }) {
  return <ActionBar autoplay={false} selected={count} defaultTotal={200} onDelete={onDelete} />;
}
```

## States

- **Selection**: Three rows picked: the bulk bar over the notice and two toasts.
- **Status menu open**: Opened upward from Change status, with In progress ticked. Escape or a press outside closes it.
- **Action confirmed**: Duplicate shows its check and the total has rolled from 200 to 203. The check leaves after 1.5 s.
- **Deleted, with Undo**: The bulk bar has turned into its toast and the total is down by three. Undo turns it back.
- **Toast undone**: The filter toast has fallen away after its Undo. On this stage it comes back after 2.6 s.
- **Narrow stage**: Under 650 px the bulk bar keeps its icons only (the words stay for screen readers) and messages wrap.

## How it works

- Four bars sit on a sheet whose dashed guides are the bars’ own edges drawn across it. By day each bar is white with a hairline; at night it has a near-black rim with a faint light line inside it, brighter along the top.
- Change status opens a small menu upward from the bar, driven by the arrow keys, Home, End and Escape. Duplicate and Export swap their icon for a check for a moment.
- Duplicating adds rows, and the total rolls to its new value: each digit is a column of 0 to 9 sliding in a window one digit tall.
- Delete turns the bar into a toast. Its rim, light line and fill are fixed-size layers wider than both shapes, each cut to the bar by a clip-path that springs from the old width to the new one (FLIP), while the two rows cross-fade. Undo plays it back.
- A toast’s Undo sends it away on a short fall, and on this demo stage it springs back a moment later. Each bar paints on a small layer of its own inside one fixed-height layout boundary; below 650px the bulk bar keeps its icons only and the messages wrap.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: white bars with a soft shadow on a light grey sheet by day, near-black bars on a dark grey sheet a step above them at night, so the bars keep their shape in both. Explore plans is the one filled button, black by day and white at night.

## API reference

- **selected** (`number`, default `3`): How many rows are selected.
- **defaultTotal** (`number`, default `200`): How many rows there are at first.
- **defaultState** (`ActionBarState`): Opens in a given moment, with the demo off: the status ticked and whether its menu is open, the actions showing their check, whether the rows were deleted, the toasts sent away by Undo (filter, schedule) and the total. Checks and toasts given here stay put.
- **onStatusChange** (`(status: StatusId) => void`): Called with the status picked in the menu (backlog, progress, done or canceled).
- **onDuplicate** (`() => void`): Called when the selected rows are duplicated.
- **onExport** (`() => void`): Called when the selected rows are exported.
- **onDelete** (`() => void`): Called when the selected rows are deleted.
- **onRestore** (`() => void`): Called when the deletion is undone.
- **onExplorePlans** (`() => void`): Called when Explore plans is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer picks a status, duplicates, exports, deletes, restores, undoes both toasts and resets.
- **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

- Change status is a menu button (aria-haspopup, aria-expanded) whose statuses are radio menu items, reached with the arrow keys, Home and End; Escape closes the menu and returns focus to the button.
- Screen readers get the count as plain text (“3 of 200 selected”); the rolling digits are hidden from them.
- Every action the reader takes is announced in a polite live region. Delete moves focus to the toast’s Undo, and Undo moves it back to Delete.
- The hidden row and the toasts that are away are inert. Under reduced motion every change is immediate and the demo never plays; keyboard focus inside the stage also stops the demo.

## Source

### action-bar/action-bar.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type ReactNode,
  type Ref,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import {
  AlertIcon,
  CheckIcon,
  ChevronUpIcon,
  DuplicateIcon,
  ExportIcon,
  StatusIcon,
  StatusMark,
  TrashIcon,
} from './icons';
import { RollingNumber } from './rolling-number';
import { easingOf, spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './action-bar.module.css';

/*
 * Action bar: floating bars on a sheet of guides. A bulk-action bar for the rows selected
 * in a table, a notice about the plan, and two toasts that offer to undo what was just done.
 *
 * - Change status opens a small menu upward from the bar. Duplicate and Export confirm with a
 *   check for a moment; duplicating adds rows, and the total rolls to its new value, digit by
 *   digit (rolling-number.tsx).
 * - Delete turns the bar into a toast that offers to undo. The bar's plate morphs from one
 *   width to the other: the plate is a fixed-size layer wider than both, cut to the bar's shape
 *   by a clip-path that animates (FLIP), while the two rows of content cross-fade.
 * - A toast's Undo sends it away on a spring; as this is a stage, it comes back a moment later.
 * - The guides are the bars' own edges, drawn as dashed lines across the sheet, under the bars.
 * - While on screen and untouched, a ghost pointer goes through all of it and resets. Under
 *   reduced motion every change is immediate.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the component follows the
 *   page's color-scheme, or the one the colorScheme prop sets: white bars on a light grey sheet by
 *   day, near-black bars on a dark grey sheet at night.
 */

export type StatusId = 'backlog' | 'progress' | 'done' | 'canceled';

const STATUSES: { id: StatusId; label: string }[] = [
  { id: 'backlog', label: 'Backlog' },
  { id: 'progress', label: 'In progress' },
  { id: 'done', label: 'Done' },
  { id: 'canceled', label: 'Canceled' },
];

/** The springs: the plate's morph, and the bars' and the menu's moves (the stylesheet's). */
const MORPH = spring(0.16, 560);
const MOVE = spring(0.22, 380);
/** How far the plate reaches past the bar on each side, in px (the stylesheet's --reach). */
const REACH = 200;
/** The plate's corner radius, in px. */
const RADIUS = 10;
/** How long a confirmation shows, and how long a toast stays away after its Undo, in ms. */
const CONFIRM = 1500;
const AWAY = 2600;

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

type Confirm = 'status' | 'duplicate' | 'export';
type ToastId = 'filter' | 'schedule';

/**
 * A moment to open on, for docs and tests; the demo stays off. Confirmations given here stay on
 * (they only time out after an action), and a toast sent away here stays away.
 */
export type ActionBarState = {
  /** The status ticked in the menu. */
  status?: StatusId | null;
  /** The status menu is open. */
  menu?: boolean;
  /** Actions showing their check. */
  confirmed?: Partial<Record<Confirm, boolean>>;
  /** The selected rows were deleted: the bulk bar is its toast, and the total is down. */
  deleted?: boolean;
  /** Toasts sent away by their Undo. */
  away?: Partial<Record<ToastId, boolean>>;
  /** How many rows there are (after defaultTotal and a deletion). */
  total?: number;
};

/** What the demo does, step by step: the reader's own actions, without their announcements. */
type DemoAct =
  | { type: 'menu' }
  | { type: 'status'; status: StatusId }
  | { type: 'confirm'; what: 'duplicate' | 'export' }
  | { type: 'delete' }
  | { type: 'restore' }
  | { type: 'undo'; toast: ToastId }
  | { type: 'reset' };

export function ActionBar({
  selected = 3,
  defaultTotal = 200,
  onStatusChange,
  onDuplicate,
  onExport,
  onDelete,
  onRestore,
  onExplorePlans,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** How many rows are selected. */
  selected?: number;
  /** How many rows there are at first. */
  defaultTotal?: number;
  /** Called with the status picked in the menu. */
  onStatusChange?: (status: StatusId) => void;
  /** Called when the selected rows are duplicated. */
  onDuplicate?: () => void;
  /** Called when the selected rows are exported. */
  onExport?: () => void;
  /** Called when the selected rows are deleted. */
  onDelete?: () => void;
  /** Called when the deletion is undone. */
  onRestore?: () => void;
  /** Called when Explore plans is pressed. */
  onExplorePlans?: () => void;
  /** While on screen and untouched, a ghost pointer goes through the bars. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this moment (the menu open, an action confirmed, deleted…), with the demo off. */
  defaultState?: ActionBarState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  const [total, setTotal] = useState(
    () => defaultState?.total ?? (defaultState?.deleted ? defaultTotal - selected : defaultTotal),
  );
  const [status, setStatus] = useState<StatusId | null>(defaultState?.status ?? null);
  const [menu, setMenu] = useState(defaultState?.menu ?? false);
  const [confirmed, setConfirmed] = useState<Record<Confirm, boolean>>({
    status: false,
    duplicate: false,
    export: false,
    ...defaultState?.confirmed,
  });
  const [deleted, setDeleted] = useState(defaultState?.deleted ?? false);
  const [away, setAway] = useState<Record<ToastId, boolean>>({
    filter: false,
    schedule: false,
    ...defaultState?.away,
  });
  const [message, setMessage] = useState('');
  /** Keyboard focus is in the stage: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const menuId = useId();

  const stage = useRef<HTMLDivElement>(null);
  const sheet = useRef<HTMLDivElement>(null);
  const bulk = useRef<HTMLDivElement>(null);
  const edge = useRef<HTMLSpanElement>(null);
  const ring = useRef<HTMLSpanElement>(null);
  const fill = useRef<HTMLSpanElement>(null);
  const shadow = useRef<HTMLSpanElement>(null);
  const content = useRef<HTMLDivElement>(null);
  const statusButton = useRef<HTMLButtonElement>(null);
  const menuList = useRef<HTMLDivElement>(null);
  const duplicateButton = useRef<HTMLButtonElement>(null);
  const exportButton = useRef<HTMLButtonElement>(null);
  const deleteButton = useRef<HTMLButtonElement>(null);
  const restoreButton = useRef<HTMLButtonElement>(null);
  const undoFilter = useRef<HTMLButtonElement>(null);
  const undoSchedule = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopGhost = useRef<(() => void) | null>(null);
  const timers = useRef(new Map<string, number>());
  /** The bulk bar's width before it turned (into a toast, or back), for the morph. */
  const morphFrom = useRef<number | null>(null);
  /** Where focus goes once the bulk bar has turned, when the reader turned it. */
  const focusAfter = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const pending = timers.current;
    return () => pending.forEach((timer) => window.clearTimeout(timer));
  }, []);

  /** Runs `action` after `ms`, replacing the timer of the same name. */
  const later = (name: string, ms: number, action: () => void) => {
    window.clearTimeout(timers.current.get(name));
    timers.current.set(name, window.setTimeout(action, ms));
  };

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

  const confirm = (what: Confirm) => {
    setConfirmed((now) => ({ ...now, [what]: true }));
    later(what, CONFIRM, () => setConfirmed((now) => ({ ...now, [what]: false })));
  };

  const pickStatus = (next: StatusId, byReader: boolean) => {
    setStatus(next);
    setMenu(false);
    confirm('status');
    onStatusChange?.(next);
    if (byReader) {
      const label = STATUSES.find((item) => item.id === next)?.label ?? next;
      announce(`${rows} moved to ${label}.`);
      statusButton.current?.focus();
    }
  };

  const duplicate = (byReader: boolean) => {
    setTotal((now) => now + selected);
    confirm('duplicate');
    onDuplicate?.();
    if (byReader) announce(`${rows} duplicated. ${selected} of ${total + selected} selected.`);
  };

  const exportRows = (byReader: boolean) => {
    confirm('export');
    onExport?.();
    if (byReader) announce(`${rows} exported.`);
  };

  /** Turns the bulk bar into its toast, or back, and plays the plate's morph. */
  const turn = (toToast: boolean, byReader: boolean) => {
    if (toToast === deleted) return;
    setMenu(false);
    morphFrom.current = bulk.current?.offsetWidth ?? null;
    focusAfter.current = byReader ? (toToast ? restoreButton.current : deleteButton.current) : null;
    setDeleted(toToast);
    setTotal((now) => now + (toToast ? -selected : selected));
    if (toToast) {
      onDelete?.();
      if (byReader) announce(`${rows} deleted. Press Undo to bring them back.`);
    } else {
      onRestore?.();
      if (byReader) announce(`${rows} restored.`);
    }
  };

  /** A toast's Undo: it goes away, and comes back after a moment. */
  const undo = (toast: ToastId, byReader: boolean) => {
    setAway((now) => ({ ...now, [toast]: true }));
    later(toast, AWAY, () => setAway((now) => ({ ...now, [toast]: false })));
    if (byReader) {
      announce(toast === 'filter' ? 'Amount range filter restored.' : 'Vela schedule restored.');
      // Its button is gone: focus stays in the stage.
      sheet.current?.focus();
    }
  };

  const reset = () => {
    setMenu(false);
    setStatus(null);
    turn(false, false);
    setTotal(defaultTotal);
  };

  // The plate's morph: from the bar's width before it turned to its width now.
  useLayoutEffect(() => {
    const from = morphFrom.current;
    const bar = bulk.current;
    morphFrom.current = null;
    focusAfter.current?.focus();
    focusAfter.current = null;
    if (from === null || !bar || reducedMotion()) return;
    const to = bar.offsetWidth;
    const side = (from - to) / 2;
    const timing = { duration: MORPH.duration, easing: easingOf(MORPH) };
    const cut = (inset: number, extra: number) =>
      `inset(${inset}px ${REACH - extra + inset}px round ${RADIUS - inset}px)`;
    edge.current?.animate([{ clipPath: cut(0, side) }, { clipPath: cut(0, 0) }], timing);
    ring.current?.animate([{ clipPath: cut(1, side) }, { clipPath: cut(1, 0) }], timing);
    fill.current?.animate([{ clipPath: cut(2, side) }, { clipPath: cut(2, 0) }], timing);
    shadow.current?.animate(
      [{ transform: `scaleX(${from / Math.max(1, to)})` }, { transform: 'none' }],
      timing,
    );
    // The rows are cut to the plate as it morphs, so no text shows past it.
    content.current?.animate(
      [
        { clipPath: `inset(0 ${-side}px round ${RADIUS}px)` },
        { clipPath: `inset(0 0 round ${RADIUS}px)` },
      ],
      timing,
    );
  }, [deleted]);

  // The menu: a press outside closes it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      const target = event.target as Node;
      if (menuList.current?.contains(target) || statusButton.current?.contains(target)) return;
      setMenu(false);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  const openMenu = (focusItem: boolean) => {
    setMenu(true);
    if (focusItem) {
      // Once it shows: the current status, or the first.
      requestAnimationFrame(() => {
        const items =
          menuList.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]');
        const current = STATUSES.findIndex((item) => item.id === status);
        items?.[Math.max(0, current)]?.focus();
      });
    }
  };

  const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const items = [
      ...(menuList.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []),
    ];
    const at = items.indexOf(document.activeElement as HTMLButtonElement);
    const moves: Partial<Record<string, number>> = {
      ArrowDown: (at + 1) % items.length,
      ArrowUp: (at - 1 + items.length) % items.length,
      Home: 0,
      End: items.length - 1,
    };
    const next = moves[event.key];
    if (next !== undefined) {
      event.preventDefault();
      items[next]?.focus();
    } else if (event.key === 'Escape' || event.key === 'Tab') {
      if (event.key === 'Escape') event.preventDefault();
      setMenu(false);
      statusButton.current?.focus();
    }
  };

  /** The demo's moves: the reader's own, quietly. */
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'menu') openMenu(false);
    else if (act.type === 'status') pickStatus(act.status, false);
    else if (act.type === 'confirm') {
      if (act.what === 'duplicate') duplicate(false);
      else exportRows(false);
    } else if (act.type === 'delete') turn(true, false);
    else if (act.type === 'restore') turn(false, false);
    else if (act.type === 'undo') undo(act.toast, false);
    else reset();
  });

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

  const demo = autoplay && !defaultState && playing && !keyboard;
  useEffect(() => {
    const area = stage.current;
    const arrow = pointer.current;
    if (!demo || !area || !arrow) return;
    const stop = runDemo(area, arrow, async ({ wait, moveTo, press }) => {
      const item = (id: StatusId) =>
        menuList.current?.querySelector(`[data-status="${id}"]`) ?? null;
      await wait(900);
      demoAct({ type: 'reset' });
      for (let round = 0; ; round += 1) {
        await moveTo(statusButton.current);
        await press(() => demoAct({ type: 'menu' }));
        await wait(650);
        const pick: StatusId = round % 2 === 0 ? 'progress' : 'done';
        await moveTo(item(pick), { x: 0.4, y: 0.5 });
        await wait(200);
        await press(() => demoAct({ type: 'status', status: pick }));
        await wait(900);
        await moveTo(duplicateButton.current);
        await press(() => demoAct({ type: 'confirm', what: 'duplicate' }));
        await wait(1300);
        await moveTo(exportButton.current);
        await press(() => demoAct({ type: 'confirm', what: 'export' }));
        await wait(1100);
        await moveTo(undoFilter.current);
        await press(() => demoAct({ type: 'undo', toast: 'filter' }));
        await wait(900);
        await moveTo(deleteButton.current);
        await press(() => demoAct({ type: 'delete' }));
        await wait(1300);
        await moveTo(restoreButton.current);
        await wait(250);
        await press(() => demoAct({ type: 'restore' }));
        await wait(1100);
        await moveTo(undoSchedule.current);
        await press(() => demoAct({ type: 'undo', toast: 'schedule' }));
        await wait(2200);
        demoAct({ type: 'reset' });
        await wait(1600);
      }
    });
    stopGhost.current = stop;
    return stop;
  }, [demo]);

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  const springs = {
    '--spring': MOVE.easing,
    '--move-ms': `${MOVE.duration}ms`,
    '--reach': `${REACH}px`,
    '--radius': `${RADIUS}px`,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div ref={stage} className={styles.stage} style={springs}>
        <div ref={sheet} className={styles.sheet} tabIndex={-1}>
          {/* The bulk-action bar, which turns into a toast when its rows are deleted. */}
          <div className={styles.slot}>
            <Guides />
            <div
              ref={bulk}
              className={styles.bar}
              data-part="bulk-bar"
              data-turned={deleted || undefined}
            >
              <span ref={shadow} className={styles.shadow} aria-hidden="true" />
              <span ref={edge} className={styles.edge} aria-hidden="true" />
              <span ref={ring} className={styles.ring} aria-hidden="true" />
              <span ref={fill} className={styles.fill} aria-hidden="true" />

              <div ref={content} className={styles.rows}>
                <div
                  className={`${styles.row} ${styles.bulkRow}`}
                  role="group"
                  aria-label="Selected rows"
                  inert={deleted}
                >
                  <span className={`${styles.segment} ${styles.count}`} data-part="count">
                    <span className={styles.visuallyHidden}>
                      {selected} of {total} selected
                    </span>
                    <span aria-hidden="true">
                      <b>{selected}</b> of{' '}
                      <b>
                        <RollingNumber value={total} />
                      </b>
                      <span className={styles.selectedWord}> selected</span>
                    </span>
                  </span>
                  <span className={styles.menuAnchor}>
                    <button
                      ref={statusButton}
                      type="button"
                      className={`${styles.segment} ${styles.action} ${styles.statusAction}`}
                      data-part="status"
                      aria-haspopup="menu"
                      aria-expanded={menu}
                      aria-controls={menuId}
                      data-open={menu || undefined}
                      onClick={(event) => (menu ? setMenu(false) : openMenu(event.detail === 0))}
                      onKeyDown={(event) => {
                        if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
                          event.preventDefault();
                          openMenu(true);
                        }
                      }}
                    >
                      <Swap done={confirmed.status}>
                        <StatusIcon />
                      </Swap>
                      <span className={styles.label}>Change status</span>
                      <span className={styles.chevron}>
                        <ChevronUpIcon />
                      </span>
                    </button>
                    <div
                      ref={menuList}
                      id={menuId}
                      className={styles.menu}
                      role="menu"
                      data-part="menu"
                      aria-label="Change status"
                      data-open={menu || undefined}
                      inert={!menu}
                      onKeyDown={onMenuKey}
                    >
                      {STATUSES.map((item) => (
                        <button
                          key={item.id}
                          type="button"
                          role="menuitemradio"
                          aria-checked={status === item.id}
                          className={styles.menuItem}
                          data-part="menu-item"
                          data-status={item.id}
                          tabIndex={-1}
                          onClick={() => pickStatus(item.id, true)}
                        >
                          <span className={styles.mark}>
                            <StatusMark status={item.id} />
                          </span>
                          {item.label}
                          <span className={styles.tick} aria-hidden="true">
                            <CheckIcon />
                          </span>
                        </button>
                      ))}
                    </div>
                  </span>
                  <button
                    ref={duplicateButton}
                    type="button"
                    className={`${styles.segment} ${styles.action}`}
                    data-part="action"
                    onClick={() => duplicate(true)}
                  >
                    <Swap done={confirmed.duplicate}>
                      <DuplicateIcon />
                    </Swap>
                    <span className={styles.label}>Duplicate</span>
                  </button>
                  <button
                    ref={exportButton}
                    type="button"
                    className={`${styles.segment} ${styles.action}`}
                    data-part="action"
                    onClick={() => exportRows(true)}
                  >
                    <Swap done={confirmed.export}>
                      <ExportIcon />
                    </Swap>
                    <span className={styles.label}>Export</span>
                  </button>
                  <button
                    ref={deleteButton}
                    type="button"
                    className={`${styles.segment} ${styles.action} ${styles.danger}`}
                    data-part="delete"
                    onClick={() => turn(true, true)}
                  >
                    <span className={styles.icon}>
                      <TrashIcon />
                    </span>
                    <span className={styles.label}>Delete</span>
                  </button>
                </div>

                <div className={`${styles.row} ${styles.turnedRow}`} inert={!deleted}>
                  <Message subject={`${rows}`} rest="have been deleted" />
                  <button
                    ref={restoreButton}
                    type="button"
                    className={`${styles.segment} ${styles.undo}`}
                    data-part="undo"
                    onClick={() => turn(false, true)}
                  >
                    Undo
                  </button>
                </div>
              </div>
            </div>
          </div>

          {/* The notice. */}
          <div className={styles.slot}>
            <Guides />
            <div className={`${styles.bar} ${styles.plain} ${styles.notice}`} data-part="notice">
              <span className={`${styles.segment} ${styles.message}`}>
                <span className={styles.icon}>
                  <AlertIcon />
                </span>
                <span className={styles.text}>Your free plan ends on October 17th, 2024</span>
              </span>
              <button
                type="button"
                className={styles.explore}
                data-part="explore"
                onClick={() => onExplorePlans?.()}
              >
                Explore plans
              </button>
            </div>
          </div>

          {/* The two toasts. */}
          <Toast
            away={away.filter}
            undoRef={undoFilter}
            onUndo={() => undo('filter', true)}
            subject="Amount range"
            rest="filter has been deleted"
          />
          <Toast
            away={away.schedule}
            undoRef={undoSchedule}
            onUndo={() => undo('schedule', true)}
            subject="Vela"
            rest="schedule has been successfully canceled"
          />
        </div>

        <DemoPointer ref={pointer} />
        <p className={styles.visuallyHidden} aria-live="polite">
          {message}
        </p>
      </div>
    </div>
  );
}

/** The dashed guides along a bar's four edges, across the whole sheet. */
function Guides() {
  return (
    <span className={styles.guides} aria-hidden="true">
      <i />
      <i />
      <i />
      <i />
    </span>
  );
}

/** An icon that gives way to a check while `done`. */
function Swap({ done, children }: { done: boolean; children: ReactNode }) {
  return (
    <span className={`${styles.icon} ${styles.swap}`} data-done={done || undefined}>
      <span>{children}</span>
      <span>
        <CheckIcon />
      </span>
    </span>
  );
}

/** A toast's message: the alert, its subject in white and the rest in grey. */
function Message({ subject, rest }: { subject: string; rest: string }) {
  return (
    <span className={`${styles.segment} ${styles.message}`} role="status" data-part="message">
      <span className={styles.icon}>
        <AlertIcon />
      </span>
      <span className={styles.text}>
        <b>{subject}</b> {rest}
      </span>
    </span>
  );
}

function Toast({
  away,
  undoRef,
  onUndo,
  subject,
  rest,
}: {
  away: boolean;
  undoRef: Ref<HTMLButtonElement>;
  onUndo: () => void;
  subject: string;
  rest: string;
}) {
  return (
    <div className={styles.slot}>
      <Guides />
      <div
        className={`${styles.bar} ${styles.plain} ${styles.toast}`}
        data-part="toast"
        data-away={away || undefined}
        inert={away}
      >
        <Message subject={subject} rest={rest} />
        <button
          ref={undoRef}
          type="button"
          className={`${styles.segment} ${styles.undo}`}
          data-part="undo"
          onClick={onUndo}
        >
          Undo
        </button>
      </div>
    </div>
  );
}
```

### action-bar/action-bar.module.css

```css
/* Action bar (action-bar.tsx). A sheet with four floating bars stacked in its middle, and the
   bars' edges drawn as dashed guides across it. In a stage narrower than the bulk bar, the bulk
   bar keeps its icons only and the messages wrap.

   Colours. Every colour is a token on .root, light-dark(light, dark): the component takes the
   color-scheme it inherits from the page (light unless the page says otherwise), or the one its
   colorScheme prop sets (data-color-scheme). By day the bars are white, with a hairline and a soft
   shadow, on a light grey sheet; at night they are near-black, with a dark rim and a faint light
   line inside it, on a dark grey sheet.

   Memory and layout. The stage is one layout boundary of a known size. What moves is a transform,
   an opacity or a clip-path on a box of a fixed size: the plate behind the bulk bar never changes
   size (a clip-path cuts it to the bar's width), the toasts move as a whole, the digits slide in
   their windows. The icons are drawings that never change; the bars fade the boxes around them. */

.root {
  /* The sheet, its edge, and the dashed guides across it. The sheet is a step off the bars: a
     light grey under white bars by day, a dark grey above near-black ones at night. */
  --surface: #f5f5f7;
  --edge: rgb(0 0 0 / 0.06);
  --guide: #dbdbe0;
  /* The bars' shadow on the sheet: close, then softer further down. */
  --shadow: rgb(0 0 0 / 0.08);
  --shadow-far: rgb(0 0 0 / 0.07);
  /* The bars: their fill, their words (and the check after an action), the quieter words, the
     icons, the menu's chevron, Delete, and the hairline between segments. */
  --bar: #fff;
  --ink: #18181b;
  --muted: #6b6b73;
  --glyph: #6b6b73;
  --chevron: #85858c;
  --danger: #cc2448;
  --line: rgb(0 0 0 / 0.08);
  /* A bar's edge: a rim, and inside it a faint light line, brighter along the top (by day the rim
     is a hairline, and there is no light line). */
  --rim: rgb(0 0 0 / 0.1);
  --rim-light: rgb(255 255 255 / 0);
  --rim-top: rgb(255 255 255 / 0);
  /* Under the pointer: a segment's patch, and a menu item. */
  --hover: rgb(0 0 0 / 0.045);
  --menu-hover: rgb(0 0 0 / 0.05);
  /* Explore plans, the one button that stands out from its bar: its fill, under the pointer, and
     its words. */
  --accent: #18181b;
  --accent-hover: #35353b;
  --accent-ink: #fff;
  /* The statuses' marks in the menu: in progress, done. */
  --status-progress: #a87200;
  --status-done: #5b5ee6;
  /* The focus rings. */
  --focus: #0a7aff;
  /* The demo's pointer, the same on both schemes: it looks like the system's. */
  --pointer: #111216;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.3);
  container-type: inline-size;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f5f5f7, #1f1f23);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --guide: light-dark(#dbdbe0, #323237);
    --shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.6));
    --shadow-far: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.5));
    --bar: light-dark(#fff, #131217);
    --ink: light-dark(#18181b, #fff);
    --muted: light-dark(#6b6b73, #bcbcc1);
    --glyph: light-dark(#6b6b73, #b9b9be);
    --chevron: light-dark(#85858c, #75757c);
    --danger: light-dark(#cc2448, #ec5a74);
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.12));
    --rim: light-dark(rgb(0 0 0 / 0.1), #060608);
    --rim-light: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.065));
    --rim-top: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.16));
    --hover: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.06));
    --menu-hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --accent: light-dark(#18181b, #f9f9f9);
    --accent-hover: light-dark(#35353b, #fff);
    --accent-ink: light-dark(#fff, #111113);
    --status-progress: light-dark(#a87200, #f0b44c);
    --status-done: light-dark(#5b5ee6, #8a8cf6);
    --focus: light-dark(#0a7aff, #7fb0ff);
  }
}
/* 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;
}

.stage {
  /* A bar's shadow: close under it, softer further down, tight at the sides. Its blur stays
     small: a wide one costs Safari a lot of memory on every bar. */
  --lift: 0 2px 6px var(--shadow), 0 14px 24px var(--shadow-far);
  --move: var(--spring);
  position: relative;
  height: 540px;
  overflow: hidden;
  overflow: clip;
  contain: size layout;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 13px/1 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
.stage :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

/* The bars, one per slot, from 146px down, 39px apart. */
.sheet {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 39px;
  padding: 146px 16px 0;
  outline: none;
}
.slot {
  position: relative;
  display: flex;
  justify-content: center;
  max-width: 100%;
}

/* A slot's guides: dashed lines along its edges, reaching across the sheet, under every bar. */
.guides i {
  position: absolute;
  z-index: 0;
  display: block;
  pointer-events: none;
}
.guides i:nth-child(-n + 2) {
  right: -1200px;
  left: -1200px;
  height: 1px;
  background: repeating-linear-gradient(90deg, var(--guide) 0 5px, transparent 5px 8px);
}
.guides i:nth-child(1) {
  top: 0;
}
.guides i:nth-child(2) {
  bottom: 0;
}
.guides i:nth-child(n + 3) {
  top: -1200px;
  bottom: -1200px;
  width: 1px;
  background: repeating-linear-gradient(var(--guide) 0 5px, transparent 5px 8px);
}
.guides i:nth-child(3) {
  left: 0;
}
.guides i:nth-child(4) {
  right: 0;
}

/* A bar: a rounded plate with a hairline, floating on a soft shadow. Each bar paints on a layer of
   its own, so what changes in it never repaints the sheet. */
.bar {
  position: relative;
  z-index: 1;
  display: flex;
  min-height: 43px;
  will-change: transform;
}
.plain {
  border-radius: var(--radius);
  background: var(--bar);
  box-shadow:
    inset 0 0 0 1px var(--rim),
    inset 0 2px 0 var(--rim-top),
    inset 0 0 0 2px var(--rim-light),
    var(--lift);
}

/* The bulk bar's plate: the rim, its light line and the fill reach past the bar on both sides and
   are cut to it by their clip-paths (1px further in each), which play the morph when the bar turns
   into its toast. */
.shadow,
.edge,
.ring,
.fill {
  position: absolute;
  z-index: 0;
  top: 0;
  bottom: 0;
}
.shadow {
  right: 0;
  left: 0;
  border-radius: var(--radius);
  box-shadow: var(--lift);
}
.edge,
.ring,
.fill {
  right: calc(var(--reach) * -1);
  left: calc(var(--reach) * -1);
}
.edge {
  background: var(--rim);
  clip-path: inset(0 var(--reach) round var(--radius));
}
.ring {
  background:
    linear-gradient(var(--rim-top) 0 2px, transparent 2px),
    linear-gradient(var(--rim-light), var(--rim-light)) var(--bar);
  clip-path: inset(1px calc(var(--reach) + 1px) round calc(var(--radius) - 1px));
}
.fill {
  background: var(--bar);
  clip-path: inset(2px calc(var(--reach) + 2px) round calc(var(--radius) - 2px));
}

.rows {
  position: relative;
  z-index: 1;
  display: flex;
}
.row {
  position: relative;
  display: flex;
  align-items: stretch;
  min-height: 43px;
  transition:
    opacity 0.18s ease 0.08s,
    visibility 0s linear 0s;
}
/* The row not showing sits over the one that does, out of the flow, hidden. */
.bar[data-turned] .bulkRow,
.bar:not([data-turned]) .turnedRow {
  position: absolute;
  top: 0;
  left: 50%;
  opacity: 0;
  visibility: hidden;
  translate: -50% 0;
  transition:
    opacity 0.1s ease,
    visibility 0s linear 0.1s;
}

.segment {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  gap: 10px;
  padding: 0 14px;
  white-space: nowrap;
}
/* The hairline between two segments. */
.segment + .segment::before,
.menuAnchor + .segment::before,
.segment + .menuAnchor > .segment::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 1px;
  background: var(--line);
}
.menuAnchor {
  position: relative;
  display: flex;
}

.count {
  padding: 0 13.5px 0 17.5px;
  color: var(--muted);
  line-height: 16px;
}
.count b {
  font-weight: 400;
  color: var(--ink);
}

.action {
  outline: none;
  transition: background-color 0.15s ease;
}
/* Hover and focus light a rounded patch inside the segment. */
.action::after {
  content: '';
  position: absolute;
  inset: 5px 4px;
  border-radius: 7px;
  background: rgb(255 255 255 / 0);
  transition: background-color 0.15s ease;
}
.action:hover::after,
.action[data-open]::after {
  background: var(--hover);
}
.action:focus-visible::after {
  box-shadow: 0 0 0 2px var(--focus);
}
.action:active .label {
  opacity: 0.8;
}
.statusAction {
  gap: 9px;
  padding: 0 13.5px 0 14.5px;
}
.statusAction .chevron {
  margin-left: 3px;
}
.label,
.icon,
.chevron {
  position: relative;
  z-index: 1;
}
.icon,
.chevron {
  display: grid;
  flex: none;
  place-items: center;
  width: 16px;
  height: 16px;
  color: var(--glyph);
}
.icon svg,
.chevron svg {
  display: block;
}
.chevron {
  width: 12px;
  color: var(--chevron);
  transition: transform var(--move-ms) var(--move);
}
.action[data-open] .chevron {
  transform: rotate(180deg);
}
.danger {
  padding: 0 17px 0 16px;
  color: var(--danger);
}
.danger .icon {
  color: var(--danger);
}

/* An icon and its check, stacked: the check shows for a moment after the action. */
.swap > span {
  grid-area: 1 / 1;
  display: grid;
  place-items: center;
  transition:
    opacity 0.16s ease,
    transform var(--move-ms) var(--move);
}
.swap > span + span {
  color: var(--ink);
  opacity: 0;
  transform: scale(0.5);
}
.swap[data-done] > span:first-child {
  opacity: 0;
  transform: scale(0.5);
}
.swap[data-done] > span + span {
  opacity: 1;
  transform: none;
}

/* The digits of the total, each in a window one digit tall. */
.rolling {
  display: inline-flex;
  font-variant-numeric: tabular-nums;
}
.digit {
  position: relative;
  display: inline-block;
  height: 16px;
  overflow: hidden;
  overflow: clip;
  line-height: 16px;
  vertical-align: top;
}
.digitSize {
  visibility: hidden;
}
.column {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  width: 100%;
  transition: transform 0.62s cubic-bezier(0.3, 1.25, 0.4, 1);
}

/* The status menu, opening upward from its button. */
.menu {
  position: absolute;
  z-index: 3;
  bottom: calc(100% + 8px);
  left: 50%;
  display: flex;
  flex-direction: column;
  width: 172px;
  margin-left: -86px;
  padding: 4px;
  border-radius: var(--radius);
  background: var(--bar);
  box-shadow:
    inset 0 0 0 1px var(--rim),
    inset 0 2px 0 var(--rim-top),
    inset 0 0 0 2px var(--rim-light),
    var(--lift);
  opacity: 0;
  visibility: hidden;
  transform: translateY(6px) scale(0.94);
  transform-origin: 50% 100%;
  will-change: transform;
  transition:
    opacity 0.12s ease,
    transform 0.16s ease,
    visibility 0s linear 0.16s;
}
.menu[data-open] {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition:
    opacity 0.14s ease,
    transform var(--move-ms) var(--move),
    visibility 0s linear 0s;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 9px;
  height: 30px;
  padding: 0 9px;
  border-radius: 6px;
  color: var(--ink);
  text-align: left;
  outline: none;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--menu-hover);
}
.menuItem:focus-visible {
  box-shadow: inset 0 0 0 1.5px var(--focus);
}
.mark {
  display: grid;
  color: var(--glyph);
}
.menuItem[data-status='progress'] .mark {
  color: var(--status-progress);
}
.menuItem[data-status='done'] .mark {
  color: var(--status-done);
}
.tick {
  display: grid;
  margin-left: auto;
  color: var(--glyph);
  opacity: 0;
}
.menuItem[aria-checked='true'] .tick {
  opacity: 1;
}

/* The notice and the toasts: an alert, a message, and a button. */
.message {
  gap: 11px;
  padding: 0 13px 0 20px;
  color: var(--muted);
}
.message b {
  font-weight: 400;
  color: var(--ink);
}
.text {
  line-height: 1.3;
}
.notice {
  align-items: center;
  gap: 115px;
  padding-right: 6px;
}
.notice .message {
  gap: 11px;
  padding: 0 0 0 19px;
  color: var(--ink);
}
.explore {
  flex: none;
  height: 29px;
  padding: 0 11px;
  border-radius: 6px;
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 500;
  transition: transform 0.12s ease;
}
.explore:hover {
  background: var(--accent-hover);
}
.explore:active {
  transform: scale(0.96);
}
.explore:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.undo {
  padding: 0 18px 0 13px;
  outline: none;
}
.undo::after {
  content: '';
  position: absolute;
  inset: 5px 4px;
  border-radius: 7px;
}
.undo:hover::after {
  background: var(--hover);
}
.undo:focus-visible::after {
  box-shadow: 0 0 0 2px var(--focus);
}

/* A toast goes away on a short fall and comes back on a spring. */
.toast {
  transition:
    opacity 0.2s ease,
    transform var(--move-ms) var(--move),
    visibility 0s linear 0s;
}
.toast[data-away] {
  opacity: 0;
  visibility: hidden;
  transform: translateY(10px) scale(0.95);
  transition:
    opacity 0.18s ease,
    transform 0.24s cubic-bezier(0.4, 0, 1, 1),
    visibility 0s linear 0.24s;
}

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

/* A stage narrower than the bulk bar: the bulk bar keeps its icons, and the messages wrap. */
@container (max-width: 649px) {
  .stage {
    height: 480px;
  }
  .sheet {
    gap: 24px;
    padding-top: 150px;
  }
  .selectedWord,
  .bulkRow .label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .bulkRow .segment {
    gap: 6px;
    padding: 0 12px;
  }
  .bulkRow .count {
    padding-left: 15px;
  }
  .bulkRow .chevron {
    margin-left: 0;
  }
  .notice {
    gap: 12px;
  }
  .message,
  .notice .message {
    flex: 1 1 auto;
    min-width: 0;
    padding-top: 7px;
    padding-bottom: 7px;
    white-space: normal;
  }
  .notice .message .icon {
    display: none;
  }
}

/* A phone's width: tighter segments and margins. */
@container (max-width: 380px) {
  .stage {
    height: 500px;
  }
  .sheet {
    gap: 18px;
    padding-right: 8px;
    padding-left: 8px;
  }
  .bulkRow .segment {
    padding: 0 9px;
  }
  .bulkRow .count {
    padding-left: 12px;
  }
  .message {
    gap: 9px;
    padding-right: 10px;
    padding-left: 13px;
  }
  .notice .message {
    padding-left: 14px;
  }
  .undo {
    padding: 0 13px 0 11px;
  }
}

/* Browsers without linear() easings get a close curve. */
@supports not (transition-timing-function: linear(0, 1)) {
  .stage {
    --move: cubic-bezier(0.3, 1.3, 0.5, 1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .row,
  .action,
  .action::after,
  .chevron,
  .swap > span,
  .column,
  .menu,
  .menu[data-open],
  .explore,
  .toast,
  .toast[data-away] {
    transition: none;
  }
}
```

### action-bar/rolling-number.tsx

```tsx
import styles from './action-bar.module.css';

/*
 * A number whose digits roll to their new values, like an odometer. Each digit is a column of
 * 0 to 9 seen through a window one digit tall; a change slides the column (a transform, on a
 * spring set in the stylesheet). The digits are drawn with tabular figures, so the number keeps
 * its width while they roll. Screen readers get the plain number from the bar's own label.
 */

const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];

export function RollingNumber({ value }: { value: number }) {
  const digits = String(Math.max(0, Math.round(value))).split('');
  return (
    <span className={styles.rolling} aria-hidden="true">
      {digits.map((digit, i) => (
        // Keyed from the right, so a number that grows a digit keeps its ones in place.
        <span key={digits.length - i} className={styles.digit}>
          <span className={styles.digitSize}>0</span>
          <span
            className={styles.column}
            style={{ transform: `translateY(${-Number(digit) * 10}%)` }}
          >
            {DIGITS.map((d) => (
              <span key={d}>{d}</span>
            ))}
          </span>
        </span>
      ))}
    </span>
  );
}
```

### action-bar/icons.tsx

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

/*
 * The bars' icons: 16 × 16 line drawings with a 1.5 stroke and round ends, in the colour of the
 * text around them. They never change once drawn (a style change inside an SVG lays it out
 * again): to switch one for another, the bar fades the HTML boxes around them.
 */

function Icon({ children }: { children: ReactNode }) {
  return (
    <svg
      viewBox="0.6 0.6 14.8 14.8"
      width="16"
      height="16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {children}
    </svg>
  );
}

/** A ring with a pen across it: change a status. */
export function StatusIcon() {
  return (
    <Icon>
      <path d="M13.2 7.6A5.4 5.4 0 1 1 8.4 2.6" />
      <path d="m6.7 10.1.5-2.3 4.9-4.9a1.15 1.15 0 0 1 1.7 1.6L8.9 9.4z" />
    </Icon>
  );
}

export function ChevronUpIcon() {
  return (
    <Icon>
      <path d="m4.5 9.5 3.5-3 3.5 3" />
    </Icon>
  );
}

/** Two sheets: the copy at the top right, the edge of the one under it at the bottom left. */
export function DuplicateIcon() {
  return (
    <Icon>
      <rect x="6.2" y="2.3" width="6.6" height="7.9" rx="1.6" />
      <path d="M3.3 6.2v5.8a1.7 1.7 0 0 0 1.7 1.7h5" />
    </Icon>
  );
}

/** An arrow down into a tray. */
export function ExportIcon() {
  return (
    <Icon>
      <path d="M8 2.6v7.7M4.9 7.2l3.1 3.1 3.1-3.1M2.2 10v2.2a1.2 1.2 0 0 0 1.2 1.2h9.2a1.2 1.2 0 0 0 1.2-1.2V10" />
    </Icon>
  );
}

export function TrashIcon() {
  return (
    <Icon>
      <path d="M2.2 4.2h11.6M6 4.2V3.1c0-.4.3-.7.7-.7h2.6c.4 0 .7.3.7.7v1.1M4.2 4.2l.9 8.4c.1.6.5.9 1 .9h3.8c.5 0 .9-.3 1-.9l.9-8.4" />
    </Icon>
  );
}

/** A stop sign with an exclamation mark: something to know. */
export function AlertIcon() {
  return (
    <Icon>
      <path d="M5.6 1.9h4.8l3.7 3.7v4.8l-3.7 3.7H5.6l-3.7-3.7V5.6z" />
      <path d="M8 5v3.7M8 11v.01" />
    </Icon>
  );
}

export function CheckIcon() {
  return (
    <Icon>
      <path d="m3.4 8.4 3 3 6.2-6.7" />
    </Icon>
  );
}

/** The statuses' marks, for the menu. */
export function StatusMark({ status }: { status: 'backlog' | 'progress' | 'done' | 'canceled' }) {
  return (
    <Icon>
      {status === 'backlog' && <circle cx="8" cy="8" r="5.5" strokeDasharray="2.4 2.2" />}
      {status === 'progress' && (
        <>
          <circle cx="8" cy="8" r="5.5" />
          <path d="M8 4.8a3.2 3.2 0 0 1 0 6.4z" fill="currentColor" stroke="none" />
        </>
      )}
      {status === 'done' && (
        <>
          <circle cx="8" cy="8" r="5.5" />
          <path d="m5.7 8.2 1.6 1.6 3-3.3" />
        </>
      )}
      {status === 'canceled' && (
        <>
          <circle cx="8" cy="8" r="5.5" />
          <path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
        </>
      )}
    </Icon>
  );
}
```

### action-bar/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### action-bar/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 and press it, calling 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.
 *
 * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, and
 * the press scales a box that keeps a transform at rest (a box that gains or loses one is laid
 * out again). The arrow is an SVG drawing whose style never changes, inside HTML boxes.
 */

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

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

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
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.94 };

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

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    // Screen pixels to the stage's own, should the stage 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.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    // The arrow 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);
  };

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

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

/** The pointer'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 * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

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

/** The arrow the demo moves around. It is decorative, and invisible while no demo plays. */
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>
  );
}
```

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