# Account setup

> A setup dialog whose steps fold open on fields you edit where they stand. Phone numbers and dates are laid out as you type.

A dialog of three steps, each with a badge that says where it stands. Open a step and its fields appear; press a value and it becomes a bordered input with a clear button and a check. Phone numbers lay themselves out as you type, dates fill a dd/mm/yyyy mask, and a step’s badge turns Completed once every field is filled.

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

Category: Inputs.

## Anatomy

1. **Dialog**: White blocks stacked from the header to the footer. Opening a step grows it from its middle: the panel is uncovered while the blocks below slide down.
2. **Title**: Names the dialog for screen readers too, with a line under it and a cross that closes it.
3. **Step**: A disclosure button: an icon in a ring, a name and a line. One step is open at a time.
4. **Status badge**: In Progress for the step to do next or one begun, Incomplete for the rest, Completed once every field is filled.
5. **Chevron**: Turns and fills with the ink while its step is open.
6. **Field row**: An icon, a label and the value. Pressing the value or the label opens the field.
7. **Open field**: A bordered input around the value’s own box, with a cross that clears it and a check that saves it. Enter saves, Escape cancels.
8. **Proceed**: Opens the first required step not done yet at its first empty field; once they are all done, it hands the values over.

## When to use

- Onboarding that asks for a profile in a few groups, where some groups are required and the rest can wait.
- Values the person mostly reads back and fixes one at a time, so each field opens in place instead of a whole form.
- Showing at a glance which steps are done: each step’s badge says In Progress, Incomplete or Completed.
- A setup that may be skipped and picked up later: closing it leaves a button that brings it back as it was.

## When not to use

- One or two fields. Ask for them inline, or with a plain form.
- Steps that depend on each other’s answers. Use a wizard that walks through them in order.
- Long free text or uploads: a row holds one line. Use a full form or a settings page.
- Anything that must be filled in before the product works at all and can’t be skipped. Use a blocking form with the reason on it.

## Installation

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

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

