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.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/approval-card

Once per app, add the registry to components.json:

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

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/approval-card.json

Adds components/pixilui/approval-card.tsx. Needs motion, clsx, tailwind-merge.

Preview#

Risk
Status
Theme
Accent

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
"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#

Use it when

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

Use something else when

  • 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.
Example
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>
  );
}

API#

PropTypeDefaultDescription
titlerequiredstringWhat the agent wants to do, in one sentence.
itemsApprovalCardItem[][]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.
sourceReactNodeWho is asking: the agent's name, a run ID, when. Sits beside the actions.
iconReactNodeThe 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.
receiptReactNodeThe receipt line once decided. Defaults to the title, or the note when rejected with one.
receiptIconReactNodeMark in the receipt tile. Defaults to a drawn check (approved) or cross (rejected).
labelsPartial<ApprovalCardLabels>Copy overrides, for verbs that fit the action ("Approve and send") and for translation.

Also takes 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#

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