# Row editor

> A table row that lifts into a form in place: its values glide into the fields, and back into the row when you press Done.

A small table of expenses with a pencil on each row. Edit one and it lifts into three labelled fields while the other rows dim and blur, its values gliding from their cells into the fields. Done writes them back: the values glide home as the form settles into a row, and the total updates.

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

Category: Data.

## Anatomy

1. **Caption**: Names the table and gives it its accessible name.
2. **Table**: A card on a dotted stage, with a head, the rows and a total. It stays centred, so an opening row grows it up and down.
3. **Column head**: Icon and name for each column. It dims while a row is open.
4. **Row**: A line of cells and one tab stop for the whole table. While another row is open, its text blurs into a soft shadow.
5. **Edit button**: A pencil at the end of each row. Enter or F2 on the row does the same.
6. **Open row**: Lifts into a card of its own; its values glide from their cells into the fields on a spring.
7. **Field**: A label with its icon, and a framed input. The frames fade in a few ms apart, behind the values.
8. **Done**: Writes the fields back and closes the row; Cancel, beside it, leaves the row as it was.

## When to use

- A short table of records, each with a few fields, where people fix one row at a time.
- Edits that should keep their context: the other rows stay in view, dimmed, while one is open.
- Two to four fields a row, short text or numbers, saved together with Done.
- Keyboard-heavy lists: arrow keys move between rows, Enter opens one, Escape backs out.

## When not to use

- Spreadsheet-like editing, cell by cell across many columns. Use a data grid with inline cells.
- Records with many fields or long text. Open them in a side panel or a page of their own.
- Tables of hundreds of rows: the lift and the blur are made for a few dozen at most.
- Changes that must be checked by someone else before they count. Use a form with a review step.
- A single value to change, such as a price. An inline field in the cell is lighter.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/row-editor.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
'use client';

import { RowEditor } from './row-editor';
import { EXPENSES } from './expenses';

