free204 lines · framer-motion
Approval Card
The agent asks. A human decides. A request card with risk level, context chips, a diff preview and a countdown. Approve or reject with a click or a key, and a stamp lands on the card.
- Use it for
- Merges, payments, emails to customers, anything an agent should not do alone.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
deploy-bot asks
Merge PR #482 into main
60
Tests passed. The change touches billing, so a human has to sign off.
Medium riskRepo: acme/webFiles: 4 changedEst. cost: $0.04
- const fee = total * 0.029+ const fee = Math.round(total * 0.029 * 100) / 100
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
export type ApprovalRisk = "low" | "medium" | "high";
export type ApprovalDecision = "approved" | "rejected" | "expired";
export interface ApprovalCardProps {
agent?: string;
title?: string;
summary?: string;
risk?: ApprovalRisk;
details?: { label: string; value: string }[];
/** Seconds before the request expires. 0 = never. */
timeout?: number;
/** Show a unified diff preview of the proposed change. */
diff?: string[];
onDecision?: (decision: ApprovalDecision) => void;
/** Resolve automatically after N ms (for demos). */
autoDecision?: { decision: "approved" | "rejected"; after: number };
className?: string;
}
const riskStyles: Record<ApprovalRisk, { label: string; dot: string; chip: string }> = {
low: { label: "Low risk", dot: "bg-ok", chip: "text-ok bg-ok/10 ring-ok/25" },
medium: { label: "Medium risk", dot: "bg-warn", chip: "text-warn bg-warn/10 ring-warn/25" },
high: { label: "High risk", dot: "bg-destructive", chip: "text-destructive bg-destructive/10 ring-destructive/25" },
};
export function ApprovalCard({
agent = "deploy-bot",
title = "Merge PR #482 into main",
summary = "Tests passed. The change touches billing, so a human has to sign off.",
risk = "medium",
details = [
{ label: "Repo", value: "acme/web" },
{ label: "Files", value: "4 changed" },
{ label: "Est. cost", value: "$0.04" },
],
timeout = 60,
diff = ["- const fee = total * 0.029", "+ const fee = Math.round(total * 0.029 * 100) / 100"],
onDecision,
autoDecision,
className,
}: ApprovalCardProps) {
const reduce = useReducedMotion();
const [decision, setDecision] = React.useState<ApprovalDecision | null>(null);
const [left, setLeft] = React.useState(timeout);
const titleId = React.useId();
const decide = React.useCallback(
(d: ApprovalDecision) => {
setDecision((cur) => {
if (cur) return cur;
onDecision?.(d);
return d;
});
},
[onDecision],
);
React.useEffect(() => {
setLeft(timeout);
setDecision(null);
}, [timeout]);
React.useEffect(() => {
if (!timeout || decision) return;
const t = setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
return () => clearInterval(t);
}, [timeout, decision]);
React.useEffect(() => {
if (timeout && left === 0 && !decision) decide("expired");
}, [left, timeout, decision, decide]);
React.useEffect(() => {
if (!autoDecision) return;
const t = setTimeout(() => decide(autoDecision.decision), autoDecision.after);
return () => clearTimeout(t);
}, [autoDecision, decide]);
const r = riskStyles[risk];
const pct = timeout ? left / timeout : 1;
return (
<motion.section
role="group"
aria-labelledby={titleId}
initial={reduce ? false : { opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
onKeyDown={(e) => {
if (decision) return;
if (e.key === "a" || e.key === "A") decide("approved");
if (e.key === "r" || e.key === "R") decide("rejected");
}}
className={cn(
"relative w-full max-w-md overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-[0_1px_0_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.18)]",
className,
)}
>
<header className="flex items-center gap-3 px-5 pt-4">
<span className="grid size-8 place-items-center rounded-lg bg-primary font-mono text-[11px] font-bold text-primary-foreground" aria-hidden>
{agent.slice(0, 2).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">{agent} asks</p>
<h3 id={titleId} className="truncate text-[15px] font-semibold leading-tight">
{title}
</h3>
</div>
{timeout > 0 && !decision && (
<div className="relative size-9 shrink-0" aria-label={`${left} seconds left`} role="timer">
<svg viewBox="0 0 36 36" className="size-9 -rotate-90">
<circle cx="18" cy="18" r="15" fill="none" className="stroke-muted" strokeWidth="3" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="3" strokeLinecap="round"
className={cn("transition-[stroke-dashoffset] duration-1000 ease-linear", pct < 0.25 ? "stroke-destructive" : "stroke-signal")}
strokeDasharray={94.25} strokeDashoffset={94.25 * (1 - pct)}
/>
</svg>
<span className="absolute inset-0 grid place-items-center font-mono text-[10px] tabular-nums">{left}</span>
</div>
)}
</header>
<div className="space-y-3 px-5 pb-4 pt-3">
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
<div className="flex flex-wrap items-center gap-2">
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1", r.chip)}>
<span className={cn("size-1.5 rounded-full", r.dot)} />
{r.label}
</span>
{details.map((d) => (
<span key={d.label} className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{d.label}: <span className="font-medium text-foreground">{d.value}</span>
</span>
))}
</div>
{diff.length > 0 && (
<pre className="overflow-x-auto rounded-lg bg-muted/70 p-3 font-mono text-[11.5px] leading-5 scrollbar-thin">
{diff.map((line, i) => (
<div key={i} className={cn(line.startsWith("+") && "text-ok", line.startsWith("-") && "text-destructive")}>
{line}
</div>
))}
</pre>
)}
</div>
<footer className="flex gap-2 border-t border-border bg-muted/40 px-5 py-3">
<button
type="button"
disabled={!!decision}
onClick={() => decide("rejected")}
className="h-9 flex-1 rounded-lg border border-border bg-card text-sm font-medium transition hover:bg-muted disabled:opacity-50"
>
Reject <kbd className="ml-1 font-mono text-[10px] text-muted-foreground">R</kbd>
</button>
<button
type="button"
disabled={!!decision}
onClick={() => decide("approved")}
className="h-9 flex-1 rounded-lg bg-primary text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
>
Approve <kbd className="ml-1 font-mono text-[10px] opacity-60">A</kbd>
</button>
</footer>
<AnimatePresence>
{decision && (
<motion.div
key="stamp"
role="status"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-0 grid place-items-center bg-card/70"
>
<motion.span
initial={reduce ? { opacity: 0, rotate: -8 } : { opacity: 0, scale: 1.6, rotate: -14 }}
animate={{ opacity: 1, scale: 1, rotate: -8 }}
transition={{ type: "spring", stiffness: 500, damping: 22 }}
className={cn(
"rounded-md border-[3px] bg-card px-4 py-1 font-mono text-xl font-black uppercase tracking-[0.2em]",
decision === "approved" && "border-ok text-ok",
decision === "rejected" && "border-destructive text-destructive",
decision === "expired" && "border-muted-foreground text-muted-foreground",
)}
>
{decision}
</motion.span>
</motion.div>
)}
</AnimatePresence>
</motion.section>
);
}
export default ApprovalCard;
How it behaves
- →Countdown ring drains over timeout seconds and turns red in the last quarter; at zero the request resolves as expired.
- →Approve / Reject buttons, plus A and R keyboard shortcuts while focus is inside the card.
- →After a decision, a rotated rubber-stamp overlay springs in (APPROVED green, REJECTED red, EXPIRED grey) and buttons disable.
- →Diff lines starting with + are green, - are red, in a monospace block.
- →onDecision(decision) fires exactly once.
Accessibility
- ✓Card is a group labelled by its title.
- ✓Countdown has role=timer and an aria-label with seconds left.
- ✓Stamp is announced via role=status.
- ✓Honours prefers-reduced-motion.