# Approval card

An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note.

Tier: free. Type: pattern. Category: ai. Tags: agent, approval, keyboard, motion.

## Install

```bash
npx shadcn@latest add @pixilui/approval-card
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/approval-card.tsx`. npm: `motion`.

## Preview

Live preview: https://pixilui.com/components/approval-card

Knobs:

- `risk`: `medium`, `low`, `high`
- `status`: `pending`, `approved`, `rejected`

Signature: Approve, and the whole card folds into a one-line receipt on a spring while focus lands on it.

## Code

`components/pixilui/approval-card.tsx`

```tsx
"use client";

import {
  forwardRef,
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type FormEvent,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { useMorphSize } from "./lib/use-morph-size";
import { motionTokens, reducedTransition } from "./motion-tokens";
import { Button } from "./button";
import { Input } from "./input";

export type ApprovalCardStatus = "pending" | "approved" | "rejected";
export type ApprovalCardRisk = "low" | "medium" | "high";

export type ApprovalCardItem = {
  /** What the row is, in a word or two: "Audience", "Cost". */
  label: string;
  /** The value the person checks before deciding. */
  value: ReactNode;
  /** Set tabular mono figures for amounts, counts and IDs. */
  numeric?: boolean;
};

export type ApprovalCardLabels = {
  approve: string;
  confirm: string;
  approving: string;
  reject: string;
  rejectWithNote: string;
  rejecting: string;
  cancel: string;
  noteLabel: string;
  notePlaceholder: string;
  approved: string;
  rejected: string;
  error: string;
  confirmHint: string;
  low: string;
  medium: string;
  high: string;
};

const defaultLabels: ApprovalCardLabels = {
  approve: "Approve",
  confirm: "Yes, approve",
  approving: "Approving",
  reject: "Reject",
  rejectWithNote: "Reject with note",
  rejecting: "Rejecting",
  cancel: "Cancel",
  noteLabel: "Note for the agent",
  notePlaceholder: "What should it change?",
  approved: "Approved",
  rejected: "Rejected",
  error: "That did not go through. Nothing changed, so you can try again.",
  confirmHint: "High risk. Press approve again to confirm.",
  low: "Low risk",
  medium: "Medium risk",
  high: "High risk",
};

export type ApprovalCardProps = Omit<ComponentPropsWithoutRef<"section">, "title"> & {
  /** What the agent wants to do, in one sentence. */
  title: string;
  /** Rows the person should see before deciding. */
  items?: ApprovalCardItem[];
  /** Blast radius. High risk asks for a second press before it approves. */
  risk?: ApprovalCardRisk;
  /** Controlled decision. Leave it out and the card keeps its own once the callbacks settle. */
  status?: ApprovalCardStatus;
  /** Starting decision when uncontrolled. */
  defaultStatus?: ApprovalCardStatus;
  /** Who is asking: the agent's name, a run ID, when. Sits beside the actions. */
  source?: ReactNode;
  /** The agent or tool mark, shown in a small tile beside the title. */
  icon?: ReactNode;
  /** Runs on approve. Return a promise and the card shows its own pending state, then folds into a receipt. */
  onApprove?: () => void | Promise<void>;
  /** Runs on reject with the note the person wrote (possibly empty). Return a promise for a pending state. */
  onReject?: (note: string) => void | Promise<void>;
  /** The receipt line once decided. Defaults to the title, or the note when rejected with one. */
  receipt?: ReactNode;
  /** Mark in the receipt tile. Defaults to a drawn check (approved) or cross (rejected). */
  receiptIcon?: ReactNode;
  /** Copy overrides, for verbs that fit the action ("Approve and send") and for translation. */
  labels?: Partial<ApprovalCardLabels>;
};

type Phase = "idle" | "armed" | "approving" | "noting" | "rejecting";

const riskStyles: Record<ApprovalCardRisk, string> = {
  low: "bg-success-subtle text-success",
  medium: "bg-warning-subtle text-warning",
  high: "bg-danger-subtle text-danger",
};

function ApprovalCardMark({ status, reduced }: { status: Exclude<ApprovalCardStatus, "pending">; reduced: boolean }) {
  const draw = reduced
    ? { initial: { pathLength: 1 }, transition: reducedTransition }
    : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter } };
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">
      {status === "approved" ? (
        <motion.path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
      ) : (
        <motion.path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
      )}
    </svg>
  );
}

export const ApprovalCard = forwardRef<HTMLElement, ApprovalCardProps>(function ApprovalCard(
  {
    title,
    items = [],
    risk = "low",
    status: statusProp,
    defaultStatus = "pending",
    source,
    icon,
    onApprove,
    onReject,
    receipt,
    receiptIcon,
    labels: labelsProp,
    className,
    onKeyDown,
    ...rest
  },
  forwardedRef,
) {
  const labels = { ...defaultLabels, ...labelsProp };
  const reduced = useReducedMotion() ?? false;
  const titleId = useId();
  const [innerStatus, setInnerStatus] = useState<ApprovalCardStatus>(defaultStatus);
  const [phase, setPhase] = useState<Phase>("idle");
  const [note, setNote] = useState("");
  const [submittedNote, setSubmittedNote] = useState("");
  const [error, setError] = useState(false);
  const status = statusProp ?? innerStatus;
  const decided = status !== "pending";

  const sectionRef = useRef<HTMLElement | null>(null);
  const rejectRef = useRef<HTMLButtonElement | null>(null);
  const noteRef = useRef<HTMLInputElement | null>(null);
  const receiptRef = useRef<HTMLDivElement | null>(null);
  const focusAfter = useRef<"note" | "reject" | "receipt" | null>(null);

  const { containerRef, measureRef } = useMorphSize<HTMLElement, HTMLDivElement>("height", `${status}:${phase === "noting" || phase === "rejecting"}:${error}`);

  const setRef = useCallback(
    (node: HTMLElement | null) => {
      sectionRef.current = node;
      containerRef.current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef) forwardedRef.current = node;
    },
    [forwardedRef, containerRef],
  );

  // Focus follows the morph: into the note, back to Reject, onto the receipt.
  useEffect(() => {
    const target = focusAfter.current;
    if (!target) return;
    const el = target === "note" ? noteRef.current : target === "reject" ? rejectRef.current : receiptRef.current;
    if (el) {
      el.focus();
      focusAfter.current = null;
    }
  });

  const focusInside = () => Boolean(sectionRef.current?.contains(sectionRef.current.ownerDocument.activeElement));

  const approve = async () => {
    if (decided || phase === "approving" || phase === "rejecting" || phase === "noting") return;
    if (risk === "high" && phase !== "armed") {
      setPhase("armed");
      return;
    }
    setError(false);
    setPhase("approving");
    try {
      await onApprove?.();
      if (focusInside()) focusAfter.current = "receipt";
      setInnerStatus("approved");
      setPhase("idle");
    } catch {
      setError(true);
      setPhase("idle");
    }
  };

  const openNote = () => {
    if (decided || phase === "approving" || phase === "rejecting") return;
    setError(false);
    setPhase("noting");
    focusAfter.current = "note";
  };

  const closeNote = () => {
    setPhase("idle");
    focusAfter.current = "reject";
  };

  const reject = async (event: FormEvent) => {
    event.preventDefault();
    if (phase === "rejecting") return;
    setError(false);
    setPhase("rejecting");
    const text = note.trim();
    try {
      await onReject?.(text);
      if (focusInside()) focusAfter.current = "receipt";
      setSubmittedNote(text);
      setInnerStatus("rejected");
      setPhase("idle");
    } catch {
      setError(true);
      setPhase("noting");
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented || decided) return;
    if (event.key === "Enter" && event.target === event.currentTarget) {
      event.preventDefault();
      void approve();
    } else if (event.key === "Escape") {
      if (phase === "noting") {
        event.preventDefault();
        event.stopPropagation();
        closeNote();
      } else if (phase === "armed" || document.activeElement !== rejectRef.current) {
        event.preventDefault();
        event.stopPropagation();
        if (phase === "armed") setPhase("idle");
        rejectRef.current?.focus();
      }
    }
  };

  const announcement =
    phase === "approving"
      ? labels.approving
      : phase === "rejecting"
        ? labels.rejecting
        : phase === "armed"
          ? labels.confirmHint
          : error
            ? labels.error
            : status === "approved"
              ? labels.approved
              : status === "rejected"
                ? labels.rejected
                : "";

  const leave = reduced
    ? { opacity: 0, transition: reducedTransition }
    : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } };
  const arrive = (index: number) =>
    reduced
      ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: reducedTransition }
      : {
          initial: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
          animate: { opacity: 1, filter: "blur(0px)" },
          transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * (index + 1) },
        };

  const noteOpen = phase === "noting" || phase === "rejecting";
  const receiptText = receipt ?? (status === "rejected" && submittedNote ? submittedNote : title);

  return (
    <section
      ref={setRef}
      tabIndex={0}
      aria-labelledby={titleId}
      data-status={status}
      data-risk={risk}
      data-phase={phase}
      className={cn(
        "relative w-full min-w-0 overflow-hidden rounded-panel bg-surface font-body text-14 text-foreground shadow-raised outline-none hairline focus-visible:focus-ring",
        className,
      )}
      onKeyDown={handleKeyDown}
      {...rest}
    >
      <div ref={measureRef}>
        <AnimatePresence initial={false} mode="popLayout">
          {decided ? (
            <motion.div
              key={`receipt-${status}`}
              ref={receiptRef}
              tabIndex={-1}
              data-slot="approval-card-receipt"
              className="flex min-w-0 items-center gap-3 rounded-panel px-4 py-3 outline-none focus-visible:focus-ring"
              exit={leave}
            >
              <motion.span
                className={cn(
                  "grid size-7 shrink-0 place-items-center rounded-pill [&_svg]:size-3.5",
                  status === "approved" ? "bg-success-subtle text-success" : "bg-danger-subtle text-danger",
                )}
                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                transition={reduced ? reducedTransition : { ...motionTokens.spring.snappy, delay: motionTokens.stagger.item }}
              >
                {receiptIcon ?? <ApprovalCardMark status={status} reduced={reduced} />}
              </motion.span>
              <motion.span className="shrink-0 font-medium" {...arrive(1)}>
                {status === "approved" ? labels.approved : labels.rejected}
              </motion.span>
              <motion.span id={titleId} className="min-w-0 truncate text-secondary" {...arrive(2)}>
                {receiptText}
              </motion.span>
            </motion.div>
          ) : (
            <motion.div key="body" className="flex flex-col gap-4 p-5" exit={leave}>
              <header className="flex items-start gap-3">
                {icon ? (
                  <span className="grid size-9 shrink-0 place-items-center rounded-control bg-surface-muted text-foreground [&_svg]:size-4.5">{icon}</span>
                ) : null}
                <h3 id={titleId} className="min-w-0 flex-1 text-pretty pt-1.5 text-16 font-medium text-foreground">
                  {title}
                </h3>
                <span
                  data-slot="approval-card-risk"
                  className={cn("mt-1 inline-flex h-6 shrink-0 items-center gap-1.5 rounded-pill px-2.5 text-12 font-medium", riskStyles[risk])}
                >
                  <span aria-hidden="true" className="size-1.5 rounded-pill bg-current" />
                  {labels[risk]}
                </span>
              </header>

              {items.length > 0 ? (
                <dl className="flex flex-col rounded-control bg-surface-muted px-3.5 py-1">
                  {items.map((item, index) => (
                    <div
                      key={`${item.label}-${index}`}
                      className={cn("flex items-baseline justify-between gap-4 py-2.5", index > 0 && "border-t border-border-subtle")}
                    >
                      <dt className="shrink-0 text-secondary">{item.label}</dt>
                      <dd className={cn("min-w-0 break-words text-right text-foreground", item.numeric && "numeric")}>{item.value}</dd>
                    </div>
                  ))}
                </dl>
              ) : null}

              {error ? <p className="text-14 text-danger">{labels.error}</p> : null}

              {noteOpen ? (
                <form className="flex flex-wrap items-center gap-2" onSubmit={reject}>
                  <Input
                    ref={noteRef}
                    size="sm"
                    aria-label={labels.noteLabel}
                    placeholder={labels.notePlaceholder}
                    value={note}
                    onChange={(event) => setNote(event.target.value)}
                    className="min-w-0 flex-1 basis-48"
                    autoComplete="off"
                  />
                  <div className="ms-auto flex gap-2">
                    <Button type="button" variant="ghost" size="sm" onClick={closeNote} disabled={phase === "rejecting"}>
                      {labels.cancel}
                    </Button>
                    <Button type="submit" variant="secondary" size="sm" loading={phase === "rejecting"}>
                      {phase === "rejecting" ? labels.rejecting : note.trim() ? labels.rejectWithNote : labels.reject}
                    </Button>
                  </div>
                </form>
              ) : (
                <footer className="flex flex-wrap items-center gap-3">
                  {source ? <div className="min-w-0 flex-1 basis-40 truncate text-muted">{source}</div> : null}
                  <div className="ms-auto flex gap-2">
                    <Button ref={rejectRef} variant="secondary" size="sm" onClick={openNote} aria-disabled={phase === "approving" || undefined}>
                      {labels.reject}
                    </Button>
                    <Button
                      variant={phase === "armed" ? "danger" : "primary"}
                      size="sm"
                      loading={phase === "approving"}
                      onClick={() => void approve()}
                      onBlur={() => phase === "armed" && setPhase("idle")}
                    >
                      {phase === "approving" ? labels.approving : phase === "armed" ? labels.confirm : labels.approve}
                    </Button>
                  </div>
                </footer>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <p className="sr-only" aria-live="polite">
        {announcement}
      </p>
    </section>
  );
});
```