export function Budget() {
  return (
    <RowEditor
      label="Monthly expenses"
      expenses={EXPENSES}
      autoplay={false}
      onChange={(rows) => console.log(rows)}
    />
  );
}
```

## States

- **At rest**: Rows of cells, a pencil on each, the total under them.
- **Row in focus**: Reached with Tab or the arrow keys: a blue ring round the row. Enter opens it.
- **Editing**: One row lifted into its form; the head and total dim, the other rows blur and turn inert.
- **Amount selected**: The field in focus wears a ring; its text selected, ready to be typed over.
- **Typing**: A new amount being typed, with the caret after it. Enter saves, Escape cancels.
- **Narrow**: Under 480 px wide the method moves under the name, and the labels take a narrower column.
- **Dark**: With `colorScheme="dark"`, or a dark page: each surface a step lighter than the one under it.

## How it works

- Each row holds two layers, its line of cells and its form. The one in use sets the row’s height; the other lies over it, hidden.
- Opening and saving are FLIP: each value is measured on screen, then the element that takes its place (a field, or a cell) plays from that spot and size to its own, with the Web Animations API.
- The row’s height animates on the same spring. The table stays centred, so it grows as much upwards as downwards, and each value’s own move leaves out the part the table already carries.
- The labels, the fields’ frames and the buttons fade in behind the values, a few milliseconds apart. A new total arrives with a little blur.
- The other rows dim and blur without a filter: their text turns transparent with a blurred text-shadow, painted with the text, so no row needs a compositing layer of its own.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop, with no script involved. At night depth comes from surfaces a step lighter each (stage, table, lifted row) and faint hairlines rather than shadows.

## API reference

- **expenses** (`Expense[]`, default `EXPENSES`): The rows: an id, what the expense was for (name), how it was paid (method) and its amount in dollars.
- **label** (`string`, default `'Monthly expenses'`): The table’s caption and accessible name.
- **defaultState** (`RowEditorState`): Opens the table mid-edit: editing (the row open in its form, by id), draft (what its fields hold), typing (the amount 'selected' or 'typing') and focus (the row drawn with the focus ring). Setting it turns the demo off.
- **onChange** (`(expenses: Expense[]) => void`): Called with all the rows after a save.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo cursor edits an amount, then puts it back.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The table is a grid with one tab stop: the arrow keys, Home and End move between rows, and Enter or F2 opens the focused row. The pencils are labelled buttons (“Edit Market run”).
- The open row is a labelled form: Enter in a field saves, Escape cancels, and focus returns to the row. The other rows are inert while it is open.
- Saves are announced, for example “Saved Market run: Cash, $58.”
- Under reduced motion the row opens and closes at once, without travel or fades, and the demo does not play.

## Source

### row-editor/row-editor.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentType,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { DemoCursor, playDemo } from './demo-cursor';
import { EXPENSES, money, type Expense } from './expenses';
import { CardIcon, CheckIcon, CloseIcon, CoinIcon, PencilIcon, ReceiptIcon } from './icons';
import { easingOf, spring } from './spring';
import { useAutoplay } from './use-autoplay';
import styles from './row-editor.module.css';

/*
 * Row editor: a table of expenses whose rows open into a form in place. Press a row's pencil, or
 * Enter on the row, and it lifts into three labelled fields while the other rows dim and blur;
 * its values glide from their cells into the fields. Done writes them back, and they glide home
 * as the form settles into a row again.
 *
 * - Each row holds two layers, its line of cells and its form. The one in use sets the row's
 *   height; the other lies over it, hidden.
 * - The values travel by FLIP: each one is measured on screen before the switch, then the one
 *   that takes its place after it, which plays from the old place (and size) to its own on a
 *   spring. The row's height follows on the same spring; the table stays centred, so it grows
 *   as much upwards as downwards. A switch that interrupts another starts from where everything
 *   is on screen.
 * - The labels, the fields' frames and the buttons fade in behind the values, a few ms apart.
 * - Keyboard: the arrow keys move between rows, Enter edits, Enter in a field saves, Escape
 *   cancels. Saves are announced to screen readers.
 * - While on screen and untouched, a demo cursor edits an amount on its own.
 * - Its colours are light-dark() pairs in the stylesheets: it follows the page's color-scheme, or
 *   the one the colorScheme prop sets. No colour is read or computed here.
 */

type Key = 'name' | 'method' | 'amount';
type Draft = Record<Key, string>;

/** A state to open in, held still: the demo doesn't play. */
export type RowEditorState = {
  /** The row open in its form, by id. */
  editing?: string;
  /** What the open row's fields hold, in place of its values. */
  draft?: Partial<Draft>;
  /** The amount as the demo types it: selected, or with a caret after it. */
  typing?: 'selected' | 'typing';
  /** The row drawn with the keyboard's focus ring, by id. */
  focus?: string;
};

const FIELDS: { key: Key; label: string; Icon: ComponentType<{ className?: string }> }[] = [
  { key: 'name', label: 'Expense', Icon: ReceiptIcon },
  { key: 'method', label: 'Method', Icon: CardIcon },
  { key: 'amount', label: 'Amount', Icon: CoinIcon },
];

/** The lift and the settle: a spring with a little bounce. */
const MOVE = spring(0.15, 520);

/** The row the demo edits, and how much it adds to the amount (then takes off again). */
const DEMO_ROW = 2;
const DEMO_STEP = 16;

type DemoAct =
  | { type: 'open'; id: string }
  | { type: 'select' }
  | { type: 'type'; text: string }
  | { type: 'save' }
  | { type: 'cancel' };

/** Where a value is on screen, and how big its text is. */
type Spot = { rect: DOMRect; font: number };

const toDraft = (expense: Expense): Draft => ({
  name: expense.name,
  method: expense.method,
  amount: String(expense.amount),
});

/** The draft, written back: a blank or unreadable field keeps what it had. */
function fromDraft(draft: Draft, was: Expense): Expense {
  const amount = parseFloat(draft.amount.replace(/[^\d.]/g, ''));
  return {
    ...was,
    name: draft.name.trim() || was.name,
    method: draft.method.trim() || was.method,
    amount: Number.isFinite(amount) ? Math.round(amount * 100) / 100 : was.amount,
  };
}

/** The values a row shows in one of its layers: the cells, or the fields. */
const valuesIn = (row: HTMLElement, layer: 'line' | 'form') =>
  Array.from(row.querySelectorAll<HTMLElement>(`[data-layer='${layer}'] [data-travel]`));

export function RowEditor({
  expenses = EXPENSES,
  label = 'Monthly expenses',
  onChange,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The rows: what each expense was for, how it was paid and how much it came to. */
  expenses?: Expense[];
  /** The table's caption, and its accessible name. */
  label?: string;
  /** Called with all the rows once an edit is saved. */
  onChange?: (expenses: Expense[]) => void;
  /** While on screen and untouched, a demo cursor edits an amount on its own. */
  autoplay?: boolean;
  /**
   * Its colours: light, dark, or 'system' for the reader's setting. Unset, it follows the page's
   * color-scheme (light if the page sets none).
   */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in a state (a row open, its fields typed in, a row in focus) and turns the demo off. */
  defaultState?: RowEditorState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [rows, setRows] = useState(expenses);
  /** The row open in a form, by id. */
  const startRow = expenses.find((row) => row.id === defaultState?.editing);
  const [editing, setEditing] = useState<string | null>(startRow?.id ?? null);
  const [draft, setDraft] = useState<Draft>(() => ({
    ...(startRow ? toDraft(startRow) : { name: '', method: '', amount: '' }),
    ...defaultState?.draft,
  }));
  /** The row that takes the keyboard focus: one tab stop for the whole table. */
  const [active, setActive] = useState(() =>
    Math.max(
      0,
      expenses.findIndex((row) => row.id === (defaultState?.focus ?? defaultState?.editing)),
    ),
  );
  /** The demo's typing in the amount: its text selected first, then a caret. */
  const [demoTyping, setDemoTyping] = useState<'selected' | 'typing' | null>(
    (startRow && defaultState?.typing) || null,
  );
  /** The focus ring drawn by defaultState, until the reader moves the focus. */
  const [heldFocus, setHeldFocus] = useState(defaultState?.focus ?? null);
  /** Set once an amount has changed: from then on, a new total arrives with a little blur. */
  const [totalChanged, setTotalChanged] = useState(false);
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const labelId = useId();
  const hintId = useId();
  const total = Math.round(rows.reduce((sum, row) => sum + row.amount, 0) * 100) / 100;

  const tableElement = useRef<HTMLDivElement>(null);
  const rowElements = useRef(new Map<string, HTMLDivElement>());
  const cursor = useRef<HTMLSpanElement>(null);
  /** Just before a switch: the table's top, the row's height and its values on screen. */
  const before = useRef<{
    id: string;
    top: number;
    height: number;
    spots: Map<string, Spot>;
  } | null>(null);
  const running = useRef<Animation[]>([]);
  /** Where focus goes once a switch the reader made has rendered. */
  const focusNext = useRef<{ id: string; on: 'row' | 'field' } | null>(null);

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

  /** Opens row `next` (or closes the open one, with null), after noting where its values are. */
  const switchTo = (next: string | null) => {
    const id = next ?? editing;
    const row = id ? rowElements.current.get(id) : undefined;
    const table = tableElement.current;
    if (id && row && table && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      const spots = new Map<string, Spot>();
      for (const value of valuesIn(row, next ? 'line' : 'form')) {
        spots.set(value.dataset.travel ?? '', {
          rect: value.getBoundingClientRect(),
          font: parseFloat(getComputedStyle(value).fontSize),
        });
      }
      before.current = {
        id,
        top: table.getBoundingClientRect().top,
        height: row.getBoundingClientRect().height,
        spots,
      };
      for (const animation of running.current) animation.cancel();
      running.current = [];
    }
    setEditing(next);
  };

  // The FLIP: the row grows or shrinks to its new height, and each value that took over plays
  // from where the one before it was. The table stays centred on the stage, so as the row's
  // height changes the table glides by half as much, carrying the values with it: their own
  // move leaves that part out.
  useLayoutEffect(() => {
    const from = before.current;
    before.current = null;
    const row = from ? rowElements.current.get(from.id) : undefined;
    const table = tableElement.current;
    if (from && row && table) {
      // Everything is measured in the new layout first: the row's height animation, once it
      // starts, moves the table back.
      const box = row.getBoundingClientRect();
      // Screen pixels to the component's own, should it sit in a scaled container.
      const scale = box.width / row.offsetWidth || 1;
      const shift = (from.top - table.getBoundingClientRect().top) / scale;
      const moves = valuesIn(row, editing === from.id ? 'form' : 'line').flatMap((value) => {
        const was = from.spots.get(value.dataset.travel ?? '');
        if (!was) return [];
        const now = value.getBoundingClientRect();
        const size = was.font / parseFloat(getComputedStyle(value).fontSize);
        const x = (was.rect.left - now.left) / scale;
        const y = (was.rect.top + was.rect.height / 2 - (now.top + now.height / 2)) / scale - shift;
        return [{ value, start: `translate(${x}px, ${y}px) scale(${size})` }];
      });
      const timing = { duration: MOVE.duration, easing: easingOf(MOVE) };
      running.current.push(
        row.animate(
          [{ height: `${from.height / scale}px` }, { height: `${box.height / scale}px` }],
          timing,
        ),
        ...moves.map(({ value, start }) =>
          value.animate([{ transform: start }, { transform: 'none' }], timing),
        ),
      );
    }
    const target = focusNext.current;
    focusNext.current = null;
    if (target) {
      const element = rowElements.current.get(target.id);
      if (target.on === 'row') element?.focus();
      else {
        const field = element?.querySelector<HTMLInputElement>("[data-layer='form'] input");
        field?.focus();
        field?.select();
      }
    }
  }, [editing]);

  /** Opens a row in its form; a reader's open puts focus in its first field. */
  const open = (id: string, byReader: boolean) => {
    const expense = rows.find((row) => row.id === id);
    if (editing || !expense) return;
    setDraft(toDraft(expense));
    setActive(rows.indexOf(expense));
    if (byReader) focusNext.current = { id, on: 'field' };
    switchTo(id);
  };

  const save = (byReader: boolean) => {
    const was = rows.find((row) => row.id === editing);
    if (!was) return;
    const next = fromDraft(draft, was);
    const updated = rows.map((row) => (row.id === was.id ? next : row));
    setRows(updated);
    setDemoTyping(null);
    if (next.amount !== was.amount) setTotalChanged(true);
    onChange?.(updated);
    if (byReader) {
      focusNext.current = { id: was.id, on: 'row' };
      announce(`Saved ${next.name}: ${next.method}, $${money(next.amount)}.`);
    }
    switchTo(null);
  };

  const cancel = (byReader: boolean) => {
    if (!editing) return;
    setDemoTyping(null);
    if (byReader) {
      focusNext.current = { id: editing, on: 'row' };
      announce('Edit cancelled.');
    }
    switchTo(null);
  };

  const onRowKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number) => {
    // Keys pressed in the form are the form's.
    if (event.target !== event.currentTarget) return;
    const last = rows.length - 1;
    const moves: Record<string, number> = {
      ArrowDown: Math.min(last, index + 1),
      ArrowUp: Math.max(0, index - 1),
      Home: 0,
      End: last,
    };
    if (event.key in moves) {
      event.preventDefault();
      setActive(moves[event.key]);
      rowElements.current.get(rows[moves[event.key]].id)?.focus();
    } else if (event.key === 'Enter' || event.key === 'F2') {
      event.preventDefault();
      open(rows[index].id, true);
    }
  };

  // The demo: the cursor opens a row, types a new amount and presses Done; next time, it puts
  // the amount back.
  const readState = useEffectEvent(() => ({ rows, editing }));
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'open') open(act.id, false);
    else if (act.type === 'select') setDemoTyping('selected');
    else if (act.type === 'type') {
      setDraft((now) => ({ ...now, amount: act.text }));
      setDemoTyping('typing');
    } else if (act.type === 'save') save(false);
    else cancel(false);
  });
  const demo = autoplay && !defaultState && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const arrow = cursor.current;
    if (!demo || !stage || !arrow) return;
    let base: number | null = null;
    const stop = playDemo(stage, arrow, async ({ wait, moveTo, press, leave }) => {
      for (let turn = 0; ; turn += 1) {
        await wait(turn ? 2000 : 1000);
        const state = readState();
        // Left open by a reader: cancel it first.
        if (state.editing) {
          const cancelButton = rowElements.current
            .get(state.editing)
            ?.querySelector("[data-layer='form'] button[type='button']");
          if (!cancelButton) return;
          await moveTo(cancelButton);
          await press(cancelButton, () => demoAct({ type: 'cancel' }));
          await wait(1100);
        }
        const expense = state.rows[Math.min(DEMO_ROW, state.rows.length - 1)];
        const row = expense && rowElements.current.get(expense.id);
        const pencil = row?.querySelector("[data-layer='line'] button");
        const amount = row?.querySelector<HTMLElement>(
          "[data-layer='form'] [data-travel='amount']",
        );
        const done = row?.querySelector("[data-layer='form'] button[type='submit']");
        if (!pencil || !amount || !done) return;
        base ??= expense.amount;
        const text = String(expense.amount === base ? base + DEMO_STEP : base);
        await moveTo(pencil);
        await press(pencil, () => demoAct({ type: 'open', id: expense.id }));
        await wait(700);
        // Over the start of the amount, where the text is.
        await moveTo(amount, { x: 20 - amount.offsetWidth / 2, y: 2 });
        await press(amount, () => demoAct({ type: 'select' }));
        await wait(380);
        for (let length = 1; length <= text.length; length += 1) {
          demoAct({ type: 'type', text: text.slice(0, length) });
          await wait(160);
        }
        await wait(380);
        await moveTo(done);
        await press(done, () => demoAct({ type: 'save' }));
        await wait(300);
        await leave();
      }
    });
    return () => {
      stop();
      setDemoTyping(null);
    };
  }, [demo, root]);

  useEffect(() => () => running.current.forEach((animation) => animation.cancel()), []);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': MOVE.easing, '--spring-ms': `${MOVE.duration}ms` } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={() => {
        setFocused(true);
        // What defaultState held (a focus ring, the demo's typing) gives way to the reader.
        setHeldFocus(null);
        setDemoTyping(null);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && editing) {
          event.preventDefault();
          cancel(true);
        }
      }}
    >
      <div className={styles.body}>
        <span id={labelId} className={styles.caption} data-part="caption">
          {label}
        </span>
        <div
          ref={tableElement}
          className={styles.table}
          role="grid"
          data-part="table"
          aria-labelledby={labelId}
          aria-describedby={hintId}
          data-editing={editing ? '' : undefined}
        >
          <div className={`${styles.grid} ${styles.head}`} role="row" data-part="head">
            {FIELDS.map(({ key, label: heading, Icon }) => (
              <span key={key} className={styles.heading} data-column={key} role="columnheader">
                <Icon className={styles.icon} />
                {heading}
              </span>
            ))}
            <span role="columnheader">
              <span className={styles.hidden}>Edit</span>
            </span>
          </div>

          {rows.map((row, index) => {
            const isOpen = editing === row.id;
            const values = isOpen ? draft : toDraft(row);
            return (
              <div
                key={row.id}
                ref={(element) => {
                  if (!element) return;
                  rowElements.current.set(row.id, element);
                  return () => {
                    rowElements.current.delete(row.id);
                  };
                }}
                className={styles.row}
                role="row"
                tabIndex={index === active ? 0 : -1}
                data-part="row"
                data-editing={isOpen || undefined}
                data-focus-ring={heldFocus === row.id || undefined}
                inert={editing !== null && !isOpen}
                onFocus={(event) => {
                  if (event.target === event.currentTarget) setActive(index);
                }}
                onKeyDown={(event) => onRowKeyDown(event, index)}
              >
                <div
                  className={`${styles.grid} ${styles.line}`}
                  data-layer="line"
                  role="none"
                  inert={isOpen}
                >
                  <span className={styles.name} role="gridcell">
                    <span className={styles.value} data-travel="name">
                      {row.name}
                    </span>
                  </span>
                  <span className={styles.method} role="gridcell">
                    <span className={styles.value} data-travel="method">
                      {row.method}
                    </span>
                  </span>
                  <span className={styles.amount} role="gridcell">
                    <span className={styles.currency}>$</span>
                    <span className={styles.value} data-travel="amount">
                      {money(row.amount)}
                    </span>
                  </span>
                  <span className={styles.editCell} role="gridcell">
                    <button
                      type="button"
                      className={styles.edit}
                      data-part="edit"
                      tabIndex={-1}
                      aria-label={`Edit ${row.name}`}
                      onClick={() => open(row.id, true)}
                    >
                      <PencilIcon />
                    </button>
                  </span>
                </div>

                <div
                  className={styles.formCell}
                  data-layer="form"
                  role="gridcell"
                  aria-colspan={4}
                  inert={!isOpen}
                >
                  <form
                    className={styles.form}
                    data-part="form"
                    aria-label={`Edit ${row.name}`}
                    onSubmit={(event) => {
                      event.preventDefault();
                      save(true);
                    }}
                  >
                    {FIELDS.map(({ key, label: name, Icon }, order) => (
                      <label
                        key={key}
                        className={styles.field}
                        data-part="field"
                        style={{ '--order': order } as CSSProperties}
                      >
                        <span className={styles.fieldLabel} data-part="label">
                          <Icon className={styles.icon} />
                          {name}
                        </span>
                        <span
                          className={styles.control}
                          data-part="control"
                          data-demo={(isOpen && key === 'amount' && demoTyping) || undefined}
                        >
                          {key === 'amount' && (
                            <span className={styles.prefix} aria-hidden="true">
                              $
                            </span>
                          )}
                          <span
                            className={styles.entry}
                            style={{ '--chars': values[key].length } as CSSProperties}
                          >
                            <input
                              className={styles.input}
                              data-travel={key}
                              name={key}
                              value={values[key]}
                              inputMode={key === 'amount' ? 'decimal' : undefined}
                              enterKeyHint="done"
                              autoComplete="off"
                              spellCheck={false}
                              onChange={(event) =>
                                setDraft((now) => ({ ...now, [key]: event.target.value }))
                              }
                            />
                            {key === 'amount' && (
                              <>
                                <span className={styles.selection} aria-hidden="true" />
                                <span className={styles.caret} aria-hidden="true" />
                              </>
                            )}
                          </span>
                        </span>
                      </label>
                    ))}
                    <div className={styles.buttons}>
                      <button
                        type="button"
                        className={styles.cancel}
                        data-part="cancel"
                        onClick={() => cancel(true)}
                      >
                        <CloseIcon className={styles.buttonIcon} />
                        Cancel
                      </button>
                      <button type="submit" className={styles.done} data-part="done">
                        <CheckIcon className={styles.buttonIcon} />
                        Done
                      </button>
                    </div>
                  </form>
                </div>
              </div>
            );
          })}

          <div className={`${styles.grid} ${styles.total}`} role="row" data-part="total">
            <span role="rowheader">Total</span>
            <span role="gridcell" />
            <span className={styles.amount} role="gridcell">
              <span className={styles.currency}>$</span>
              <span key={total} className={totalChanged ? styles.arrive : undefined}>
                {money(total)}
              </span>
            </span>
            <span role="gridcell" />
          </div>
        </div>
        <p id={hintId} className={styles.hidden}>
          Arrow keys move between expenses; Enter edits one.
        </p>
      </div>
      <DemoCursor ref={cursor} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### row-editor/row-editor.module.css

```css
/* Row editor (row-editor.tsx): a light stage with the table on a white card, or at night a
   near-black stage with the table on a dark grey one. A row open in its form carries data-editing,
   and so does the table while one is.

   Colours are the tokens below, each 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). */

