# Approval card

> The questions an agent asks before it acts, one at a time in a small card: pick, type, skip or page through, then send.

A card that asks before acting. Each question offers a few options and a line for something else; a single-choice pick moves on by itself, several choices wait for Continue, and the last answer sends them all. A 1/3 counter rolls as the questions slide past, Skip moves on without an answer, and the close button folds the card into a small button that brings it back where it was.

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

Category: AI.

## Anatomy

1. **Card**: One question at a time in a window as tall as it. Moving on slides the column and crossfades, 360 ms.
2. **Question**: One line, asked plainly, ending in a question mark.
3. **Option**: A checkbox when several can be picked, a radio when one. A single pick moves on by itself after 490 ms.
4. **Highlight**: Glides between rows under the pointer or the focus, so the list reads as a menu.
5. **Something else**: A line to type an answer of one’s own. Typing clears a single pick, and a pick clears the typing.
6. **Pager**: Up and down between questions, and a counter that rolls one line on each move.
7. **Skip**: Moves on with no answer, which the agent reads as “your call”. On the last question it closes the card.
8. **Continue**: Enabled once there is an answer; on the last question it says Send and sends them all.

## When to use

- An agent that needs a few decisions before it acts: scope, target, which files.
- Two to five short questions, each with 2 to 4 likely answers and room for something else.
- Answers the agent can’t guess safely, where skipping is allowed and means “your call”.
- In a chat or a side panel, where one small card asks without taking over the screen.

## When not to use

- A single yes or no before a risky action. Use a confirmation with the action named on its button.
- Long forms, or questions that need typing more than picking. Use a real form.
- Choices that depend on each other’s answers in many ways: the card asks in a fixed order.
- Telling the person what the agent already did. That is a message, not a question.

## Installation

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

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

const questions: ApprovalQuestion[] = [
  {
    id: 'scope',
    prompt: 'How far should the refactor go?',
    options: ['Just this file', 'The whole module', 'Everything that imports it'],
  },
  {
    id: 'checks',
    prompt: 'Which checks should run first?',
    options: ['Type check', 'Unit tests', 'Lint'],
    multiple: true,
  },
];