## Usage

When to use:

- An agent is about to do something with a cost or a blast radius: send, pay, delete, deploy, email customers.
- A human has to sign off inside a run timeline or an inbox of agent requests.

When not to use:

- A yes or no with no consequence: use a button.
- A plan with many steps to review: use agent-plan.
- Confirming your own destructive action: use hold-to-confirm.

```tsx
import { useState } from "react";
import { Mail, RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { ApprovalCard, type ApprovalCardItem } from "@/components/pixilui/approval-card";

export const knobs = {
  risk: ["medium", "low", "high"],
  status: ["pending", "approved", "rejected"],
} as const;

const items: ApprovalCardItem[] = [
  { label: "Template", value: "Q3 renewal, second notice" },
  { label: "Audience", value: "3,412 customers on annual plans" },
  { label: "Sends from", value: "billing@northwind.co" },
  { label: "Estimated cost", value: "$41.30", numeric: true },
];

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

export default function ApprovalCardDemo({ risk = "medium", status = "pending" }: KnobValues<typeof knobs>) {
  const [round, setRound] = useState(0);
  const [decided, setDecided] = useState(false);
  // "Ask again" restarts from pending, until the status knob moves.
  const [resetFrom, setResetFrom] = useState<string | null>(null);
  const start = resetFrom === status ? "pending" : status;

  return (
    <div className="flex w-full max-w-md flex-col items-center gap-4">
      <ApprovalCard
        key={`${risk}-${status}-${round}`}
        title="Send renewal reminders to 3,412 customers"
        icon={<Mail strokeWidth={1.5} />}
        source="Billing agent, run 4821"
        items={items}
        risk={risk}
        defaultStatus={start}
        labels={{ approve: "Approve and send" }}
        onApprove={async () => {
          await wait(1100);
          setDecided(true);
        }}
        onReject={async () => {
          await wait(700);
          setDecided(true);
        }}
      />
      {decided || start !== "pending" ? (
        <Button
          variant="ghost"
          size="sm"
          icon={<RotateCcw strokeWidth={1.5} />}
          onClick={() => {
            setDecided(false);
            setResetFrom(status);
            setRound((n) => n + 1);
          }}
        >
          Ask again
        </Button>
      ) : null}
    </div>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` (required) | `string` |  | What the agent wants to do, in one sentence. |