.root {
  /* The stage, the table on it, and the row lifted out of the table: a step lighter each at night,
     where shadows hardly show. */
  --surface: #e4e4e7;
  --surface-2: #fff;
  --surface-3: #fff;
  /* Names, amounts and the caption; methods and Cancel; headings, labels and the total's name;
     icons, $ signs and the pencils. */
  --ink: #1d1d1f;
  --ink-soft: #48484a;
  --ink-muted: #6e6e73;
  --ink-faint: #aeaeb2;
  /* While a row is open: the head and the total, the other rows' pencils, and the soft shadow
     their text turns into. */
  --ink-dim: #cdcdd2;
  --ink-dimmer: #dedee2;
  --ink-blur: rgb(29 29 31 / 0.38);
  /* The lines between rows; the stage's edge and the lifted row's; the stage's dots; the fields'
     frames; the table's edge (none on light). */
  --line: #efeff1;
  --edge: rgb(0 0 0 / 0.06);
  --dots: rgb(0 0 0 / 0.055);
  --field-edge: #e0e0e5;
  --table-edge: transparent;
  /* A pencil under the pointer and pressed; Cancel, and Cancel under the pointer. */
  --fill: #f2f2f4;
  --fill-press: #ebebee;
  --fill-hover: #eaeaed;
  /* Done, its label, and Done under the pointer. */
  --accent: #1d1d1f;
  --accent-ink: #fff;
  --accent-hover: #3a3a3c;
  /* Focus rings and the demo's caret, the glow round a field's ring, and selected text. */
  --focus: #0a84ff;
  --focus-glow: rgb(10 132 255 / 0.16);
  --selection: rgb(10 132 255 / 0.22);
  /* The table's soft drop and the shade right under it; the lifted row's drop. */
  --shadow: rgb(0 0 0 / 0.14);
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow-lift: rgb(0 0 0 / 0.26);
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 15px/1.3 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e4e4e7, #111113);
    --surface-2: light-dark(#fff, #1c1c1e);
    --surface-3: light-dark(#fff, #262629);
    --ink: light-dark(#1d1d1f, #f2f2f4);
    --ink-soft: light-dark(#48484a, #c2c2c7);
    --ink-muted: light-dark(#6e6e73, #a1a1a6);
    --ink-faint: light-dark(#aeaeb2, #8e8e93);
    --ink-dim: light-dark(#cdcdd2, #444448);
    --ink-dimmer: light-dark(#dedee2, #36363a);
    --ink-blur: light-dark(rgb(29 29 31 / 0.38), rgb(242 242 244 / 0.26));
    --line: light-dark(#efeff1, #29292c);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --dots: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.05));
    --field-edge: light-dark(#e0e0e5, #3c3c41);
    --table-edge: light-dark(transparent, rgb(255 255 255 / 0.05));
    --fill: light-dark(#f2f2f4, rgb(255 255 255 / 0.08));
    --fill-press: light-dark(#ebebee, rgb(255 255 255 / 0.12));
    --fill-hover: light-dark(#eaeaed, rgb(255 255 255 / 0.12));
    --accent: light-dark(#1d1d1f, #f2f2f4);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --accent-hover: light-dark(#3a3a3c, #d4d4d8);
    --focus: light-dark(#0a84ff, #409cff);
    --focus-glow: light-dark(rgb(10 132 255 / 0.16), rgb(64 156 255 / 0.28));
    --selection: light-dark(rgb(10 132 255 / 0.22), rgb(64 156 255 / 0.36));
    --shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow-lift: light-dark(rgb(0 0 0 / 0.26), rgb(0 0 0 / 0.6));
  }
}
/* The colorScheme prop. Without it nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root button,
.root input {
  font: inherit;
}

/* The table stays centred: when a row opens, it grows by as much upwards as downwards. */
.body {
  --columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 0.72fr) 32px;
  --label: 112px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  min-height: 600px;
  padding: 24px 20px;
}
.caption {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--ink);
}

.table {
  width: 100%;
  max-width: 580px;
  padding: 4px 6px 6px;
  border-radius: 20px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--table-edge),
    0 1px 2px var(--shadow-near),
    0 12px 32px -16px var(--shadow);
}
.grid {
  display: grid;
  grid-template-columns: var(--columns);
  column-gap: 12px;
  align-items: center;
  padding: 0 8px 0 14px;
}
.icon {
  flex: none;
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--ink-faint);
}

.head {
  height: 42px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-muted);
}
.heading {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  white-space: nowrap;
}

/* A row: its line of cells and its form, one of them lying over the other, hidden. */
.row {
  position: relative;
  overflow: clip;
  border-radius: 0;
  box-shadow: inset 0 1px var(--line);
  outline: none;
  transition:
    border-radius 0.3s ease,
    background-color 0.3s ease,
    box-shadow 0.35s ease;
}
.row:focus-visible,
.row[data-focus-ring] {
  border-radius: 12px;
  box-shadow: inset 0 0 0 2px var(--focus);
}
/* Lifted: a card of its own over the table. */
.row[data-editing] {
  z-index: 1;
  border-radius: 14px;
  background: var(--surface-3);
  box-shadow:
    0 0 0 1px var(--edge),
    0 14px 34px -14px var(--shadow-lift);
}
/* While a row is open, the rest of the table dims and the other rows blur: their text turns into
   a soft shadow of itself. It is all colour, painted with the text; opacity or a filter would
   give each row a compositing layer of its own. */
.head,
.total,
.icon,
.amount,
.value {
  transition:
    color 0.35s ease,
    text-shadow 0.35s ease;
}
.table[data-editing] :is(.head, .total),
.table[data-editing] :is(.head, .total) :is(.icon, .amount, .currency) {
  color: var(--ink-dim);
}
.table[data-editing] .row:not([data-editing]) :is(.value, .currency) {
  color: transparent;
  text-shadow: 0 0 5px var(--ink-blur);
}
.table[data-editing] .row:not([data-editing]) .edit {
  color: var(--ink-dimmer);
}

/* The layer in use paints over the other, so the values travel over the parts fading out. */
.line,
.formCell {
  position: relative;
}
.row:not([data-editing]) .line,
.row[data-editing] .formCell {
  z-index: 1;
}
.row:not([data-editing]) .formCell,
.row[data-editing] .line {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  visibility: hidden;
  /* Hidden once their parts have faded. */
  transition: visibility 0s 0.25s;
}
/* The values hand over at once: the ones that take over travel from there. */
.row[data-editing] .line [data-travel],
.row:not([data-editing]) .formCell [data-travel] {
  visibility: hidden;
}
.value,
.input {
  transform-origin: left center;
}

.line {
  height: 50px;
}
.name,
.method,
.amount {
  display: flex;
  align-items: baseline;
  min-width: 0;
  white-space: nowrap;
}
.value {
  display: block;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.name {
  font-weight: 500;
}
.method {
  color: var(--ink-soft);
}
.amount {
  font-variant-numeric: tabular-nums;
}
.currency {
  margin-right: 1px;
  color: var(--ink-faint);
  transition:
    opacity 0.25s ease 0.12s,
    color 0.35s ease,
    text-shadow 0.35s ease;
}
/* The open row's currency and pencil leave quickly, and come back a beat after it closes. */
.row[data-editing] .currency,
.row[data-editing] .edit {
  opacity: 0;
  transition-duration: 0.12s;
  transition-delay: 0s;
}

.edit {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: none;
  color: var(--ink-faint);
  cursor: pointer;
  transition:
    opacity 0.25s ease 0.12s,
    background-color 0.2s ease,
    color 0.2s ease,
    transform 0.15s ease;
}
.edit svg {
  width: 16px;
  height: 16px;
  fill: currentColor;
}
@media (hover: hover) {
  .edit:hover {
    background: var(--fill);
    color: var(--ink);
  }
}
.edit:is(:active, [data-pressed]) {
  background: var(--fill-press);
  color: var(--ink);
  transform: scale(0.9);
}
.edit:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The total, under the rows: a new one arrives with a little blur. */
.total {
  height: 46px;
  box-shadow: inset 0 1px var(--line);
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-muted);
}
.total .amount {
  font-size: 15px;
  font-weight: 600;
  color: var(--ink);
}
.arrive {
  animation: arrive 0.5s var(--ease-out) 0.18s both;
}
@keyframes arrive {
  from {
    opacity: 0;
    filter: blur(4px);
    transform: translateY(6px);
  }
}

/* The form. */
.form {
  display: grid;
  gap: 8px;
  padding: 12px 12px 12px 14px;
}
.field {
  display: grid;
  grid-template-columns: var(--label) minmax(0, 1fr);
  align-items: center;
}
.fieldLabel {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: var(--ink-muted);
}
.control {
  position: relative;
  display: flex;
  align-items: center;
  height: 38px;
  padding: 0 12px;
}
/* The field's frame, which fades in with the form (by colour, see below), and its focus ring,
   which follows focus at once: both drawn round the value, not over it. */
.control::before,
.control::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 11px;
  pointer-events: none;
}
.control::before {
  background-color: var(--surface-3);
  box-shadow: inset 0 0 0 1px var(--field-edge);
}
.control::after {
  box-shadow:
    inset 0 0 0 1.5px transparent,
    0 0 0 3.5px transparent;
  transition: box-shadow 0.2s ease;
}
.control:focus-within::after,
.control[data-demo]::after {
  box-shadow:
    inset 0 0 0 1.5px var(--focus),
    0 0 0 3.5px var(--focus-glow);
}
/* As the form opens, the ring of the field in focus arrives with its frame. */
.row[data-editing] .control::after {
  animation: ring-in 0.3s ease calc(var(--order) * 45ms + 90ms) backwards;
}
@keyframes ring-in {
  from {
    box-shadow:
      inset 0 0 0 1.5px transparent,
      0 0 0 3.5px transparent;
  }
}
.prefix {
  position: relative;
  margin-right: 2px;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.entry {
  position: relative;
  display: flex;
  flex: 1;
  min-width: 0;
  height: 100%;
}
.input {
  flex: 1;
  min-width: 0;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: inherit;
  outline: none;
  appearance: none;
}
.input::selection {
  background: var(--selection);
}
.input[name='name'] {
  font-weight: 500;
}
.input[name='method'] {
  color: var(--ink-soft);
}
.input[name='amount'] {
  font-variant-numeric: tabular-nums;
}

/* The demo's typing: the amount selected first, then a caret after it. */
.selection,
.caret {
  position: absolute;
  top: 50%;
  left: 0;
  height: 1.35em;
  margin-top: -0.675em;
  border-radius: 3px;
  pointer-events: none;
  visibility: hidden;
}
.selection {
  width: calc(var(--chars) * 1ch + 1px);
  background: var(--selection);
}
.caret {
  left: calc(var(--chars) * 1ch + 1px);
  width: 2px;
  border-radius: 1px;
  background: var(--focus);
}
.control[data-demo='selected'] .selection,
.control[data-demo='typing'] .caret {
  visibility: visible;
}

.buttons {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 4px;
}
.cancel,
.done {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 34px;
  margin: 0;
  padding: 0 14px 0 11px;
  border: 0;
  border-radius: 11px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    transform 0.15s ease;
}
.cancel {
  background: var(--fill);
  color: var(--ink-soft);
}
.done {
  background: var(--accent);
  color: var(--accent-ink);
}
@media (hover: hover) {
  .cancel:hover {
    background: var(--fill-hover);
  }
  .done:hover {
    background: var(--accent-hover);
  }
}
.cancel:is(:active, [data-pressed]),
.done:is(:active, [data-pressed]) {
  transform: scale(0.96);
}
.cancel:focus-visible,
.done:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.buttonIcon {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The form's parts arrive behind the values, one after another; they leave quickly. The frames
   fade by colour, the small parts by opacity. */
.fieldLabel,
.prefix,
.buttons {
  transition:
    opacity 0.3s ease calc(var(--order, 3) * 45ms + 90ms),
    transform 0.45s var(--ease-out) calc(var(--order, 3) * 45ms + 90ms);
}
.control::before {
  transition:
    background-color 0.3s ease calc(var(--order) * 45ms + 90ms),
    box-shadow 0.3s ease calc(var(--order) * 45ms + 90ms);
}
.row:not([data-editing]) :is(.fieldLabel, .prefix, .buttons) {
  opacity: 0;
  transition-duration: 0.14s;
  transition-delay: 0s;
}
.row:not([data-editing]) .control::before {
  background-color: transparent;
  box-shadow: inset 0 0 0 1px transparent;
  transition-duration: 0.14s;
  transition-delay: 0s;
}
.row:not([data-editing]) .fieldLabel {
  transform: translateX(-6px);
}
.row:not([data-editing]) .buttons {
  transform: translateY(4px);
}

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

/* Narrow: the method goes under the name, and the labels get a narrower column. */
@container (max-width: 480px) {
  .body {
    --columns: minmax(0, 1fr) auto 32px;
    --label: 92px;
    padding: 20px 12px;
  }
  .total > :nth-child(2) {
    display: none;
  }
  .table {
    padding: 2px 4px 4px;
  }
  .grid {
    padding: 0 4px 0 12px;
  }
  .heading[data-column='method'] {
    display: none;
  }
  .line {
    grid-template-areas:
      'name amount edit'
      'method amount edit';
    height: 56px;
    align-content: center;
  }
  .line .name {
    grid-area: name;
  }
  .line .method {
    grid-area: method;
    font-size: 13px;
  }
  .line .amount {
    grid-area: amount;
  }
  .line .editCell {
    grid-area: edit;
  }
  .form {
    padding: 10px 8px 10px 12px;
  }
  .fieldLabel {
    font-size: 13px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .arrive,
  .row[data-editing] .control::after {
    animation: none;
  }
  .row,
  .head,
  .total,
  .icon,
  .amount,
  .value,
  .control::after,
  .currency,
  .edit,
  .fieldLabel,
  .control::before,
  .prefix,
  .buttons {
    transition: none;
  }
}
```

### row-editor/expenses.ts

```tsx
/*
 * The sample expenses: what each one was for, how it was paid and how much it came to.
 */

export type Expense = {
  id: string;
  /** What it was for. */
  name: string;
  /** How it was paid. */
  method: string;
  /** In dollars. */
  amount: number;
};

export const EXPENSES: Expense[] = [
  { id: 'studio', name: 'Studio rent', method: 'Transfer', amount: 940 },
  { id: 'train', name: 'Train pass', method: 'Debit card', amount: 96 },
  { id: 'market', name: 'Market run', method: 'Cash', amount: 42 },
  { id: 'phone', name: 'Phone plan', method: 'Credit card', amount: 35 },
  { id: 'climbing', name: 'Climbing gym', method: 'Credit card', amount: 68 },
];

/** 940 → "940", 1250 → "1,250", 42.5 → "42.50". */
export const money = (amount: number) =>
  amount.toLocaleString('en-US', {
    minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
    maximumFractionDigits: 2,
  });
```

### row-editor/icons.tsx

```tsx
/*
 * The row editor's icons, drawn on a 16 × 16 grid with a 1.5 stroke (the pencil is filled).
 */

type IconProps = { className?: string };

/** Expense: a receipt. */
export function ReceiptIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path d="M3.75 2.25h8.5v11.5l-1.7-1.1-1.4 1.1L8 12.65l-1.15 1.1-1.4-1.1-1.7 1.1z" />
      <path d="M6 5.75h4M6 8.5h2.5" />
    </svg>
  );
}

/** Method: a card. */
export function CardIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <rect x="2" y="3.5" width="12" height="9" rx="2" />
      <path d="M2 6.75h12M4.5 10h2.5" />
    </svg>
  );
}

/** Amount: a coin. */
export function CoinIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="5.75" />
      <path d="M9.6 6.1H7.35a1.1 1.1 0 0 0 0 2.2h1.3a1.1 1.1 0 0 1 0 2.2H6.4M8 4.9v1.2M8 10.5v1.2" />
    </svg>
  );
}

export function PencilIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <g transform="rotate(45 8 8)">
        <path d="M6.2 3.3a1.8 1.8 0 0 1 3.6 0v.9H6.2z" />
        <path d="M6.2 5.3h3.6v6.1L8 14.9l-1.8-3.5z" />
      </g>
    </svg>
  );
}

export function CloseIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path d="m4.5 4.5 7 7m0-7-7 7" />
    </svg>
  );
}

export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path d="m3.5 8.4 2.9 2.9 6.1-6.4" />
    </svg>
  );
}
```

### row-editor/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)';
}
```

