A human-in-the-loop card to approve or reject an agent action, with a reasoning disclosure and a depleting timeout ring that auto-expires the request.
npx shadcn@latest add @paragon/human-approval-cardAlso installs: button
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ChevronRight, ShieldCheck } from "lucide-react";
import { Button } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";
export type ApprovalState = "pending" | "approved" | "rejected" | "expired";
export interface HumanApprovalCardProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** The action awaiting approval, e.g. "Delete 3 production records". */
action: string;
/** Which agent or run is asking. */
requestedBy?: string;
/** The agent's reasoning, revealed on disclosure. */
reasoning?: string;
/** Seconds until the request auto-expires. Set 0 to disable the timer. */
timeout?: number;
onApprove?: () => void;
onReject?: () => void;
onExpire?: () => void;
static?: boolean;
}
/**
* A human-in-the-loop approval card for an agent action. A ring around the
* shield depletes as the timeout counts down (stroke-dashoffset transition,
* linear — a progress case), and the remaining seconds tick tabular. The
* agent's reasoning sits behind a disclosure (grid-rows). Approving or
* rejecting settles the card into a resolved state; letting the timer run out
* expires it. The timer pauses on hidden tabs and freezes under reduced
* motion (ring shows the full remaining amount, no sweep).
*/
export function HumanApprovalCard({
action,
requestedBy = "Agent run",
reasoning,
timeout = 30,
onApprove,
onReject,
onExpire,
static: isStatic = false,
className,
...props
}: HumanApprovalCardProps) {
const reducedMotion = useReducedMotion();
const [state, setState] = React.useState<ApprovalState>("pending");
const [remaining, setRemaining] = React.useState(timeout);
const [showReason, setShowReason] = React.useState(false);
const onExpireRef = React.useRef(onExpire);
onExpireRef.current = onExpire;
React.useEffect(() => {
if (state !== "pending" || timeout <= 0) return;
const id = setInterval(() => {
if (document.hidden) return;
setRemaining((r) => {
if (r <= 1) {
clearInterval(id);
setState("expired");
onExpireRef.current?.();
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(id);
}, [state, timeout]);
const size = 40;
const stroke = 3;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const frac = timeout > 0 ? remaining / timeout : 1;
const resolved = state !== "pending";
const stateMeta = {
pending: { label: "Awaiting approval", tone: "text-muted-foreground" },
approved: { label: "Approved", tone: "text-success" },
rejected: { label: "Rejected", tone: "text-destructive" },
expired: { label: "Expired — no response", tone: "text-muted-foreground" },
}[state];
const ringColor =
frac <= 0.25 ? "var(--color-destructive)" : "var(--color-primary)";
return (
<div
data-slot="human-approval-card"
className={cn(
"flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div className="flex items-start gap-3">
<div className="relative grid size-10 shrink-0 place-items-center">
{state === "pending" && timeout > 0 && (
<svg
aria-hidden
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="absolute inset-0 -rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-secondary)"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={ringColor}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - frac)}
style={{
transition:
isStatic || reducedMotion
? undefined
: "stroke-dashoffset 1000ms linear, stroke 300ms var(--ease-out)",
}}
/>
</svg>
)}
<ShieldCheck
aria-hidden
className={cn(
"size-[18px]",
resolved ? stateMeta.tone : "text-primary",
)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className={cn("text-xs font-medium", stateMeta.tone)}>
{stateMeta.label}
</span>
{state === "pending" && timeout > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">
{remaining}s
</span>
)}
</div>
<span className="text-sm font-medium text-foreground">{action}</span>
<span className="text-xs text-muted-foreground">
Requested by {requestedBy}
</span>
</div>
</div>
{reasoning && (
<div>
<button
type="button"
aria-expanded={showReason}
onClick={() => setShowReason((s) => !s)}
className="flex items-center gap-1 text-xs text-muted-foreground transition-colors duration-(--duration-fast) hover:text-foreground"
>
<ChevronRight
aria-hidden
className={cn(
"size-3.5",
!isStatic &&
"transition-transform duration-(--duration-fast) ease-out",
showReason && "rotate-90",
)}
/>
{showReason ? "Hide reasoning" : "Show reasoning"}
</button>
<div
className={cn(
"grid",
!isStatic &&
"transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-in-out) motion-reduce:transition-none",
)}
style={{ gridTemplateRows: showReason ? "1fr" : "0fr" }}
>
<div inert={!showReason} className="min-h-0 overflow-hidden">
<p className="mt-2 border-l-2 border-border pl-3 text-xs leading-relaxed text-muted-foreground">
{reasoning}
</p>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={resolved}
onClick={() => {
setState("rejected");
onReject?.();
}}
>
Reject
</Button>
<Button
size="sm"
className="flex-1"
disabled={resolved}
onClick={() => {
setState("approved");
onApprove?.();
}}
>
Approve
</Button>
</div>
</div>
);
}