| `items` | `ApprovalCardItem[]` | `[]` | Rows the person should see before deciding. |
| `risk` | `"low" \| "medium" \| "high"` | `"low"` | Blast radius. High risk asks for a second press before it approves. |
| `status` | `"pending" \| "approved" \| "rejected"` |  | Controlled decision. Leave it out and the card keeps its own once the callbacks settle. |
| `defaultStatus` | `"pending" \| "approved" \| "rejected"` | `"pending"` | Starting decision when uncontrolled. |
| `source` | `ReactNode` |  | Who is asking: the agent's name, a run ID, when. Sits beside the actions. |
| `icon` | `ReactNode` |  | The agent or tool mark, shown in a small tile beside the title. |
| `onApprove` | `() => void \| Promise<void>` |  | Runs on approve. Return a promise and the card shows its own pending state, then folds into a receipt. |
| `onReject` | `(note: string) => void \| Promise<void>` |  | Runs on reject with the note the person wrote (possibly empty). Return a promise for a pending state. |
| `receipt` | `ReactNode` |  | The receipt line once decided. Defaults to the title, or the note when rejected with one. |
| `receiptIcon` | `ReactNode` |  | Mark in the receipt tile. Defaults to a drawn check (approved) or cross (rejected). |
| `labels` | `Partial<ApprovalCardLabels>` |  | Copy overrides, for verbs that fit the action ("Approve and send") and for translation. |