export function OnboardingDialog() {
  return (
    <AccountSetup
      autoplay={false}
      defaultOpen="required"
      onProceed={(values) => console.log('Saved', values)}
    />
  );
}
```

## States

- **Fresh**: Every step folded and empty: the first is In Progress, the others Incomplete.
- **Step open**: The first step folded open on its six empty fields, placeholders faded.
- **Editing**: A phone number laid out as it is typed: `(212) 555-01`.
- **Date mask**: What is left of dd/mm/yyyy shows, faded, after the digits typed so far.
- **Not whole yet**: A 31st of February keeps the field open, outlined in red; the hint goes to screen readers.
- **Required done**: The first step Completed; the next one is now In Progress.
- **All done**: Every step Completed. Proceed hands the values over and closes the dialog.
- **Closed**: After Skip, the cross or Proceed: a button with the title brings it back as it was.

## How it works

- Each step is a disclosure button with a badge: In Progress for the next step to do or one already begun, Incomplete for the others, and Completed once every field holds a whole value.
- A step opens with the dialog growing from its middle. The dialog is a stack of blocks, each on a layer of its own. Each frame, the panel is clipped to the part that is open and the blocks below move by the part that is closed, so no height is ever animated.
- A value and its input share one box, with the input hidden underneath until it opens. Opening a field therefore changes no markup, and no style ever reaches its icons.
- A phone number keeps its digits and lays them out as (123) 456-7894. A date shows what is left of its dd/mm/yyyy mask, faded, after the digits typed so far. A value that is not whole yet, such as nine digits or a 31st of February, keeps the field open with a red outline.
- Proceed opens the first required step that is not done, at its first empty field; once those steps are done, it passes every value on. Skip and the cross fold the dialog into a button that brings it back.
- Every colour is a light-dark() token on the root, so the dialog follows the page’s color-scheme, or the colorScheme prop. At night it is dark grey on a near-black stage, the editing ring turns light, the invalid ring a lighter red, and the demo’s I-beam turns light.

## API reference

- **sections** (`SetupSection[]`, default `SECTIONS`): The steps, each with its fields (sections.tsx). Proceed waits for the steps marked required.
- **title** (`string`, default `'Account Setup'`): The dialog’s title, which also names it for screen readers.
- **description** (`string`, default `'Complete simple steps to get started.'`): The line under the title.
- **defaultValues** (`Record<string, string>`, default `{}`): The values to start from, by field id.
- **defaultOpen** (`string | null`, default `null`): The id of the step open at first (required, profile or optional in the default steps), or null for none.
- **defaultEditing** (`{ field: string; draft?: string; invalid?: boolean } | null`, default `null`): A field open as an input at first, with its text so far (the saved value when left out) and whether it shows as not whole yet. Its step should be the one open. Turns the demo off.
- **defaultClosed** (`boolean`, default `false`): Starts closed, as the button that brings the dialog back. Turns the demo off.
- **onChange** (`(values: Record<string, string>) => void`): Called with every value each time a field is saved.
- **onProceed** (`(values: Record<string, string>) => void`): Called with every value when Proceed is pressed with all required steps done.
- **onClose** (`() => void`): Called when the dialog closes, by Skip, the cross or Proceed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer fills in most of the first step, then opens the other two in turn.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the stage.

## Accessibility

- Each step is a heading that holds a disclosure button with aria-expanded. Its panel is a region labelled by that button, and the badge is read as part of the button’s name.
- Enter on a value opens its input. Enter or the check saves, Escape cancels, and focus returns to the value.
- An input whose value is not whole is marked aria-invalid and points to a hint that says what to fix. Saves, completions, cancels and closing are announced in a polite live region.
- Under reduced motion, steps open and close at once and the demo never plays.

## Source

### account-setup/account-setup.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { FieldRow, type RowPart } from './field-row';
import { FOLD, opennessAt, playFold, type Fold, type FoldPlayer, type Openness } from './fold';
import { hintFor, isFilled, nextDraft } from './format';
import { GhostPointer, runDemo } from './ghost-pointer';
import { CloseIcon, InfoIcon, SetupIcon } from './icons';
import { DEMO_TEXT, SECTIONS, type SetupField, type SetupSection } from './sections';
import { useAutoplay } from './use-autoplay';
import styles from './account-setup.module.css';

/*
 * Account setup: a dialog of steps to fill in, each a section that folds open on a list of fields.
 *
 * - Each step shows where it stands in a badge: In Progress for the step to do next (or one begun),
 *   Incomplete for the rest, Completed once every field is filled. One step is open at a time.
 * - A step folds open with the dialog growing from its middle, played with transforms on layers
 *   (fold.ts): the panel is uncovered from the top while the blocks below it move down.
 * - A field's value turns into a bordered input where it stands (field-row.tsx). A phone number
 *   and a date are laid out as they are typed, the date over a fading dd/mm/yyyy mask (format.ts).
 *   Enter or the check saves, Escape cancels, the cross clears. A value that is not whole yet
 *   (nine digits of a phone, a 31st of February) keeps the field open, outlined in red.
 * - Proceed opens the first required step not done yet at its first empty field; once those are
 *   all done, it hands the values over. Skip and the cross close the dialog into a button that
 *   brings it back.
 * - While on screen and untouched, a ghost pointer opens the first step and fills it in.
 * - 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.
 */

export type SetupValues = Record<string, string>;

type Status = 'done' | 'progress' | 'todo';

/** The field being edited: its text so far, and who is typing it. */
type Editing = { field: string; draft: string; by: 'reader' | 'demo'; invalid: boolean };

/** What the demo does, one step at a time. */
type DemoAct =
  | { type: 'reset' }
  | { type: 'reopen' }
  | { type: 'toggle'; section: string }
  | { type: 'edit'; field: string }
  | { type: 'type'; text: string }
  | { type: 'commit' };

const STATUS_LABEL: Record<Status, string> = {
  done: 'Completed',
  progress: 'In Progress',
  todo: 'Incomplete',
};

const NO_VALUES: SetupValues = {};

const kindOf = (field: SetupField) => field.kind ?? 'text';
const filled = (field: SetupField, values: SetupValues) =>
  isFilled(kindOf(field), values[field.id] ?? '');

/** Each step's badge: the first step not done yet, or one begun, is in progress. */
function statusesOf(sections: SetupSection[], values: SetupValues) {
  const statuses: Record<string, Status> = {};
  let ahead = false;
  for (const section of sections) {
    const count = section.fields.filter((field) => filled(field, values)).length;
    const status: Status =
      count === section.fields.length ? 'done' : count > 0 || !ahead ? 'progress' : 'todo';
    statuses[section.id] = status;
    if (status !== 'done') ahead = true;
  }
  return statuses;
}

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

export function AccountSetup({
  sections = SECTIONS,
  title = 'Account Setup',
  description = 'Complete simple steps to get started.',
  defaultValues = NO_VALUES,
  defaultOpen = null,
  defaultEditing = null,
  defaultClosed = false,
  onChange,
  onProceed,
  onClose,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The steps, each with its fields (sections.tsx); `required` steps must be done to proceed. */
  sections?: SetupSection[];
  /** The dialog's title, which also names it for screen readers. */
  title?: string;
  /** The line under the title. */
  description?: string;
  /** The values to start from, by field id. */
  defaultValues?: SetupValues;
  /** The id of the step open at first, or none. */
  defaultOpen?: string | null;
  /**
   * A field open as an input at first, with its text so far (the saved value when left out) and
   * whether it is shown as not whole yet. Its step should be the one open. Turns the demo off.
   */
  defaultEditing?: { field: string; draft?: string; invalid?: boolean } | null;
  /** Starts closed, as the button that brings it back. Turns the demo off. */
  defaultClosed?: boolean;
  /** Called with every value each time a field is saved. */
  onChange?: (values: SetupValues) => void;
  /** Called with every value when Proceed is pressed with all required steps done. */
  onProceed?: (values: SetupValues) => void;
  /** Called when the dialog is closed, by Skip, the cross or Proceed. */
  onClose?: () => void;
  /** While on screen and untouched, a ghost pointer fills in the first step on its own. */
  autoplay?: boolean;
  /** Its colours: light, dark, or 'system' for the reader's setting; unset, it follows the page. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the stage. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [initialValues] = useState(defaultValues);
  const [values, setValues] = useState(defaultValues);
  const [open, setOpen] = useState(defaultOpen);
  /** The panels in the layout: the open one, and while a fold plays, the one it closes. */
  const [laidOut, setLaidOut] = useState<string[]>(defaultOpen ? [defaultOpen] : []);
  const [foldTurn, setFoldTurn] = useState(0);
  const [editing, setEditing] = useState<Editing | null>(() =>
    defaultEditing
      ? {
          field: defaultEditing.field,
          draft: defaultEditing.draft ?? defaultValues[defaultEditing.field] ?? '',
          by: 'reader',
          invalid: defaultEditing.invalid ?? false,
        }
      : null,
  );
  const [closed, setClosed] = useState(defaultClosed);
  const [focused, setFocused] = useState(false);
  /**
   * How the stage's width fits the dialog: narrow drops the rings, tiny the labels too. Unknown
   * until measured, and laid out as wide meanwhile.
   */
  const [fit, setFit] = useState<'wide' | 'narrow' | 'tiny' | null>(null);
  const [message, setMessage] = useState('');
  const baseId = useId();
  const titleId = `${baseId}-title`;
  const hintId = `${baseId}-hint`;

  const dialog = useRef<HTMLElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const reopenButton = useRef<HTMLButtonElement>(null);
  /** Each step's disclosure button, by section id. */
  const heads = useRef(new Map<string, HTMLButtonElement>());
  /** Each row's value button, input and check, by "field:part". */
  const parts = useRef(new Map<string, HTMLElement>());
  const fold = useRef<Fold | null>(null);
  const running = useRef<FoldPlayer | null>(null);
  /** What to focus once the next render is on screen. */
  const focusNext = useRef<(() => HTMLElement | null | undefined) | null>(null);

  const statuses = statusesOf(sections, values);

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

  const locate = (fieldId: string) => {
    for (const section of sections) {
      const field = section.fields.find((item) => item.id === fieldId);
      if (field) return { field, section };
    }
    return null;
  };
  const part = (fieldId: string, name: RowPart) => parts.current.get(`${fieldId}:${name}`);

  const save = (next: SetupValues) => {
    setValues(next);
    onChange?.(next);
  };

  /** The values with `edit` saved, or null when it is not whole (an empty field is fine). */
  const withDraft = (edit: Editing) => {
    const found = locate(edit.field);
    const draft = edit.draft.trim();
    if (!found || (draft && !isFilled(kindOf(found.field), draft))) return null;
    return { ...values, [edit.field]: draft };
  };

  /** Ends the edit in progress quietly: saved if whole, dropped if not. */
  const settle = () => {
    if (!editing) return;
    const next = withDraft(editing);
    if (next && next[editing.field] !== (values[editing.field] ?? '')) save(next);
    setEditing(null);
  };

  const startEdit = (fieldId: string, by: Editing['by']) => {
    if (editing?.field === fieldId) return;
    settle();
    setEditing({ field: fieldId, draft: values[fieldId] ?? '', by, invalid: false });
    if (by === 'reader') focusNext.current = () => part(fieldId, 'input');
  };

  const commit = (byReader: boolean) => {
    if (!editing) return;
    const found = locate(editing.field);
    if (!found) return;
    const next = withDraft(editing);
    if (!next) {
      setEditing({ ...editing, invalid: true });
      if (byReader) announce(hintFor(kindOf(found.field)));
      return;
    }
    const wasDone = statuses[found.section.id] === 'done';
    save(next);
    setEditing(null);
    if (byReader) {
      const nowDone = statusesOf(sections, next)[found.section.id] === 'done';
      announce(
        `${found.field.label} saved.${nowDone && !wasDone ? ` ${found.section.title} completed.` : ''}`,
      );
      focusNext.current = () => part(editing.field, 'value');
    }
  };

  const cancel = () => {
    if (!editing) return;
    const fieldId = editing.field;
    setEditing(null);
    announce('Edit cancelled.');
    focusNext.current = () => part(fieldId, 'value');
  };

  const clear = () => {
    if (!editing) return;
    setEditing({ ...editing, draft: '', invalid: false });
    const fieldId = editing.field;
    focusNext.current = () => part(fieldId, 'input');
  };

  /** Opens step `id`, or closes it if it is open; the step open before closes. */
  const toggle = (id: string) => {
    settle();
    foldTo(id);
  };

  const foldTo = (id: string) => {
    const next = open === id ? null : id;
    const ids = sections.map((section) => section.id);
    const to: Openness = Object.fromEntries(ids.map((key) => [key, key === next ? 1 : 0]));
    setOpen(next);
    if (reducedMotion()) {
      fold.current = null;
      setLaidOut(next ? [next] : []);
      return;
    }
    // From where the panels are: open or closed at rest, or partway through a fold.
    const now = performance.now();
    const from: Openness = fold.current
      ? opennessAt(fold.current, now)
      : Object.fromEntries(ids.map((key) => [key, laidOut.includes(key) ? 1 : 0]));
    fold.current = { from, to, start: now };
    setLaidOut(ids.filter((key) => from[key] > 0.001 || to[key] > 0));
    setFoldTurn((turn) => turn + 1);
  };

  const close = () => {
    settle();
    shut();
    announce(`${title} closed.`);
    focusNext.current = () => reopenButton.current;
  };

  const shut = () => {
    setClosed(true);
    onClose?.();
  };

  const reopen = (byReader: boolean) => {
    setClosed(false);
    if (byReader) focusNext.current = () => closeButton.current;
  };

  const proceed = () => {
    settle();
    const pending = sections.find((section) => section.required && statuses[section.id] !== 'done');
    if (!pending) {
      onProceed?.(values);
      shut();
      announce('Account set up.');
      focusNext.current = () => reopenButton.current;
      return;
    }
    // The first required step not done yet, at its first empty field.
    if (open !== pending.id) foldTo(pending.id);
    const empty = pending.fields.find((field) => !filled(field, values));
    if (empty) {
      setEditing({ field: empty.id, draft: values[empty.id] ?? '', by: 'reader', invalid: false });
      focusNext.current = () => part(empty.id, 'input');
    }
    announce(`Fill in ${pending.title} first.`);
  };

  // A fold starts once its panels are laid out: each piece plays from where it is.
  useLayoutEffect(() => {
    const current = fold.current;
    const element = dialog.current;
    if (!current || !element) return;
    running.current?.stop();
    current.start = performance.now();
    running.current = playFold(element, current, () => {
      if (fold.current !== current) return;
      fold.current = null;
      setLaidOut(Object.keys(current.to).filter((key) => current.to[key] > 0));
    });
  }, [foldTurn]);

  // A fold that has ended: the closed panels have left the layout, the pieces drop their moves.
  useLayoutEffect(() => {
    if (fold.current) return;
    running.current?.stop();
    running.current = null;
  }, [laidOut]);

  useEffect(() => () => running.current?.stop(), []);

  // The layout follows the stage's own width, as the observer reports it. The layers are made
  // once it is known (data-sized), so a phone does not paint them at a wide layout's sizes first.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => {
      const width = entry.contentRect.width;
      setFit(width <= 330 ? 'tiny' : width <= 460 ? 'narrow' : 'wide');
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);

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

  // The demo: the pointer opens the first step and fills in its fields one by one, a field every
  // four seconds, pressing the next value to save the one before. Then it opens each other step
  // in turn, resting on its first row, and folds the last one away. Each round starts again from
  // the first values.
  const readState = useEffectEvent(() => ({ open, closed }));
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'reset') {
      setValues(initialValues);
      setEditing(null);
    } else if (act.type === 'reopen') reopen(false);
    else if (act.type === 'toggle') toggle(act.section);
    else if (act.type === 'edit') startEdit(act.field, 'demo');
    else if (act.type === 'type') {
      setEditing((now) => now && { ...now, draft: act.text, invalid: false });
    } else commit(false);
  });
  // A dialog asked to start in a given state stays there.
  const demo = autoplay && !defaultEditing && !defaultClosed && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    const [first, ...others] = sections;
    if (!demo || !stage || !ghost || !first) return;
    const head = (id: string) => heads.current.get(id);
    const value = (id: string | undefined) => parts.current.get(`${id}:value`);
    // A value the pointer rests on shows its hover, as under a real pointer.
    let hovered: HTMLElement | undefined;
    const hover = (element?: HTMLElement) => {
      hovered?.removeAttribute('data-hover');
      hovered = element;
      hovered?.setAttribute('data-hover', '');
    };
    const typed = first.fields.filter((field) => DEMO_TEXT[field.id] !== undefined);
    const stop = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      const flip = async (id: string) => {
        hover();
        await moveTo(head(id)?.querySelector('[data-chevron]'), { shape: 'hand' });
        await press(() => demoAct({ type: 'toggle', section: id }));
        await wait(FOLD.duration);
      };
      for (let round = 0; ; round += 1) {
        await wait(round ? 1500 : 100);
        if (readState().closed) {
          await moveTo(reopenButton.current, { shape: 'hand' });
          await press(() => demoAct({ type: 'reopen' }));
          await wait(500);
        }
        demoAct({ type: 'reset' });
        if (readState().open !== first.id) await flip(first.id);
        for (const field of typed) {
          const target = value(field.id);
          await moveTo(target, { at: { x: 0.3, y: 0.55 }, shape: 'text' });
          hover(target);
          await wait(150);
          const pressed = performance.now();
          await press(() => demoAct({ type: 'edit', field: field.id }));
          hover();
          await wait(250);
          let draft = '';
          for (const key of DEMO_TEXT[field.id]) {
            draft = nextDraft(kindOf(field), draft, draft + key);
            demoAct({ type: 'type', text: draft });
            await wait(70 + ((key.charCodeAt(0) * 37) % 70));
          }
          await moveTo(target, { at: { x: 0.5, y: 0.55 } });
          // The next field opens about four seconds after this one, the pointer on it.
          await wait(Math.max(0, pressed + 3320 - performance.now()));
        }
        // The last field is saved where it stands, as Enter would.
        await wait(600);
        demoAct({ type: 'commit' });
        await wait(1300);
        for (const section of others) {
          await flip(section.id);
          await moveTo(value(section.fields[0]?.id), { at: { x: 0.4, y: 0.55 }, shape: 'arrow' });
          hover(value(section.fields[0]?.id));
          await wait(1000);
        }
        const last = others.at(-1);
        if (last) await flip(last.id);
        await moveTo(head(others[0]?.id ?? first.id)?.querySelector('[data-status]'), {
          shape: 'hand',
        });
      }
    });
    return () => {
      stop();
      hover();
      setEditing((now) => (now?.by === 'demo' ? null : now));
    };
  }, [demo, root, sections]);

  const editingField = editing ? locate(editing.field)?.field : undefined;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-sized={fit !== null || undefined}
      data-narrow={(fit !== null && fit !== 'wide') || undefined}
      data-tiny={fit === 'tiny' || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.stage}>
        <section
          ref={dialog}
          className={styles.dialog}
          data-part="dialog"
          aria-labelledby={titleId}
          data-closed={closed || undefined}
          inert={closed}
        >
          {/* The shadow, painted first, under the footer's end: it moves with the footer. */}
          <span className={styles.shadow} data-fold-end="" aria-hidden="true" />
          <header className={styles.header} data-part="header" data-fold="">
            <span className={styles.ring}>
              <SetupIcon />
            </span>
            <div className={styles.heading}>
              <h2 id={titleId} className={styles.title} data-part="title">
                {title}
              </h2>
              <p className={styles.subtitle}>{description}</p>
            </div>
            <button
              ref={closeButton}
              type="button"
              className={styles.close}
              data-part="close"
              aria-label={`Close ${title}`}
              onClick={close}
            >
              <CloseIcon />
            </button>
          </header>

          <div>
            {sections.map((section) => {
              const expanded = open === section.id;
              const status = statuses[section.id];
              const panelId = `${baseId}-${section.id}`;
              return (
                <div key={section.id} className={styles.step}>
                  <h3 className={styles.stepHeading} data-fold="">
                    <button
                      ref={(element) => {
                        if (element) heads.current.set(section.id, element);
                        else heads.current.delete(section.id);
                      }}
                      id={`${panelId}-button`}
                      type="button"
                      className={styles.stepButton}
                      data-part="step"
                      aria-expanded={expanded}
                      aria-controls={panelId}
                      onClick={() => toggle(section.id)}
                    >
                      <span className={styles.ring}>{section.icon}</span>
                      <span className={styles.stepText}>
                        <span className={styles.stepTitle}>
                          <span className={styles.stepName}>{section.title}</span>
                          <InfoIcon className={styles.info} />
                        </span>
                        <span className={styles.stepDescription}>{section.description}</span>
                      </span>
                      <span className={styles.badge} data-part="badge" data-status={status}>
                        {STATUS_LABEL[status]}
                      </span>
                      <span
                        className={styles.chevron}
                        data-part="chevron"
                        data-chevron=""
                        data-open={expanded || undefined}
                      />
                    </button>
                  </h3>
                  <div
                    id={panelId}
                    className={styles.panel}
                    data-part="panel"
                    style={{ '--rows': section.fields.length } as CSSProperties}
                    role="region"
                    aria-labelledby={`${panelId}-button`}
                    data-fold={laidOut.includes(section.id) ? section.id : undefined}
                    aria-hidden={!expanded || undefined}
                  >
                    <div className={styles.rows}>
                      {section.fields.map((field) => {
                        const mine = editing?.field === field.id ? editing : null;
                        return (
                          <FieldRow
                            key={field.id}
                            field={field}
                            value={values[field.id] ?? ''}
                            draft={mine ? mine.draft : null}
                            invalid={mine?.invalid ?? false}
                            demo={mine?.by === 'demo'}
                            reachable={expanded}
                            hintId={hintId}
                            partRef={(name, element) => {
                              const key = `${field.id}:${name}`;
                              if (element) parts.current.set(key, element);
                              else parts.current.delete(key);
                            }}
                            onEdit={() => startEdit(field.id, 'reader')}
                            onDraft={(text) =>
                              setEditing((now) => now && { ...now, draft: text, invalid: false })
                            }
                            onCommit={() => commit(true)}
                            onCancel={cancel}
                            onClear={clear}
                            onLeave={settle}
                          />
                        );
                      })}
                    </div>
                  </div>
                </div>
              );
            })}
          </div>

          <footer className={styles.footer} data-part="footer" data-fold="">
            <button type="button" className={styles.skip} data-part="skip" onClick={close}>
              Skip
            </button>
            <button type="button" className={styles.proceed} data-part="proceed" onClick={proceed}>
              Proceed
            </button>
          </footer>
        </section>

        <button
          ref={reopenButton}
          type="button"
          className={styles.reopen}
          data-part="reopen"
          data-shown={closed || undefined}
          inert={!closed}
          onClick={() => reopen(true)}
        >
          <SetupIcon />
          {title}
        </button>
        <GhostPointer ref={pointer} />
      </div>
      {/* Outside the stage, which then has nothing of its own to paint. */}
      <p id={hintId} className={styles.hidden}>
        {editing?.invalid && editingField ? hintFor(kindOf(editingField)) : ''}
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### account-setup/account-setup.module.css

```css
/* Account setup (account-setup.tsx): a grey stage with the dialog in its middle, or at night a
   near-black stage with the dialog in dark grey.

   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).

   Sizes are written as design pixels times --u, so the whole dialog scales from one value.

   Memory. A phone keeps the page's pixels in tiles the width of the screen, about 7 MB each, and
   keeps a second copy of any layer or tile painted again. So nothing the demo changes is painted
   twice in a big picture (see Layers below). The stage is one layout boundary of a fixed size, so
   a change inside it (a value typed, a panel joining the layout) lays it out alone. And no style
   reaches an icon while the demo plays: Safari lays out each drawing a style change reaches, and
   two at once lay out the whole page. So a hidden input goes with opacity and stacking, not with
   visibility or inert, which its icons would inherit. */