### row-editor/demo-cursor.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './demo-cursor.module.css';

/*
 * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses
 * them, so the demo reads like someone using the component. Each glide is a CSS transition, one
 * per axis with a different easing, which bends the path into a gentle arc: nothing runs between
 * moves.
 */

export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">
      <span className={styles.vertical}>
        <span className={styles.ripple} />
        <svg className={styles.arrow} viewBox="0 0 20 20">
          <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" />
        </svg>
      </span>
    </span>
  );
}

export type DemoSteps = {
  /** Resolves after `ms`; never, once the demo has stopped. */
  wait: (ms: number) => Promise<void>;
  /** Glides the cursor to the middle of `target`, or `offset` from it (in the stage's px). */
  moveTo: (target: Element, offset?: { x: number; y: number }) => Promise<void>;
  /** Presses `target`: the cursor dips, the target shows itself pressed, `action` runs. */
  press: (target: Element, action: () => void) => Promise<void>;
  /** Moves the cursor off and fades it out. */
  leave: () => Promise<void>;
};

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

/**
 * Plays `script` on `stage` with the cursor. The function it returns stops the demo at once: the
 * step in progress never resolves, and the cursor fades away.
 */
export function playDemo(
  stage: HTMLElement,
  cursor: HTMLElement,
  script: (steps: DemoSteps) => Promise<void>,
) {
  let alive = true;
  let timer = 0;
  let pressed: Element | null = null;
  let at: Point = { x: 0, y: 0 };

  const wait = (ms: number) =>
    new Promise<void>((resolve) => {
      timer = window.setTimeout(() => {
        if (alive) resolve();
      }, ms);
    });

  /** A point on the screen, in the stage's own px (it may sit in a scaled container). */
  const toStage = (x: number, y: number): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (x - box.left) / scale, y: (y - box.top) / scale };
  };

  const place = (point: Point, ms: number) => {
    at = point;
    cursor.style.setProperty('--move', `${ms}ms`);
    cursor.style.setProperty('--x', `${point.x}px`);
    cursor.style.setProperty('--y', `${point.y}px`);
  };

  const moveTo = async (target: Element, offset: Point = { x: 0, y: 0 }) => {
    const box = target.getBoundingClientRect();
    const middle = toStage(box.left + box.width / 2, box.top + box.height / 2);
    const to = { x: middle.x + offset.x, y: middle.y + offset.y };
    if (cursor.dataset.state === 'hidden') {
      // Comes in from below and to the right of its first target, inside the stage.
      place(
        {
          x: Math.min(to.x + 70, stage.offsetWidth - 24),
          y: Math.min(to.y + 80, stage.offsetHeight - 24),
        },
        0,
      );
      // Applies that jump before the glide starts.
      cursor.getBoundingClientRect();
      cursor.dataset.state = 'shown';
    }
    // Longer trips take a little longer.
    const ms = Math.round(Math.min(900, Math.max(420, Math.hypot(to.x - at.x, to.y - at.y) * 2.2)));
    place(to, ms);
    await wait(ms + 40);
  };

  const press = async (target: Element, action: () => void) => {
    cursor.dataset.state = 'pressed';
    target.setAttribute('data-pressed', '');
    pressed = target;
    cursor.querySelector(`.${styles.ripple}`)?.animate(
      [
        { transform: 'scale(0.2)', opacity: 0.5 },
        { transform: 'scale(1)', opacity: 0 },
      ],
      { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
    await wait(130);
    action();
    cursor.dataset.state = 'shown';
    target.removeAttribute('data-pressed');
    pressed = null;
    await wait(120);
  };

  const leave = async () => {
    place({ x: at.x + 36, y: at.y + 44 }, 500);
    cursor.dataset.state = 'hidden';
    await wait(500);
  };

  void script({ wait, moveTo, press, leave });

  return () => {
    alive = false;
    window.clearTimeout(timer);
    cursor.dataset.state = 'hidden';
    pressed?.removeAttribute('data-pressed');
  };
}
```

### row-editor/demo-cursor.module.css

```css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The
   outer box carries the horizontal move, the inner one the vertical move, eased differently. */

.cursor {
  /* The arrow and its edge stay the system's on both schemes, dark and edged in white, so it reads
     on the dark surfaces and the light Done button alike; the ring where it presses turns light at
     night. */
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --pointer-ring: rgb(0 0 0 / 0.14);
  --x: 0px;
  --y: 0px;
  --move: 0ms;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--x));
  transition:
    opacity 0.25s ease,
    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);
}
@supports (color: light-dark(#fff, #000)) {
  .cursor {
    --pointer-ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.16));
  }
}
.cursor[data-state='shown'],
.cursor[data-state='pressed'] {
  opacity: 1;
}
.vertical {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.arrow path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ripple {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--pointer-ring);
  opacity: 0;
}
```

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