Also accepts every native `<section>` attribute except `title`, and a ref to it.

## Keyboard

- `Tab`: Focuses the card, then Reject, then Approve.
- `Enter`: Approves when the card itself is focused. High risk asks for a second press.
- `Esc`: Focuses Reject. In the note, closes it and returns to Reject. On an armed high-risk approval, disarms it.
- `Enter`: In the note field, sends the rejection with the note.

## Accessibility

- The card is a section labelled by its title, so it reads as a named region.
- Approve and Reject are real buttons; pending work uses aria-busy, never disabled, so focus stays put.
- Status changes (approving, approved, rejected, errors, the high-risk confirm) are announced in a polite live region.
- When the card folds, focus moves to the receipt so keyboard users are never dropped to the page.

## Motion

- Approving folds the card into a one-line receipt: its height shrinks on the smooth spring while the radius and surface stay one material.
- The old face fades out in 120ms through a 2px blur; the receipt's parts arrive in reading order 35ms apart.
- The receipt check draws itself and scales in on the snappy spring. No glow, no confetti.
- Opening the note grows the card on the morph spring; the Approve label morphs to Approving and to Yes, approve on high risk.
- Reduced motion: the fold is instant and the receipt fades in.

## Responsive

- Fills its container. When the source and the actions no longer fit on one line, the actions wrap to their own line, right-aligned; the note field does the same.
- Uses flex-wrap, not container queries, so it keeps its width inside shrink-to-fit parents.
- Long titles wrap; long values break inside their row.

## Performance

- One ResizeObserver per card drives the fold; nothing animates on mount.

## Notes for AI

- Return a promise from onApprove and onReject: the card shows pending, recovers on a thrown error, and folds when it resolves.
- Name the action in labels.approve ("Approve and send"); keep the title one sentence in the agent's words.
- Put amounts, counts and IDs in items with numeric: true so they set in tabular figures.
- Pass status to control it from your store; leave it out and the card tracks its own decision.
- Pass the agent's mark as icon; the component never imports an icon library.

## Related

- `hold-to-confirm` (planned)
- `agent-plan` (planned)
- [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
- [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.
- [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place.