export function AgentQuestions({ onAnswer }: { onAnswer: (answers: Record<string, string[] | null>) => void }) {
  return <ApprovalCard questions={questions} onSubmit={onAnswer} autoplay={false} />;
}
```

## States

- **First question**: Nothing picked yet: Continue waits, Skip is always there.
- **One picked**: The radio fills in 200 ms; the card moves on by itself a moment later.
- **Several picked**: Checkboxes wait for Continue, so the reader can tick as many as they like.
- **Typed answer**: On the last question the button reads Send. Enter in the line sends too.
- **Closed**: Dismissed, or the last question skipped: a small button brings it back where it was.
- **Sent**: The card gives way to a green note, with Start over beside it.

## How it works

- The questions stand in one column inside a clipped window as tall as the current one. Changing question slides the column by a transform and crossfades the two while the window’s height follows, all three on 360 ms of the same soft ease-out. Offsets and heights are read from the layout, and a ResizeObserver keeps them right, so questions of any length line up.
- A single-choice pick fills its radio in 200 ms (the ink fades in, the dot scales up from nothing), then the card moves on 490 ms after the press; on the last question that press sends the answers. Checkbox questions wait for Continue. Typing something else clears a radio’s pick, and a pick clears the typing; Enter in the line continues.
- A soft highlight sits under the row the pointer or the focus is on and glides to the next by its top and height (220 ms), fading out when the pointer leaves, so the options read as a menu.
- The counter rolls: on a change the old and new numbers stand in a column one line tall that slides by one line with the Web Animations API, 40 ms after the questions set off and over 334 ms, slow in with a long, soft landing; then it is a single number again.
- Questions out of view are inert, so neither focus nor a screen reader reaches them; when focus was in the card it moves on with it, to the next question’s first option, the Start over button or the button that reopens the card.
- Sending swaps the card for a green “Answers sent” note that pops in (260 ms, from 95 %); Start over brings a fresh card in, rising 8 px as it fades in over 380 ms. Skip on the last question closes the card, as the close button does; reopened, it comes back on the same question with its answers.
- While on screen and untouched, a demo answers by itself: it hovers the options and picks one, ticks two boxes and presses Continue, types an answer of its own and sends, then starts over. Its answers never reach onSubmit and are not announced; any key or press hands the card to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. The card’s shadow is written once with all its layers, those a scheme does not use being transparent: a long soft lift by day, a short dark one at night.

## API reference

- **questions** (`ApprovalQuestion[]`, default `three sample questions`): The questions, asked in order: { id, prompt, options, multiple?, other?, demo? }. multiple makes the options checkboxes; other sets the free line’s placeholder, or false hides it; demo is what the autoplay answers (option indexes, or words to type).
- **onSubmit** (`(answers: ApprovalAnswers) => void`): Called once the last question is answered or Send is pressed, with each question’s answer by id: the options picked and anything typed, or null if skipped.
- **onDismiss** (`() => void`): Called when the reader closes the card, or skips the last question.
- **continueLabel** (`string`, default `'Continue'`): The button that moves on once a question is answered.
- **sendLabel** (`string`, default `'Send'`): The same button’s words on the last question.
- **skipLabel** (`string`, default `'Skip'`): The skip button’s words.
- **otherLabel** (`string`, default `'Something else…'`): The free line’s placeholder, unless a question sets its own.
- **sentLabel** (`string`, default `'Answers sent'`): The note shown once the answers are sent.
- **restartLabel** (`string`, default `'Start over'`): The button beside the note that brings a fresh card.
- **reopenLabel** (`string`, default `'Open approval'`): The button that brings a closed card back.
- **defaultState** (`ApprovalState`): Opens with the demo off on a question (question, from 0), with answers already in (answers, by question index), a row highlighted (highlight), or a phase: 'open', 'closed' or 'sent'.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo answers the questions by itself and starts over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a labelled section. Each question’s options are a radiogroup of radio buttons, or a group of checkboxes, named by the question, with aria-checked; the free line is a text field named “Something else”.
- Every option, the free line and every button is reachable with Tab; the up and down arrows also move between a question’s options and its free line, Space or Enter picks, and Enter in the free line continues.
- Questions out of view are inert and hidden from screen readers. When the question changes, the answers are sent or the card closes, a polite live region says so (“Question 2 of 3. Which mix-ins should we stock?”), and focus moves along if it was in the card. The visual 1/3 counter is hidden in favour of “Question 1 of 3”.
- The previous and next arrows are disabled at the ends, and Continue until the question has an answer. Focus rings are visible on every control.
- Under reduced motion the questions, the counter and the highlights change at once, the card and the note appear without their entrance, and the demo never plays.

## Source

### approval-card/approval-card.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './approval-card.module.css';

/*
 * Approval card: the questions an agent asks before it acts, one at a time, in a small card.
 * Each question has a few options and a line for something else; the reader answers, skips or
 * pages between them, and the last answer sends them all.
 *
 * - The questions sit in one column inside a window as tall as the current one. Moving to
 *   another slides the column by a transform and crossfades the two, while the window's height
 *   follows (both 360 ms on a soft ease-out); the column's offsets are read from the layout, so
 *   questions of any length line up.
 * - A single-choice question moves on by itself 490 ms after a pick, once the radio has filled
 *   (200 ms); on the last question the pick sends the answers. Several-choice questions wait for
 *   Continue. Typing something else clears the pick, and a pick clears the typing.
 * - A soft highlight glides between the rows under the pointer or the focus (top and height on
 *   220 ms), so the list reads as a menu.
 * - The 1/3 counter rolls: the old and new numbers stand in a column that slides one line, on its
 *   own slow-in, fast-out curve.
 * - Skip moves on without an answer; on the last question it closes the card, as the close
 *   button does, and a small button opens it again where it was. Sending swaps the card for a
 *   green "Answers sent" note that pops in, with Start over beside it.
 * - While on screen and untouched, a demo answers the three questions by itself: it hovers and
 *   picks, ticks two boxes and continues, types an answer of its own and sends, then starts over.
 *   Any real input hands the card to the reader.
 * - defaultState opens it on a given question, with answers already in, a row highlighted, or
 *   closed or sent, and with no demo: for documentation and tests.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type ApprovalQuestion = {
  /** Names the question in the answers. */
  id: string;
  prompt: string;
  options: string[];
  /** Any number of options (checkboxes) instead of one (radios). */
  multiple?: boolean;
  /** The free-text line's placeholder; false hides the line. */
  other?: string | false;
  /** What the demo answers: option indexes to pick, or words to type. */
  demo?: number[] | string;
};

/** Each question's answer: the options picked and anything typed, or null if skipped. */
export type ApprovalAnswers = Record<string, string[] | null>;

/** Where the card opens (defaultState): for documentation and tests. */
export type ApprovalState = {
  /** The question shown, from 0. */
  question?: number;
  /** Answers already in, by question index: option indexes picked, or words typed. */
  answers?: Record<number, number[] | string>;
  /** The row of the shown question under the highlight (the typing line is the last). */
  highlight?: number;
  /** Open (the default), closed or sent. */
  phase?: 'open' | 'closed' | 'sent';
};

const QUESTIONS: ApprovalQuestion[] = [
  {
    id: 'flavors',
    prompt: 'How many flavors should we launch?',
    options: ['Three (core line)', 'Five (full case)', 'Just one hero'],
    demo: [1],
  },
  {
    id: 'mix-ins',
    prompt: 'Which mix-ins should we stock?',
    options: ['Chocolate chips', 'Waffle bits', 'Sprinkles'],
    multiple: true,
    demo: [0, 2],
  },
  {
    id: 'market',
    prompt: 'Which market do we enter first?',
    options: ['Food trucks', 'Grocery freezers', 'Scoop shops'],
    demo: 'Farmers markets',
  },
];

/** After a single-choice pick, the card moves on this long after (the radio fills in 200 ms). */
const ADVANCE = 490;
/** The counter's roll: it sets off 40 ms after the column, slow in, with a long soft landing. */
const ROLL: KeyframeAnimationOptions = {
  duration: 334,
  delay: 40,
  easing: 'cubic-bezier(0.39, 0.05, 0.2, 0.975)',
  fill: 'backwards',
};

type Answer = { picked: number[]; other: string };
type Phase = 'open' | 'closed' | 'sent';
type Glide = { top: number; height: number; on: boolean };

const blank = (count: number): Answer[] =>
  Array.from({ length: count }, () => ({ picked: [], other: '' }));
const answered = (answer: Answer) => answer.picked.length > 0 || answer.other.trim() !== '';

export function ApprovalCard({
  questions = QUESTIONS,
  onSubmit,
  onDismiss,
  continueLabel = 'Continue',
  sendLabel = 'Send',
  skipLabel = 'Skip',
  otherLabel = 'Something else…',
  sentLabel = 'Answers sent',
  restartLabel = 'Start over',
  reopenLabel = 'Open approval',
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The questions, asked in order. */
  questions?: ApprovalQuestion[];
  /** Called with every answer when the last question is answered (or Send is pressed). */
  onSubmit?: (answers: ApprovalAnswers) => void;
  /** Called when the reader closes the card, or skips the last question. */
  onDismiss?: () => void;
  continueLabel?: string;
  /** The Continue button's words on the last question. */
  sendLabel?: string;
  skipLabel?: string;
  /** The free-text line's placeholder, unless a question sets its own. */
  otherLabel?: string;
  /** The note shown once the answers are sent. */
  sentLabel?: string;
  restartLabel?: string;
  /** The button that brings a closed card back. */
  reopenLabel?: string;
  /** Opens on a question, answers, highlight or phase, with no demo (see ApprovalState). */
  defaultState?: ApprovalState;
  /** While on screen and untouched, a demo answers the questions by itself. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const count = questions.length;
  const last = count - 1;
  const [index, setIndex] = useState(() =>
    Math.min(Math.max(defaultState?.question ?? 0, 0), last),
  );
  const [answers, setAnswers] = useState(() =>
    blank(count).map((answer, q) => {
      const given = defaultState?.answers?.[q];
      if (given === undefined) return answer;
      return typeof given === 'string'
        ? { picked: [], other: given }
        : { picked: given, other: '' };
    }),
  );
  const [phase, setPhase] = useState<Phase>(defaultState?.phase ?? 'open');
  /** A new key mounts the card afresh, so it plays its entrance again. */
  const [cardKey, setCardKey] = useState(0);
  const [glides, setGlides] = useState<Glide[]>(() =>
    Array.from({ length: count }, () => ({ top: 0, height: 0, on: false })),
  );
  /** The demo pressing a footer button: it shrinks as under a finger. */
  const [pressed, setPressed] = useState<'skip' | 'continue' | null>(null);
  const [message, setMessage] = useState('');
  /** The column's place and the window's height, read from the layout. */
  const [frame, setFrame] = useState({ offset: 0, height: 0 });

  const items = useRef<(HTMLDivElement | null)[]>([]);
  const rows = useRef<(HTMLElement | null)[][]>([]);
  const advanceTimer = useRef(0);
  /** Focus was in the card when it changed: it goes on to the new question, note or button. */
  const keepFocus = useRef(false);
  const reopenButton = useRef<HTMLButtonElement>(null);
  const restartButton = useRef<HTMLButtonElement>(null);
  const uid = useId();

  // Other questions (by their ids): start over with them.
  const signature = questions.map((question) => question.id).join('\n');
  const [seen, setSeen] = useState(signature);
  if (seen !== signature) {
    setSeen(signature);
    setIndex(0);
    setAnswers(blank(count));
    setGlides(Array.from({ length: count }, () => ({ top: 0, height: 0, on: false })));
    setPhase('open');
  }

  const current = answers[index] ?? { picked: [], other: '' };
  const canGo = answered(current);
  const demo = autoplay && playing && !defaultState;

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

  const noteFocus = () => {
    keepFocus.current = !!root.current?.contains(document.activeElement);
  };

  // The column's offset and the window's height follow the current question, and any change in
  // its size (a font loading, a narrower card).
  useLayoutEffect(() => {
    const read = () => {
      const item = items.current[index];
      if (!item) return;
      setFrame({ offset: item.offsetTop, height: item.offsetHeight });
    };
    read();
    const observer = new ResizeObserver(read);
    for (const item of items.current) if (item) observer.observe(item);
    return () => observer.disconnect();
  }, [index, cardKey, phase, count]);

  // Focus goes along with what it was in: the new question's first row, the note, the button.
  useEffect(() => {
    if (!keepFocus.current) return;
    keepFocus.current = false;
    if (phase === 'sent') restartButton.current?.focus({ preventScroll: true });
    else if (phase === 'closed') reopenButton.current?.focus({ preventScroll: true });
    else rows.current[index]?.[0]?.focus({ preventScroll: true });
  }, [index, phase, cardKey]);

  useEffect(() => () => window.clearTimeout(advanceTimer.current), []);

  // A highlighted row asked for by defaultState, once the rows are laid out.
  const startHighlight = defaultState?.highlight;
  useLayoutEffect(() => {
    if (startHighlight === undefined) return;
    const row = rows.current[index]?.[startHighlight];
    if (row)
      setGlides((list) =>
        list.map((g, i) =>
          i === index ? { top: row.offsetTop, height: row.offsetHeight, on: true } : g,
        ),
      );
    // Only on mount: from then on the highlight follows the reader.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const toAnswers = (list: Answer[]): ApprovalAnswers =>
    Object.fromEntries(
      questions.map((question, i) => {
        const answer = list[i];
        if (!answer || !answered(answer)) return [question.id, null];
        const words = answer.picked.map((pick) => question.options[pick]);
        if (answer.other.trim()) words.push(answer.other.trim());
        return [question.id, words];
      }),
    );

  /** Moves to a question; the demo's moves are not announced, so screen readers stay quiet. */
  const go = (to: number, byDemo = false) => {
    window.clearTimeout(advanceTimer.current);
    if (to < 0 || to > last) return;
    setIndex(to);
    if (!byDemo) announce(`Question ${to + 1} of ${count}. ${questions[to].prompt}`);
  };

  /** Sends the answers; the demo's never reach onSubmit. */
  const send = (list: Answer[], byDemo: boolean) => {
    window.clearTimeout(advanceTimer.current);
    setPhase('sent');
    if (byDemo) return;
    announce(sentLabel);
    onSubmit?.(toAnswers(list));
  };

  const close = (byDemo: boolean) => {
    window.clearTimeout(advanceTimer.current);
    setPhase('closed');
    if (byDemo) return;
    announce('Closed');
    onDismiss?.();
  };

  const forward = (list: Answer[], byDemo: boolean) => {
    if (index < last) go(index + 1, byDemo);
    else send(list, byDemo);
  };

  const pick = (q: number, option: number, byDemo = false) => {
    const question = questions[q];
    const next = answers.map((answer, i) => {
      if (i !== q) return answer;
      if (question.multiple) {
        const picked = answer.picked.includes(option)
          ? answer.picked.filter((p) => p !== option)
          : [...answer.picked, option].sort((a, b) => a - b);
        return { ...answer, picked };
      }
      return { picked: [option], other: '' };
    });
    setAnswers(next);
    if (!question.multiple) {
      // Let the radio fill, then move on (or send, on the last question).
      window.clearTimeout(advanceTimer.current);
      advanceTimer.current = window.setTimeout(() => {
        if (!byDemo) noteFocus();
        if (q < last) go(q + 1, byDemo);
        else send(next, byDemo);
      }, ADVANCE);
    }
  };

  const type = (q: number, text: string) => {
    window.clearTimeout(advanceTimer.current);
    setAnswers((list) =>
      list.map((answer, i) =>
        i !== q
          ? answer
          : { picked: questions[q].multiple || !text ? answer.picked : [], other: text },
      ),
    );
  };

  const skip = (byDemo = false) => {
    if (!byDemo) noteFocus();
    if (index < last) go(index + 1, byDemo);
    else close(byDemo);
  };

  const proceed = (byDemo = false) => {
    if (!canGo) return;
    if (!byDemo) noteFocus();
    forward(answers, byDemo);
  };

  const dismiss = () => {
    noteFocus();
    close(false);
  };

  const reopen = (byDemo = false) => {
    if (!byDemo) noteFocus();
    setPhase('open');
    setCardKey((key) => key + 1);
    if (!byDemo) announce(`Question ${index + 1} of ${count}. ${questions[index].prompt}`);
  };

  const restart = (byDemo = false) => {
    if (!byDemo) noteFocus();
    window.clearTimeout(advanceTimer.current);
    setAnswers(blank(count));
    setIndex(0);
    setGlides(Array.from({ length: count }, () => ({ top: 0, height: 0, on: false })));
    setPhase('open');
    setCardKey((key) => key + 1);
    if (!byDemo) announce(`Question 1 of ${count}. ${questions[0].prompt}`);
  };

  /** Moves the highlight of question q onto a row, or fades it where it is. */
  const glide = (q: number, row: HTMLElement | null) =>
    setGlides((list) =>
      list.map((g, i) =>
        i !== q
          ? g
          : row
            ? { top: row.offsetTop, height: row.offsetHeight, on: true }
            : { ...g, on: false },
      ),
    );

  // Up and down move between a list's rows, the typing line included.
  const onListKey = (q: number) => (event: ReactKeyboardEvent) => {
    if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
    const list = (rows.current[q] ?? []).filter((row): row is HTMLElement => !!row);
    const at = list.findIndex((row) => row.contains(document.activeElement));
    if (at < 0) return;
    event.preventDefault();
    const to = Math.min(list.length - 1, Math.max(0, at + (event.key === 'ArrowDown' ? 1 : -1)));
    const target = list[to];
    (target.querySelector('input') ?? target).focus();
  };

  // The demo: answer each question the way its `demo` says, send, rest, start over.
  const step = useEffectEvent(
    (action: 'restart' | 'reopen' | 'skip' | 'continue' | 'press', arg?: unknown) => {
      if (action === 'restart') restart(true);
      else if (action === 'reopen') reopen(true);
      else if (action === 'skip') skip(true);
      else if (action === 'continue') proceed(true);
      else if (action === 'press') setPressed(arg as 'skip' | 'continue' | null);
    },
  );
  const demoPick = useEffectEvent((q: number, option: number) => pick(q, option, true));
  const demoType = useEffectEvent((q: number, text: string) => type(q, text));
  const demoClear = useEffectEvent((q: number) =>
    setAnswers((list) => list.map((answer, i) => (i === q ? { picked: [], other: '' } : answer))),
  );
  const demoGlide = useEffectEvent((q: number, row: number | null) =>
    glide(q, row === null ? null : (rows.current[q]?.[row] ?? null)),
  );
  const demoState = useEffectEvent(() => ({ index, phase, answers, questions }));

  useEffect(() => {
    if (!demo) return;
    let alive = true;
    let timer = 0;
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        timer = window.setTimeout(() => (alive ? resolve() : reject(new Error('stopped'))), ms);
      });
    const press = async (button: 'skip' | 'continue') => {
      step('press', button);
      await wait(150);
      step('press', null);
      step(button);
    };
    const run = async () => {
      await wait(900);
      for (;;) {
        const { index: q, phase: now, answers: list, questions: asked } = demoState();
        if (now === 'sent') {
          await wait(2600);
          step('restart');
          await wait(1100);
          continue;
        }
        if (now === 'closed') {
          await wait(1200);
          step('reopen');
          await wait(900);
          continue;
        }
        const question = asked[q];
        const plan = question.demo ?? [0];
        // Start the question clean, so the demo's answer is the only one.
        if (list[q] && answered(list[q])) {
          demoClear(q);
          await wait(300);
        }
        if (typeof plan === 'string' && question.other !== false) {
          const line = question.options.length;
          demoGlide(q, 0);
          await wait(420);
          demoGlide(q, line);
          await wait(500);
          for (let i = 1; i <= plan.length; i++) {
            demoType(q, plan.slice(0, i));
            await wait(55 + Math.random() * 60);
          }
          await wait(450);
          demoGlide(q, null);
          await press('continue');
        } else {
          const picks = typeof plan === 'string' ? [0] : plan;
          for (const option of picks) {
            demoGlide(q, option);
            await wait(520);
            demoPick(q, option);
            await wait(question.multiple ? 380 : 0);
          }
          if (question.multiple) {
            demoGlide(q, null);
            await wait(350);
            await press('continue');
          } else {
            // It moves on by itself; the highlight fades as the question leaves.
            await wait(ADVANCE);
            demoGlide(q, null);
          }
        }
        await wait(1000);
      }
    };
    run().catch(() => {});
    return () => {
      alive = false;
      window.clearTimeout(timer);
      step('press', null);
      for (let q = 0; q < demoState().questions.length; q++) demoGlide(q, null);
    };
  }, [demo, signature]);

  const status = (
    <p className={styles.hidden} role="status">
      {message}
    </p>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.stage}>
        {phase === 'closed' && (
          <button
            ref={reopenButton}
            type="button"
            className={styles.reopen}
            data-part="reopen"
            onClick={() => reopen()}
          >
            {reopenLabel}
          </button>
        )}
        {phase === 'sent' && (
          <div className={styles.sent}>
            <span className={styles.note} data-part="sent">
              <span className={styles.noteIcon} aria-hidden="true">
                <Check size={11} />
              </span>
              {sentLabel}
            </span>
            <button
              ref={restartButton}
              type="button"
              className={styles.restart}
              onClick={() => restart()}
            >
              {restartLabel}
            </button>
          </div>
        )}
        {phase === 'open' && (
          <section
            key={cardKey}
            className={styles.card}
            data-part="card"
            aria-label="Questions before acting"
          >
            <button
              type="button"
              className={styles.close}
              data-part="close"
              aria-label="Dismiss"
              onClick={dismiss}
            >
              <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
                <path d="M3.5 3.5l7 7m0-7l-7 7" />
              </svg>
            </button>
            <div className={styles.pad}>
              <div className={styles.window} style={{ height: frame.height || undefined }}>
                <div
                  className={styles.track}
                  style={{ transform: `translate3d(0, ${-frame.offset}px, 0)` }}
                >
                  {questions.map((question, q) => {
                    const answer = answers[q] ?? { picked: [], other: '' };
                    const here = q === index;
                    const g = glides[q] ?? { top: 0, height: 0, on: false };
                    const titleId = `${uid}-q${q}`;
                    return (
                      <div
                        key={question.id}
                        ref={(element) => {
                          items.current[q] = element;
                        }}
                        className={styles.question}
                        data-here={here}
                        aria-hidden={!here || undefined}
                        inert={!here}
                      >
                        <div id={titleId} className={styles.prompt} data-part="prompt">
                          {question.prompt}
                        </div>
                        <div
                          className={styles.list}
                          role={question.multiple ? 'group' : 'radiogroup'}
                          aria-labelledby={titleId}
                          onKeyDown={onListKey(q)}
                          onPointerLeave={() => glide(q, null)}
                        >
                          <span
                            className={styles.glide}
                            data-part="highlight"
                            aria-hidden="true"
                            style={{ top: g.top, height: g.height, opacity: g.on ? 1 : 0 }}
                          />
                          {question.options.map((option, o) => {
                            const on = answer.picked.includes(o);
                            return (
                              <button
                                key={option}
                                ref={(element) => {
                                  (rows.current[q] ??= [])[o] = element;
                                }}
                                type="button"
                                role={question.multiple ? 'checkbox' : 'radio'}
                                aria-checked={on}
                                className={styles.row}
                                data-part="option"
                                data-on={on}
                                onPointerEnter={(event) => glide(q, event.currentTarget)}
                                onFocus={(event) => glide(q, event.currentTarget)}
                                onBlur={() => glide(q, null)}
                                onClick={() => pick(q, o)}
                              >
                                <span
                                  className={question.multiple ? styles.box : styles.radio}
                                  aria-hidden="true"
                                >
                                  {question.multiple ? (
                                    <Check size={12} />
                                  ) : (
                                    <span className={styles.dot} />
                                  )}
                                </span>
                                <span className={styles.label}>{option}</span>
                              </button>
                            );
                          })}
                          {question.other !== false && (
                            <label
                              ref={(element) => {
                                (rows.current[q] ??= [])[question.options.length] = element;
                              }}
                              className={styles.row}
                              data-part="other"
                              onPointerEnter={(event) => glide(q, event.currentTarget)}
                            >
                              <input
                                className={styles.other}
                                placeholder={question.other ?? otherLabel}
                                aria-label={(question.other ?? otherLabel).replace(/…$/, '')}
                                value={answer.other}
                                onChange={(event) => type(q, event.target.value)}
                                onFocus={(event) => glide(q, event.currentTarget.parentElement)}
                                onBlur={() => glide(q, null)}
                                onKeyDown={(event) => {
                                  if (event.key === 'Enter' && answered(answer)) {
                                    event.preventDefault();
                                    noteFocus();
                                    forward(answers, false);
                                  }
                                }}
                              />
                            </label>
                          )}
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
            <div className={styles.footer}>
              <div className={styles.pager} data-part="pager">
                <button
                  type="button"
                  className={styles.arrow}
                  aria-label="Previous question"
                  disabled={index === 0}
                  onClick={() => {
                    noteFocus();
                    go(index - 1);
                  }}
                >
                  <Chevron up />
                </button>
                <span className={styles.count} data-part="count" aria-hidden="true">
                  <Roll value={index + 1} />
                  <span>/</span>
                  <span>{count}</span>
                </span>
                <span className={styles.hidden}>
                  Question {index + 1} of {count}
                </span>
                <button
                  type="button"
                  className={styles.arrow}
                  aria-label="Next question"
                  disabled={index === last}
                  onClick={() => {
                    noteFocus();
                    go(index + 1);
                  }}
                >
                  <Chevron />
                </button>
              </div>
              <div className={styles.actions}>
                <button
                  type="button"
                  className={styles.skip}
                  data-part="skip"
                  data-pressed={pressed === 'skip'}
                  onClick={() => skip()}
                >
                  {skipLabel}
                </button>
                <button
                  type="button"
                  className={styles.continue}
                  data-part="continue"
                  data-pressed={pressed === 'continue'}
                  disabled={!canGo}
                  onClick={() => proceed()}
                >
                  {index === last ? sendLabel : continueLabel}
                </button>
              </div>
            </div>
          </section>
        )}
      </div>
      {status}
    </div>
  );
}

/**
 * The counter's number. On a change the old and new stand in a column, one line each, that rolls
 * to the new one; then it is a single number again.
 */
function Roll({ value }: { value: number }) {
  const [roll, setRoll] = useState({ from: value, to: value });
  if (roll.to !== value) setRoll({ from: roll.to, to: value });
  const column = useRef<HTMLSpanElement>(null);
  const lo = Math.min(roll.from, roll.to);
  const hi = Math.max(roll.from, roll.to);

  useLayoutEffect(() => {
    const element = column.current;
    if (roll.from === roll.to || !element) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setRoll({ from: roll.to, to: roll.to });
      return;
    }
    const animation = element.animate(
      [
        { transform: `translateY(${-(roll.from - lo)}em)` },
        { transform: `translateY(${-(roll.to - lo)}em)` },
      ],
      ROLL,
    );
    animation.onfinish = () => setRoll({ from: roll.to, to: roll.to });
    return () => animation.cancel();
  }, [roll, lo]);

  if (roll.from === roll.to) return <span className={styles.digits}>{value}</span>;
  return (
    <span className={styles.digits}>
      <span
        ref={column}
        className={styles.column}
        style={{ transform: `translateY(${-(roll.to - lo)}em)` }}
      >
        {Array.from({ length: hi - lo + 1 }, (_, i) => (
          <span key={lo + i}>{lo + i}</span>
        ))}
      </span>
    </span>
  );
}

function Check({ size }: { size: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 12 12" aria-hidden="true" className={styles.tick}>
      <path d="M2.4 6.3l2.4 2.4 4.8-5.2" />
    </svg>
  );
}

function Chevron({ up = false }: { up?: boolean }) {
  return (
    <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true" className={styles.chevron}>
      <path d={up ? 'M3.5 8.75L7 5.25l3.5 3.5' : 'M3.5 5.25L7 8.75l3.5-3.5'} />
    </svg>
  );
}
```