.root {
  --u: 0.9px;
  /* The stage and its edge; the dialog's blocks, the fields and the buttons on them. */
  --surface: #c7c7c7;
  --edge: rgb(0 0 0 / 0.06);
  --surface-2: #fff;
  /* Text: titles and values; labels, descriptions and icons; placeholders and the date's mask.
     The info disc, and the i cut out of it in the dialog's colour. */
  --ink: #171717;
  --ink-muted: #5c5c5c;
  --ink-faint: #a3a3a3;
  --info: #d1d1d1;
  --info-mark: #fff;
  /* Hairlines (the rings, the dividers, the fields' box, Skip's frame); a value or button under
     the pointer. */
  --line: #ebebeb;
  --hover: #f7f7f7;
  /* The open step's chevron: a disc in the ink, and the chevron on it. */
  --on-ink: #fff;
  /* Proceed, its label (white on both) and under the pointer; focus rings. */
  --accent: #2c51fc;
  --accent-ink: #fff;
  --accent-hover: #2443e6;
  --focus: #2c51fc;
  /* A field being edited: the glow round its ring; a value not whole yet, and its glow. */
  --field-glow: rgb(23 23 23 / 0.06);
  --invalid: #fb3748;
  --invalid-glow: rgb(251 55 72 / 0.12);
  /* The badges: In Progress, Incomplete and Completed. */
  --progress: #fff1eb;
  --progress-ink: #fa7319;
  --todo: #f5f5f5;
  --done: #e0faec;
  --done-ink: #1fc16b;
  /* The dialog's drop; Skip's and Proceed's; the button that brings the dialog back, its edge
     and its drop. */
  --shadow: rgb(0 0 0 / 0.2);
  --skip-shadow: rgb(10 13 20 / 0.03);
  --accent-shadow: rgb(14 18 27 / 0.24);
  --reopen-edge: rgb(0 0 0 / 0.04);
  --reopen-shadow: rgb(0 0 0 / 0.2);
  --radius: calc(20 * var(--u));
  position: relative;
  height: 620px;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 calc(14 * var(--u)) / calc(20 * var(--u)) Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#c7c7c7, #111113);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --surface-2: light-dark(#fff, #1c1c1e);
    --ink: light-dark(#171717, #f5f5f5);
    --ink-muted: light-dark(#5c5c5c, #ababab);
    --ink-faint: light-dark(#a3a3a3, #8c8c8c);
    --info: light-dark(#d1d1d1, #48484b);
    --info-mark: light-dark(#fff, #1c1c1e);
    --line: light-dark(#ebebeb, #2e2e31);
    --hover: light-dark(#f7f7f7, #29292c);
    --on-ink: light-dark(#fff, #171717);
    --accent: light-dark(#2c51fc, #3a5bff);
    --accent-hover: light-dark(#2443e6, #4563ff);
    --focus: light-dark(#2c51fc, #5a7bff);
    --field-glow: light-dark(rgb(23 23 23 / 0.06), rgb(255 255 255 / 0.1));
    --invalid: light-dark(#fb3748, #ff6470);
    --invalid-glow: light-dark(rgb(251 55 72 / 0.12), rgb(255 100 112 / 0.22));
    --progress: light-dark(#fff1eb, #3a2617);
    --progress-ink: light-dark(#fa7319, #ff9447);
    --todo: light-dark(#f5f5f5, #2a2a2c);
    --done: light-dark(#e0faec, #15301f);
    --done-ink: light-dark(#1fc16b, #3ddc84);
    --shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.6));
    --skip-shadow: light-dark(rgb(10 13 20 / 0.03), rgb(0 0 0 / 0.2));
    --accent-shadow: light-dark(rgb(14 18 27 / 0.24), rgb(0 0 0 / 0.4));
    --reopen-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.08));
    --reopen-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
  }
}
/* The colorScheme prop. Without it nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(button, input) {
  margin: 0;
  font: inherit;
  color: inherit;
}
.root :where(button) {
  cursor: pointer;
}
.root :where(button, input):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The stage's contents, clipped to its box and laid out on their own. */
.stage {
  position: absolute;
  inset: 0;
  contain: size layout;
  display: grid;
  grid-template: minmax(0, 1fr) / minmax(0, 1fr);
  place-items: center;
  overflow: hidden;
}

/* Layers, once the stage knows its width (data-sized, so they are made once, at their size).
   - The stage itself, which paints nothing: a change inside it repaints no tile of the page.
   - Each block of the dialog (fold.ts moves them), with a transform and a clip that stay on at
     rest: Safari paints a layer again when it gains or loses either.
   - Each row: opening a field and typing repaint that row alone, not the panel.
   - Each step's chevron, the button that brings the dialog back, and the lines for screen
     readers, which change on their own. */
.root[data-sized] :is(.stage, .row, .chevron, .reopen, .hidden) {
  will-change: transform;
}
.root[data-sized] .chevron::before {
  will-change: transform;
}
.root[data-sized] :is(.header, .stepHeading, .panel[data-fold], .footer, .shadow) {
  transform: translateY(0);
  will-change: transform;
}
.root[data-sized] .panel[data-fold] {
  clip-path: inset(0);
}

/* The dialog: white blocks stacked from the header to the footer (dark grey at night), and the
   shadow under them. */
.dialog {
  position: relative;
  grid-area: 1 / 1;
  width: min(calc(523 * var(--u)), 100% - 24px);
  transition:
    opacity 0.2s ease,
    transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
    visibility 0s;
}
.dialog[data-closed] {
  visibility: hidden;
  opacity: 0;
  transform: translateY(8px) scale(0.97);
  transition:
    opacity 0.2s ease,
    transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
    visibility 0s 0.3s;
}
.shadow {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: calc(120 * var(--u));
  border-radius: 0 0 var(--radius) var(--radius);
  box-shadow: 0 18px 40px -14px var(--shadow);
}

/* An icon in a hairline ring. */
.ring {
  display: grid;
  flex: none;
  place-items: center;
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-muted);
}
.ring svg,
.close svg,
.reopen svg {
  display: block;
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
}

/* Each block is the dialog's colour and reaches 1px past its edges, so no seam shows while they
   move apart.
   The lines under the header and over the footer sit inside them, with the steps' margins. */
.header {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: calc(19 * var(--u));
  padding: calc(16 * var(--u)) calc(20 * var(--u)) calc(26 * var(--u) + 1px) calc(22 * var(--u));
  border-radius: var(--radius) var(--radius) 0 0;
  background: var(--surface-2);
  box-shadow: 0 1px 0 var(--surface-2);
}
.header::after,
.footer::before {
  content: '';
  position: absolute;
  right: 0;
  left: 0;
  height: 1px;
  background: var(--line);
}
.header::after {
  bottom: calc(12 * var(--u));
}
.header .ring {
  margin-top: calc(4 * var(--u));
}
.heading {
  flex: 1;
  min-width: 0;
}
.title {
  margin: 0;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  line-height: calc(20 * var(--u));
  letter-spacing: -0.006em;
}
.subtitle {
  margin: calc(4 * var(--u)) 0 0;
  overflow: hidden;
  font-size: calc(12 * var(--u));
  line-height: calc(16 * var(--u));
  color: var(--ink-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.close {
  display: grid;
  flex: none;
  place-items: center;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  margin: calc(-4 * var(--u)) calc(-4 * var(--u)) 0 0;
  padding: 0;
  border: 0;
  border-radius: calc(8 * var(--u));
  background: none;
  color: var(--ink-muted);
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.close:hover {
  background: var(--hover);
  color: var(--ink);
}

/* The steps. */
.step {
  position: relative;
}
.stepHeading {
  position: relative;
  margin: 0;
  padding: 0 calc(20 * var(--u)) 0 calc(22 * var(--u));
  background: var(--surface-2);
  box-shadow:
    0 -1px 0 var(--surface-2),
    0 1px 0 var(--surface-2);
  font: inherit;
}
.stepButton {
  display: flex;
  align-items: center;
  width: 100%;
  padding: calc(10.5 * var(--u)) 0;
  border: 0;
  border-radius: calc(12 * var(--u));
  background: none;
  text-align: left;
}
.stepButton > .ring {
  margin-right: calc(19 * var(--u));
}
.stepText {
  display: block;
  flex: 1;
  min-width: 0;
}
.stepTitle {
  display: flex;
  align-items: center;
  gap: calc(4 * var(--u));
  font-weight: 500;
  letter-spacing: -0.006em;
}
.stepName {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.info {
  flex: none;
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  color: var(--info);
}
.stepDescription {
  display: block;
  margin-top: calc(4 * var(--u));
  overflow: hidden;
  font-size: calc(12 * var(--u));
  line-height: calc(16 * var(--u));
  color: var(--ink-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Where a step stands. */
.badge {
  display: inline-flex;
  flex: none;
  align-items: center;
  height: calc(20 * var(--u));
  margin: 0 calc(10 * var(--u)) 0 calc(12 * var(--u));
  padding: 0 calc(11 * var(--u));
  border-radius: 999px;
  font-size: calc(11 * var(--u));
  font-weight: 500;
  line-height: calc(12 * var(--u));
  white-space: nowrap;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.badge[data-status='progress'] {
  background: var(--progress);
  color: var(--progress-ink);
}
.badge[data-status='todo'] {
  background: var(--todo);
  color: var(--ink-muted);
}
.badge[data-status='done'] {
  background: var(--done);
  color: var(--done-ink);
}

/* The disclosure's round button: a chevron, drawn with two borders, that turns up when open. */
.chevron {
  position: relative;
  flex: none;
  width: calc(24 * var(--u));
  height: calc(24 * var(--u));
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line);
  transition:
    background-color 0.2s ease,
    box-shadow 0.2s ease;
}
.chevron::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  margin: calc(-4.2 * var(--u)) 0 0 calc(-3 * var(--u));
  border-right: calc(1.5 * var(--u)) solid var(--ink-muted);
  border-bottom: calc(1.5 * var(--u)) solid var(--ink-muted);
  transform: rotate(45deg);
  transition:
    transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1),
    border-color 0.2s ease;
}
.stepButton:hover .chevron {
  background: var(--hover);
}
.chevron[data-open],
.stepButton:hover .chevron[data-open] {
  background: var(--ink);
  box-shadow: none;
}
.chevron[data-open]::before {
  border-color: var(--on-ink);
  transform: translateY(calc(2.4 * var(--u))) rotate(225deg);
}

/* A step's fields, in a hairline box under its text. Out of the layout (and of the fold) while
   closed. Its height, set from its number of rows (--rows), is rounded to an even number of
   pixels: when it joins or leaves the layout, the dialog grows or shrinks from its middle by half
   of it, and a block moved by a fraction of a pixel would be painted again. */
.panel {
  position: relative;
  height: round(nearest, calc((24 + 44 * var(--rows)) * var(--u)), 2px);
  padding: calc(2 * var(--u)) calc(20 * var(--u)) calc(10 * var(--u)) calc(73 * var(--u));
  background: var(--surface-2);
}
.panel:not([data-fold]) {
  display: none;
}
.rows {
  display: grid;
  row-gap: calc(8 * var(--u));
  padding: calc(10 * var(--u)) calc(12 * var(--u));
  border-radius: calc(12 * var(--u));
  box-shadow: inset 0 0 0 1px var(--line);
}

.row {
  display: grid;
  grid-template-columns: calc(139 * var(--u)) minmax(0, 1fr);
  align-items: center;
  height: calc(36 * var(--u));
}
.label {
  display: flex;
  align-items: center;
  gap: calc(10 * var(--u));
  min-width: 0;
  color: var(--ink-muted);
  cursor: pointer;
}
.rowIcon {
  display: grid;
  flex: none;
}
.rowIcon svg {
  display: block;
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
}
.labelText {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The value and its input share one box; one of them is hidden. */
.cell {
  position: relative;
  display: grid;
  height: 100%;
  isolation: isolate;
}
.value,
.field {
  grid-area: 1 / 1;
  min-width: 0;
  border-radius: calc(10 * var(--u));
}
.value {
  position: relative;
  z-index: 1;
  display: block;
  padding: 0 calc(12 * var(--u));
  overflow: hidden;
  border: 0;
  background: transparent;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
  cursor: text;
  transition: background-color 0.15s ease;
}
.value[data-empty] {
  color: var(--ink-faint);
}
.value:hover,
.value[data-hover] {
  background: var(--hover);
}
.row[data-editing] .value {
  visibility: hidden;
}

/* The input lies under the value until it opens, and fades in over it. */
.field {
  position: relative;
  display: flex;
  align-items: center;
  padding-right: calc(10 * var(--u));
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px var(--ink),
    0 0 0 calc(3 * var(--u)) var(--field-glow);
  opacity: 0;
  transition:
    opacity 0.15s ease,
    box-shadow 0.15s ease;
}
.row[data-editing] .field {
  z-index: 2;
  opacity: 1;
}
.cell[data-invalid] .field {
  box-shadow:
    inset 0 0 0 1px var(--invalid),
    0 0 0 calc(3 * var(--u)) var(--invalid-glow);
}
.input {
  flex: 1;
  align-self: stretch;
  min-width: 0;
  padding: 0 0 0 calc(12 * var(--u));
  border: 0;
  background: none;
  color: var(--ink);
}
.root .input:focus-visible {
  outline: none;
}
.input::placeholder {
  color: var(--ink-faint);
  opacity: 1;
}

/* The typed text again, unseen, with the demo's caret and the date mask's rest after it. */
.overlay {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(12 * var(--u));
  display: flex;
  align-items: center;
  white-space: pre;
  pointer-events: none;
}
.typed {
  visibility: hidden;
}
.mask {
  color: var(--ink-faint);
}
.caret {
  width: 1px;
  height: calc(16 * var(--u));
  margin-right: -1px;
  background: var(--ink);
  visibility: hidden;
}
.caret[data-shown] {
  visibility: visible;
  animation: blink 1.06s steps(1) infinite;
}
@keyframes blink {
  50% {
    opacity: 0;
  }
}

.fieldButton {
  display: grid;
  flex: none;
  place-items: center;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  margin-left: calc(4 * var(--u));
  padding: 0;
  border: 0;
  border-radius: calc(7 * var(--u));
  background: none;
  color: var(--ink-muted);
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.fieldButton:hover {
  background: var(--hover);
  color: var(--ink);
}
.fieldIcon {
  display: block;
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
}

/* Skip and Proceed. */
.footer {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: calc(12 * var(--u));
  padding: calc(24 * var(--u) + 1px) calc(20 * var(--u)) calc(16 * var(--u));
  border-radius: 0 0 var(--radius) var(--radius);
  background: var(--surface-2);
  box-shadow: 0 -1px 0 var(--surface-2);
}
.footer::before {
  top: calc(8 * var(--u));
}
.skip,
.proceed {
  height: calc(40 * var(--u));
  padding: 0 calc(16 * var(--u));
  border: 0;
  border-radius: calc(10 * var(--u));
  font-weight: 500;
  letter-spacing: -0.006em;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.root .skip {
  background: var(--surface-2);
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 1px 2px var(--skip-shadow);
  color: var(--ink-muted);
}
.root .skip:hover {
  background: var(--hover);
  color: var(--ink);
}
.root .proceed {
  background: var(--accent);
  box-shadow:
    0 1px 2px var(--accent-shadow),
    inset 0 1px 0 rgb(255 255 255 / 0.12);
  color: var(--accent-ink);
}
.root .proceed:hover {
  background: var(--accent-hover);
}

/* The button that brings a closed dialog back. */
.reopen {
  display: inline-flex;
  grid-area: 1 / 1;
  align-items: center;
  gap: calc(8 * var(--u));
  height: calc(40 * var(--u));
  padding: 0 calc(16 * var(--u)) 0 calc(12 * var(--u));
  border: 0;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--reopen-edge),
    0 6px 18px -6px var(--reopen-shadow);
  font-weight: 500;
  visibility: hidden;
  opacity: 0;
  transform: translateY(6px) scale(0.96);
  transition:
    opacity 0.2s ease,
    transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
    visibility 0s 0.3s;
}
.reopen svg {
  color: var(--ink-muted);
}
.reopen[data-shown] {
  visibility: visible;
  opacity: 1;
  transform: none;
  transition:
    opacity 0.2s ease 0.1s,
    transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s;
}

/* 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 stages (the component sets data-narrow from its width): tighter margins, the steps
   without their rings, the rows without icons. Not container queries: in Safari, a style change
   inside a query container can lay out the whole page again. */
.root[data-narrow] .dialog {
  width: calc(100% - 20px);
}
.root[data-narrow] :is(.header, .stepHeading, .panel, .footer) {
  padding-right: calc(14 * var(--u));
  padding-left: calc(14 * var(--u));
}
.root[data-narrow] .header {
  gap: calc(12 * var(--u));
}
.root[data-narrow] :is(.stepButton > .ring, .rowIcon) {
  display: none;
}
.root[data-narrow] .row {
  grid-template-columns: calc(98 * var(--u)) minmax(0, 1fr);
}
.root[data-narrow] .badge {
  margin: 0 calc(8 * var(--u));
  padding: 0 calc(8 * var(--u));
}

/* Very narrow stages (data-tiny): a step's title takes two lines in place of its description,
   and a field shows its value alone (its label stays for screen readers). */
.root[data-tiny] :is(.info, .stepDescription) {
  display: none;
}
.root[data-tiny] .stepName {
  display: -webkit-box;
  white-space: normal;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
}
.root[data-tiny] .row {
  grid-template-columns: minmax(0, 1fr);
}
.root[data-tiny] .label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .dialog,
  .dialog[data-closed],
  .badge,
  .chevron,
  .chevron::before,
  .field,
  .reopen,
  .reopen[data-shown] {
    transition: none;
  }
}
```

### account-setup/field-row.tsx

```tsx
'use client';

import { useId } from 'react';
import { DATE_MASK, nextDraft } from './format';
import { CheckIcon, CloseIcon } from './icons';
import type { SetupField } from './sections';
import styles from './account-setup.module.css';

/*
 * One field of a step: an icon, a label and the value. Pressing the value (or anywhere on the row)
 * turns it into a bordered input around the same box, with a cross that clears it and a check that
 * saves it; Enter saves too and Escape cancels. The value and the input are both always there, the
 * input under the value until it opens, so opening a field changes no markup and no style reaches
 * its icons. A date shows what is left of its mask, faded, after the digits typed so far.
 */

export type RowPart = 'value' | 'input' | 'confirm';

export function FieldRow({
  field,
  value,
  draft,
  invalid,
  demo,
  reachable,
  hintId,
  partRef,
  onEdit,
  onDraft,
  onCommit,
  onCancel,
  onClear,
  onLeave,
}: {
  field: SetupField;
  /** The saved value. */
  value: string;
  /** The input's text while it is open; null while the value shows. */
  draft: string | null;
  /** The draft is not whole yet: the box turns red and points to the hint. */
  invalid: boolean;
  /** The demo is typing here: a caret shows, without the input holding focus. */
  demo: boolean;
  /** Its step is open: the value can be focused. */
  reachable: boolean;
  /** The id of the line that says what to fix. */
  hintId: string;
  /** Hands the row's controls to the dialog, which focuses them and points the demo at them. */
  partRef: (part: RowPart, element: HTMLElement | null) => void;
  onEdit: () => void;
  onDraft: (text: string) => void;
  onCommit: () => void;
  onCancel: () => void;
  onClear: () => void;
  /** Focus left the field for somewhere else on the page. */
  onLeave: () => void;
}) {
  const inputId = useId();
  const kind = field.kind ?? 'text';
  const editing = draft !== null;
  const text = draft ?? value;
  // The mask's rest, after the digits typed so far.
  const rest = kind === 'date' && editing ? DATE_MASK.slice(text.length) : '';

  return (
    <div className={styles.row} data-part="row" data-editing={editing || undefined}>
      <label
        className={styles.label}
        htmlFor={inputId}
        onClick={(event) => {
          if (editing) return;
          event.preventDefault();
          if (reachable) onEdit();
        }}
      >
        <span className={styles.rowIcon}>{field.icon}</span>
        <span className={styles.labelText}>{field.label}</span>
      </label>
      <div className={styles.cell} data-invalid={(editing && invalid) || undefined}>
        <button
          ref={(element) => partRef('value', element)}
          type="button"
          className={styles.value}
          data-part="value"
          data-empty={!value || undefined}
          tabIndex={editing || !reachable ? -1 : 0}
          aria-label={`${field.label}: ${value || 'empty'}. Edit`}
          onClick={onEdit}
        >
          {value || field.placeholder}
        </button>
        <div className={styles.field} data-part="field" aria-hidden={!editing || undefined}>
          <input
            ref={(element) => partRef('input', element)}
            id={inputId}
            className={styles.input}
            tabIndex={editing ? 0 : -1}
            value={text}
            placeholder={kind === 'date' ? '' : field.placeholder}
            type={kind === 'email' ? 'email' : kind === 'phone' ? 'tel' : 'text'}
            inputMode={kind === 'date' ? 'numeric' : undefined}
            autoComplete={field.autoComplete ?? 'off'}
            spellCheck={false}
            enterKeyHint="done"
            aria-invalid={(editing && invalid) || undefined}
            aria-describedby={editing && invalid ? hintId : undefined}
            onChange={(event) => onDraft(nextDraft(kind, text, event.target.value))}
            onKeyDown={(event) => {
              if (event.key === 'Enter') {
                event.preventDefault();
                onCommit();
              } else if (event.key === 'Escape') {
                event.preventDefault();
                event.stopPropagation();
                onCancel();
              }
            }}
            onBlur={(event) => {
              if (!event.currentTarget.parentElement?.contains(event.relatedTarget)) onLeave();
            }}
          />
          {/* The typed text again, unseen, to place the mask's rest and the demo's caret after it. */}
          <span className={styles.overlay} aria-hidden="true">
            <span className={styles.typed}>{text}</span>
            <span className={styles.caret} data-shown={demo || undefined} />
            <span className={styles.mask}>{rest}</span>
          </span>
          {/* Pressing these keeps the focus in the input, so leaving it does not save first. */}
          <button
            type="button"
            className={styles.fieldButton}
            data-part="clear"
            tabIndex={editing ? 0 : -1}
            aria-label={`Clear ${field.label}`}
            onPointerDown={(event) => event.preventDefault()}
            onClick={onClear}
          >
            <CloseIcon className={styles.fieldIcon} />
          </button>
          <button
            ref={(element) => partRef('confirm', element)}
            type="button"
            className={styles.fieldButton}
            data-part="save"
            tabIndex={editing ? 0 : -1}
            aria-label={`Save ${field.label}`}
            onPointerDown={(event) => event.preventDefault()}
            onClick={onCommit}
          >
            <CheckIcon className={styles.fieldIcon} />
          </button>
        </div>
      </div>
    </div>
  );
}
```

### account-setup/fold.ts

```tsx
import { spring } from './spring';

/*
 * The fold: a section opening or closing, played with transforms rather than by animating heights.
 *
 * The dialog is a stack of white blocks: its header, each step's heading, the open step's panel,
 * the footer (rounded at the ends), and under the footer the dialog's shadow. While a fold plays,
 * every panel involved (the one opening, the one closing) is laid out at its full height, once.
 * Each panel then has an openness, 0 to 1, and the dialog shows only the open part of each: a
 * panel is clipped from the bottom to its open part, every block below it moves up by its closed
 * part, and since the dialog stays centred on the stage, every block also moves down by half the
 * closed parts. Every block keeps a layer of its own, so each frame only moves layers and resizes
 * a panel's clip; both are written in the same frame (an animation would run the moves on the
 * compositor and the clip on the page, a frame apart, and a seam would flicker between them).
 * Once it ends, the closed panels leave the layout and the blocks drop their moves: the same
 * picture, now laid out.
 *
 * The blocks are marked in the markup with `data-fold`, in order from the top; a panel in the
 * layout carries its section's id there (a panel out of it has no mark). Pieces that move with
 * the dialog's bottom edge, wherever they are in the markup, are marked `data-fold-end`.
 */

/** How open each panel is, 0 to 1, by section id. */
export type Openness = Record<string, number>;

export type Fold = {
  from: Openness;
  to: Openness;
  /** When it started, on the `performance.now()` clock. */
  start: number;
};

/** The fold's motion: a spring with no bounce. */
export const FOLD = spring(0, 300);

/** How open each panel is at `now`, partway through `fold`. */
export function opennessAt(fold: Fold, now: number): Openness {
  const progress = FOLD.at(Math.min(1, Math.max(0, (now - fold.start) / FOLD.duration)));
  const open: Openness = {};
  for (const id of Object.keys(fold.to)) {
    const from = fold.from[id] ?? 0;
    open[id] = from + ((fold.to[id] ?? 0) - from) * progress;
  }
  return open;
}

export type FoldPlayer = {
  /** Stops the fold where it is and drops every move: the pieces sit where the layout puts them. */
  stop: () => void;
};

/**
 * Plays `fold` on `dialog`, laid out with every panel involved open, frame by frame; `onEnd` runs
 * once it has settled.
 */
export function playFold(dialog: HTMLElement, fold: Fold, onEnd: () => void): FoldPlayer {
  const blocks = Array.from(dialog.querySelectorAll<HTMLElement>('[data-fold]'));
  const ends = Array.from(dialog.querySelectorAll<HTMLElement>('[data-fold-end]'));
  const heights = blocks.map((block) => (block.dataset.fold ? block.offsetHeight : 0));

  /** Places every block, then every end piece, with the panels as open as `open` says. */
  const place = (open: Openness) => {
    const closed = blocks.map((block, i) => {
      const id = block.dataset.fold;
      return id ? heights[i] * (1 - Math.min(1, Math.max(0, open[id] ?? 0))) : 0;
    });
    const total = closed.reduce((sum, part) => sum + part, 0);
    let above = 0;
    blocks.forEach((block, i) => {
      block.style.transform = `translateY(${total / 2 - above}px)`;
      if (block.dataset.fold) block.style.clipPath = `inset(0 0 ${closed[i]}px 0)`;
      above += closed[i];
    });
    for (const end of ends) end.style.transform = `translateY(${-total / 2}px)`;
  };

  let frame = 0;
  const tick = (now: number) => {
    place(opennessAt(fold, now));
    if (now - fold.start < FOLD.duration) frame = requestAnimationFrame(tick);
    else {
      frame = 0;
      onEnd();
    }
  };
  place(fold.from);
  frame = requestAnimationFrame(tick);

  return {
    stop: () => {
      cancelAnimationFrame(frame);
      for (const piece of [...blocks, ...ends]) {
        piece.style.transform = '';
        piece.style.clipPath = '';
      }
    },
  };
}
```

### account-setup/format.ts

```tsx
/*
 * What each kind of field accepts. A phone number and a date take digits only and lay them out as
 * they are typed: "(212) 555-0147", "14/03/1996". A field counts as filled once its value is
 * whole: ten digits, a real day, an address with an @ and a domain, or any text at all.
 */

export type FieldKind = 'text' | 'phone' | 'date' | 'email';

/** The date's mask: what is left of it shows, faded, after the digits typed so far. */
export const DATE_MASK = 'dd/mm/yyyy';

const MOST_DIGITS: Partial<Record<FieldKind, number>> = { phone: 10, date: 8 };

const digitsOf = (text: string, most: number) => text.replace(/\D/g, '').slice(0, most);

/** "(212) 555-0147", as far as the digits go. */
function formatPhone(digits: string) {
  if (!digits) return '';
  if (digits.length <= 3) return `(${digits}`;
  if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
  return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}

/** "14/03/1996", as far as the digits go. */
function formatDate(digits: string) {
  if (digits.length <= 2) return digits;
  if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
  return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
}

/** The field's new text once `typed` replaces `previous`: laid out for phones and dates. */
export function nextDraft(kind: FieldKind, previous: string, typed: string) {
  const most = MOST_DIGITS[kind];
  if (!most) return typed;
  let digits = digitsOf(typed, most);
  // A deleted separator takes the digit before it along, or the deletion would undo itself.
  if (typed.length < previous.length && digits === digitsOf(previous, most)) {
    digits = digits.slice(0, -1);
  }
  return kind === 'phone' ? formatPhone(digits) : formatDate(digits);
}

/** Whether `value` is whole for its kind. */
export function isFilled(kind: FieldKind, value: string) {
  const text = value.trim();
  if (kind === 'phone') return digitsOf(text, 10).length === 10;
  if (kind === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(text);
  if (kind === 'date') {
    const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text);
    if (!match) return false;
    const [day, month, year] = match.slice(1).map(Number);
    const last = new Date(year, month, 0).getDate();
    return month >= 1 && month <= 12 && day >= 1 && day <= last && year >= 1900;
  }
  return text.length > 0;
}

/** What a reader should fix, when `value` is not whole yet. */
export function hintFor(kind: FieldKind) {
  if (kind === 'phone') return 'Enter all ten digits of the number.';
  if (kind === 'date') return 'Enter a real date, as day, month and year.';
  if (kind === 'email') return 'Enter an address like name@example.com.';
  return '';
}
```

### account-setup/sections.tsx

```tsx
import type { ReactNode } from 'react';
import type { FieldKind } from './format';
import {
  AtIcon,
  BriefcaseIcon,
  BuildingIcon,
  CakeIcon,
  CogIcon,
  GlobeIcon,
  IdCardIcon,
  LinkIcon,
  MailIcon,
  NoteIcon,
  PhoneIcon,
  PinIcon,
  QuillIcon,
  UserIcon,
} from './icons';

/*
 * The steps of the setup and their fields. Each step is a section that folds open; each field is
 * a row whose value turns into an input when pressed.
 */

export type SetupField = {
  id: string;
  label: string;
  /** Shown, faded, while the field is empty. */
  placeholder: string;
  /** How it is typed and when it counts as filled (format.ts); text by default. */
  kind?: FieldKind;
  icon: ReactNode;
  /** The input's autocomplete token. */
  autoComplete?: string;
};

export type SetupSection = {
  id: string;
  title: string;
  description: string;
  icon: ReactNode;
  /** Proceed waits for this step to be done. */
  required?: boolean;
  fields: SetupField[];
};

export const SECTIONS: SetupSection[] = [
  {
    id: 'required',
    title: 'Required Information',
    description: 'Provide required information.',
    icon: <IdCardIcon />,
    required: true,
    fields: [
      {
        id: 'name',
        label: 'Full Name',
        placeholder: 'Full Name',
        icon: <UserIcon />,
        autoComplete: 'name',
      },
      {
        id: 'phone',
        label: 'Phone',
        placeholder: 'Phone',
        kind: 'phone',
        icon: <PhoneIcon />,
        autoComplete: 'tel-national',
      },
      {
        id: 'birthday',
        label: 'Date of Birth',
        placeholder: 'dd/mm/yyyy',
        kind: 'date',
        icon: <CakeIcon />,
        autoComplete: 'bday',
      },
      {
        id: 'occupation',
        label: 'Occupation',
        placeholder: 'Occupation',
        icon: <BriefcaseIcon />,
        autoComplete: 'organization-title',
      },
      {
        id: 'email',
        label: 'Email',
        placeholder: 'Enter email address...',
        kind: 'email',
        icon: <MailIcon />,
        autoComplete: 'email',
      },
      {
        id: 'address',
        label: 'Address',
        placeholder: 'Enter address...',
        icon: <PinIcon />,
        autoComplete: 'street-address',
      },
    ],
  },
  {
    id: 'profile',
    title: 'Profile Customization',
    description: 'Provide information for profile customization.',
    icon: <CogIcon />,
    fields: [
      {
        id: 'username',
        label: 'Username',
        placeholder: 'Enter username...',
        icon: <AtIcon />,
        autoComplete: 'username',
      },
      { id: 'language', label: 'Language', placeholder: 'Enter language...', icon: <GlobeIcon /> },
    ],
  },
  {
    id: 'optional',
    title: 'Optional Information',
    description: 'Provide optional information.',
    icon: <QuillIcon />,
    fields: [
      {
        id: 'bio',
        label: 'Bio',
        placeholder: 'Write something about yourself...',
        icon: <NoteIcon />,
      },
      {
        id: 'company',
        label: 'Company',
        placeholder: 'Enter company name...',
        icon: <BuildingIcon />,
        autoComplete: 'organization',
      },
      {
        id: 'website',
        label: 'Website',
        placeholder: 'Enter website URL...',
        icon: <LinkIcon />,
        autoComplete: 'url',
      },
    ],
  },
];

/**
 * What the demo types in the first step's fields, key by key: digits for phones and dates. The
 * address is left for the reader.
 */
export const DEMO_TEXT: Record<string, string> = {
  name: 'Maren',
  phone: '1234567894',
  birthday: '12011998',
  occupation: 'Designer',
  email: 'hi@plumwork.com',
};
```

### account-setup/icons.tsx

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

type IconProps = { className?: string };

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

/** A person with a small cog: setting up an account. */
export function SetupIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="8.5" cy="6.3" r="3.2" />
      <path d="M2.8 16.8c.4-3.2 2.8-5.3 5.7-5.3 1 0 1.9.2 2.7.7" />
      <circle cx="14.8" cy="15.2" r="1.5" />
      <path d="M14.8 12.2v.9M14.8 17.3v.9M11.8 15.2h.9M16.9 15.2h.9M12.7 13.1l.6.6M16.3 16.7l.6.6M12.7 17.3l.6-.6M16.3 13.7l.6-.6" />
    </svg>
  );
}

/** A card with a portrait on it. */
export function IdCardIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3.3" y="3.3" width="13.4" height="13.4" rx="2.4" />
      <circle cx="10" cy="8.4" r="1.9" />
      <path d="M6.7 13.6c.5-1.5 1.7-2.4 3.3-2.4s2.8.9 3.3 2.4" />
    </svg>
  );
}

/** A cog. */
export function CogIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M8.6 2.9h2.8l.5 2 1.6.9 2-.6 1.4 2.4-1.5 1.5v1.8l1.5 1.5-1.4 2.4-2-.6-1.6.9-.5 2H8.6l-.5-2-1.6-.9-2 .6-1.4-2.4 1.5-1.5V9.1L3.1 7.6l1.4-2.4 2 .6 1.6-.9z" />
      <circle cx="10" cy="10" r="2.2" />
    </svg>
  );
}

/** A quill. */
export function QuillIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M16.6 3.4C10.4 3.6 6.4 7.9 5.8 13.8l.4.4c5.9-.6 10.2-4.6 10.4-10.8z" />
      <path d="M3.4 16.6 9.8 10.2M8 10.6h3.4M10.4 7.9h3.2" />
    </svg>
  );
}

/** A person: a round head over a narrow bust. */
export function UserIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="10" cy="7.2" r="3.6" />
      <path d="M6.4 16.9v-.7c0-1.9 1.6-3.4 3.6-3.4s3.6 1.5 3.6 3.4v.7z" />
    </svg>
  );
}

/** A phone. */
export function PhoneIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="5.8" y="2.6" width="8.4" height="14.8" rx="2" />
    </svg>
  );
}

/** A cake with a candle, on a plate. */
export function CakeIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M10 3.6v7M5.8 10.6h8.4M5.8 10.6v6M14.2 10.6v6M4 16.6h12" />
    </svg>
  );
}

/** A briefcase with two straps. */
export function BriefcaseIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3" y="6.2" width="14" height="10.4" rx="1.6" />
      <path d="M7.4 6.2V4.3h5.2v1.9M7.2 6.2v10.4M12.8 6.2v10.4" />
    </svg>
  );
}

/** An envelope. */
export function MailIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="2.8" y="4.3" width="14.4" height="11.4" rx="2" />
      <path d="m3.6 5.4 6.4 5 6.4-5" />
    </svg>
  );
}

/** A map pin. */
export function PinIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M10 17.4s5.4-4.5 5.4-9a5.4 5.4 0 0 0-10.8 0c0 4.5 5.4 9 5.4 9z" />
      <circle cx="10" cy="8.4" r="1.9" />
    </svg>
  );
}

/** An at sign. */
export function AtIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="10" cy="10" r="2.8" />
      <path d="M12.8 7.6v3.3c0 1.1.8 1.9 1.9 1.9s1.9-1 1.9-2.8a6.6 6.6 0 1 0-2.7 5.3" />
    </svg>
  );
}

/** A globe. */
export function GlobeIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="10" cy="10" r="6.8" />
      <path d="M3.4 10h13.2M10 3.2c-3.6 3.9-3.6 9.7 0 13.6M10 3.2c3.6 3.9 3.6 9.7 0 13.6" />
    </svg>
  );
}

/** A page of notes. */
export function NoteIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="4" y="2.8" width="12" height="14.4" rx="2" />
      <path d="M7.2 7h5.6M7.2 10h5.6M7.2 13h3.2" />
    </svg>
  );
}

/** A building. */
export function BuildingIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M3.2 16.8h13.6M5 16.8V4.4c0-.8.6-1.4 1.4-1.4h4.4c.8 0 1.4.6 1.4 1.4v12.4M12.2 8.2h2.4c.8 0 1.4.6 1.4 1.4v7.2" />
      <path d="M7.6 6.4h1.6M7.6 9.4h1.6M7.6 12.4h1.6" />
    </svg>
  );
}

/** Two links of a chain. */
export function LinkIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m8.6 11.4 2.8-2.8" />
      <path d="m11.1 13.2-1.5 1.5a3 3 0 0 1-4.3-4.3l1.5-1.5M8.9 6.8l1.5-1.5a3 3 0 0 1 4.3 4.3l-1.5 1.5" />
    </svg>
  );
}

/** A filled disc with an i: more about a step. The i takes the dialog's colour (--info-mark). */
export function InfoIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="6.4" fill="currentColor" />
      <path
        d="M8 7.3v3.6"
        style={{ stroke: 'var(--info-mark)' }}
        strokeWidth="1.4"
        strokeLinecap="round"
      />
      <circle cx="8" cy="5.2" r=".85" style={{ fill: 'var(--info-mark)' }} />
    </svg>
  );
}

/** A cross. */
export function CloseIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m5.5 5.5 9 9M14.5 5.5l-9 9" />
    </svg>
  );
}

/** A check mark. */
export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m4.8 10.4 3.4 3.4 7-7.2" />
    </svg>
  );
}
```

### account-setup/spring.ts

```tsx
/*
 * A spring as a curve over time. 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. The fold reads
 * the curve frame by frame.
 */

export type Spring = {
  /** How long it takes to settle, in ms. */
  duration: number;
  /** Where the curve is at `t`, from 0 to 1 of the duration. */
  at: (t: number) => number;
};

/**
 * 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;
  return {
    duration: Math.round(settled * 1000),
    at: (t) => (t >= 1 ? 1 : position(Math.max(0, t) * settled)),
  };
}
```

### account-setup/ghost-pointer.tsx

```tsx
'use client';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### account-setup/ghost-pointer.module.css

```css
/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its
   tip sits at the point it is moved to. Each shape sits in a small layer of its own, inside a
   layer that paints nothing and stays one between moves. */

.ghost {
  /* The arrow and the hand keep the system's look on both schemes, edged so they read on dark
     and light alike; the I-beam, a bare stroke, turns light at night to show on the dark fields. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --hand-edge: #141414;
  --beam: #141414;
  position: absolute;
  z-index: 40;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  will-change: transform;
}
@supports (color: light-dark(#fff, #000)) {
  .ghost {
    --beam: light-dark(#141414, #f2f2f4);
  }
}
.shape {
  position: absolute;
  width: 22px;
  height: 22px;
  /* The script shows one shape at a time by its scale. */
  transform: scale(0);
  will-change: transform;
}
.shape svg {
  display: block;
  width: 22px;
  height: 22px;
  overflow: visible;
  filter: drop-shadow(0 1px 1.2px rgb(0 0 0 / 0.25));
}
.shape[data-shape-of='arrow'] {
  top: -2.2px;
  left: -3.3px;
  transform-origin: 3.3px 2.2px;
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shape[data-shape-of='hand'] {
  top: -1.3px;
  left: -8.4px;
  transform-origin: 8.4px 1.3px;
  fill: var(--pointer-edge);
  stroke: var(--hand-edge);
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.shape[data-shape-of='text'] {
  top: -11px;
  left: -11px;
  transform-origin: 11px 11px;
  fill: none;
  stroke: var(--beam);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
  paint-order: stroke;
}

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

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