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.
Install#
npx shadcn@latest add @pixilui/approval-cardOnce per app, add the registry to 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
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"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> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/approval-card.tsx. Needs motion, clsx, tailwind-merge.
Preview#
Approve, and the whole card folds into a one-line receipt on a spring while focus lands on it.
Code#
"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.
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#
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | 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 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
- 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.