### approval-card/approval-card.module.css

```css
/* Approval card (approval-card.tsx): a soft grey stage with the card in its middle, 320 px at
   most, or at night a graphite stage with the card a step lighter.

   Colours are the tokens below, 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). The shadows are one list for both schemes, each layer coloured for
   one of them and clear in the other.

   Motion is transforms, opacity and the window's height: the column of questions slides inside a
   clipped window, the highlight glides by top and height within one short list. */

.root {
  /* The stage, the card, and the hover highlights. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --hover: oklch(97% 0.002 247.839);
  --hover-2: oklch(93.3% 0.003 247.86);
  /* Words: strong, for options, and quiet. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* The stage's edge, and the empty radios and boxes. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* Continue, and Continue under the pointer. */
  --accent: oklch(62.6% 0.205 254.947);
  --accent-hover: oklch(55.6% 0.187 255.617);
  /* The sent note. */
  --green: oklch(60.3% 0.155 150.883);
  --green-tint: oklch(95.8% 0.017 159.118);
  /* The card's ring and lift: the day's long soft shadows, the night's short dark ones. */
  --card-ring: oklch(94.6% 0.003 264.542);
  --day-8: rgb(0 0 0 / 0.03);
  --day-5: rgb(0 0 0 / 0.02);
  --day-3: rgb(0 0 0 / 0.012);
  --night: transparent;
  --night-btn: transparent;
  --btn-ring: oklch(91.2% 0.005 258.326);
  --btn-day: rgb(0 0 0 / 0.04);
  /* The same on both schemes: the focus ring. */
  --focus: oklch(62.6% 0.205 254.947);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --accent-hover: light-dark(oklch(55.6% 0.187 255.617), oklch(78.8% 0.113 248.33));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --green-tint: light-dark(oklch(95.8% 0.017 159.118), oklch(70.5% 0.154 153.814 / 0.14));
    --card-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --day-8: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-5: light-dark(rgb(0 0 0 / 0.02), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.012), transparent);
    --night: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --night-btn: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --btn-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --btn-day: light-dark(rgb(0 0 0 / 0.04), transparent);
  }
}
.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;
}

:where(.root) :where(button, input) {
  margin: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
}
:where(.root) :where(button) {
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* What the stage holds: the card, the sent note or the button that reopens the card, centred in
   a 480 px column. */
.stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  max-width: 480px;
}

/* The card. */
.card {
  position: relative;
  width: 100%;
  max-width: 320px;
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-ring),
    0 1px 2px var(--night),
    0 2px 6px var(--night),
    0 18px 47px var(--day-8),
    0 7.5px 19px var(--day-5),
    0 4px 10.5px var(--day-5),
    0 2.3px 5.8px var(--day-3),
    0 1.2px 3.1px var(--day-3),
    0 0.5px 1.3px var(--day-3);
  animation: rise 380ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

.close {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.close svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.28;
  stroke-linecap: round;
}
.close:hover {
  background: var(--hover);
  color: var(--ink);
}

.pad {
  padding: 12px;
}

/* The window shows one question; the column slides inside it. */
.window {
  overflow: hidden;
  transition: height 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.track {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 26px;
  will-change: transform;
  transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.question {
  opacity: 0;
  transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.question[data-here='true'] {
  opacity: 1;
}

.prompt {
  padding-right: 28px;
  font-size: 14px;
  font-weight: 500;
  line-height: 21px;
}

.list {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 10px;
}
/* The highlight under the pointer or the focus: it glides from row to row. */
.glide {
  position: absolute;
  right: 0;
  left: 0;
  border-radius: 8px;
  background: var(--hover);
  pointer-events: none;
  transition:
    top 220ms cubic-bezier(0.23, 1, 0.32, 1),
    height 220ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 150ms ease;
}

.row {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 6px;
  box-sizing: border-box;
  width: 100%;
  padding: 4px 8px 4px 4px;
  border-radius: 8px;
  text-align: left;
}
button.row {
  min-height: 24px;
}

/* The radio and the box: an outline, filled with ink when picked. */
.radio,
.box {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.5px var(--line-strong);
  color: transparent;
  transition:
    color 200ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.box {
  border-radius: 5px;
}
.row[data-on='true'] .radio,
.row[data-on='true'] .box {
  background: var(--ink);
  box-shadow: none;
  color: var(--canvas);
}
.dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--canvas);
  transform: scale(0);
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.row[data-on='true'] .dot {
  transform: scale(1);
}
.root .tick {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.label {
  min-width: 0;
  color: var(--ink-2);
  font-size: 13px;
  line-height: 1;
  transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.row[data-on='true'] .label {
  color: var(--ink);
}

.other {
  flex: 1;
  min-width: 0;
  padding: 0 0 0 6px;
  color: var(--ink);
  font-size: 13px;
  line-height: 1.5;
  outline: none;
}
.other::placeholder {
  color: var(--ink-3);
  opacity: 1;
}

/* The footer: the pager on the left, Skip and Continue on the right. */
.footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px;
}
.pager {
  display: flex;
  align-items: center;
  gap: 4px;
  color: var(--ink-3);
}
.arrow {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  padding: 0;
  border-radius: 5px;
  transition: color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.arrow:enabled:hover {
  color: var(--ink);
}
.arrow:disabled {
  cursor: default;
  opacity: 0.3;
}
.root .chevron {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.17;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.count {
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: -0.1px;
}
/* The rolling number: one line tall, its column sliding inside. */
.digits {
  display: inline-block;
  height: 1em;
  overflow: hidden;
  overflow: clip;
}
.column {
  display: flex;
  flex-direction: column;
}

.actions {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-right: -2px;
}
.skip,
.continue {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 27px;
  padding: 0 12px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  user-select: none;
  transition:
    transform 150ms cubic-bezier(0, 0, 0.2, 1),
    background-color 150ms cubic-bezier(0, 0, 0.2, 1),
    opacity 150ms cubic-bezier(0, 0, 0.2, 1);
}
.skip:active,
.continue:active,
.skip[data-pressed='true'],
.continue[data-pressed='true'] {
  transform: scale(0.96);
}
.skip {
  background: var(--hover-2);
  color: var(--ink);
}
.skip:hover {
  background: var(--line-strong);
}
.continue {
  background: var(--accent);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.14);
  color: #fff;
}
.continue:hover {
  background: var(--accent-hover);
}
.continue[data-pressed='true'] {
  background: var(--accent-hover);
}
.continue:disabled {
  opacity: 0.5;
  pointer-events: none;
}

/* Closed: a small button brings the card back. */
.reopen {
  align-self: flex-start;
  padding: 8px 12px;
  border-radius: 8px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--btn-ring),
    0 0 4px var(--btn-day),
    0 1px 2px var(--night-btn);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.5;
  transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.reopen:hover {
  background: var(--hover);
}

/* Sent: a green note pops in, with Start over beside it. */
.sent {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  max-width: 320px;
  animation: pop 260ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
.note {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px 4px 4px;
  border-radius: 999px;
  background: var(--green-tint);
  color: var(--green);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.5;
}
.noteIcon {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--green);
  color: #fff;
}
.noteIcon .root .tick {
  width: 12px;
  height: 12px;
  stroke-width: 1.65;
}
.restart {
  padding: 0;
  color: var(--ink-3);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.5;
  transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.restart:hover {
  color: var(--ink);
}

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

@media (prefers-reduced-motion: reduce) {
  .card,
  .sent {
    animation: none;
  }
  .window,
  .track,
  .question,
  .glide,
  .radio,
  .box,
  .dot,
  .label,
  .skip,
  .continue {
    transition: none;
  }
}
```

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