Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Approval card

New

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

approval-card/approval-card.tsx
Live · interactive
How many flavors should we launch?

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/approval-card of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/approval-card.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

agent-questions.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} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
questionsApprovalQuestion[]three sample questionsThe 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.
continueLabelstring'Continue'The button that moves on once a question is answered.
sendLabelstring'Send'The same button’s words on the last question.
skipLabelstring'Skip'The skip button’s words.
otherLabelstring'Something else…'The free line’s placeholder, unless a question sets its own.
sentLabelstring'Answers sent'The note shown once the answers are sent.
restartLabelstring'Start over'The button beside the note that brings a fresh card.
reopenLabelstring'Open approval'The button that brings a closed card back.
defaultStateApprovalState—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'.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

approval-card.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
approval-card.module.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);
use-autoplay.ts
'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;
  • 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.