AI & Chat

Human Approval Card

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.

Install

npx shadcn@latest add @paragon/human-approval-card

Also installs: button

human-approval-card.tsx

"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>
  );
}