Approval Card
SaveAsk a person to approve or reject an agent action: what will run, then the decision, with duplicate-click protection while it is saved.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Open a pull request?
Waiting for youThe agent wants to push a branch and open a pull request in acme/docs.
What will run
git push origin docs/rate-limits gh pr create --title "Update rate limits"
Nothing runs until you decide.
Installation
npx @seamlessui/cli add approval-cardPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add approval-card --dry-runUsage
"use client";
import { useState } from "react";
import type { ApprovalStatus } from "@/components/seamless/agent/agent-run";
import { ApprovalCard } from "@/components/seamless/agent/approval-card";
export default function ApprovalCardExample() {
const [status, setStatus] = useState<ApprovalStatus>("pending");
// Replace with a call to your backend, which must verify the user may approve this action.
const record = (decision: "approved" | "rejected") => new Promise<void>((resolve) => setTimeout(() => (setStatus(decision), resolve()), 600));
return (
<ApprovalCard
className="w-full max-w-md"
title="Open a pull request?"
description="The agent wants to push a branch and open a pull request in acme/docs."
detail={'git push origin docs/rate-limits\ngh pr create --title "Update rate limits"'}
status={status}
onApprove={() => record("approved")}
onReject={() => record("rejected")}
/>
);
}States
Every state, rendered from local fixture data.
Open a pull request?
Waiting for youPush docs/rate-limits and open a PR.
What will run
gh pr create --title "Update rate limits"
Nothing runs until you decide.
Open a pull request?
ApprovedPush docs/rate-limits and open a PR.
What will run
gh pr create --title "Update rate limits"
Approved. The agent can continue.
Open a pull request?
RejectedPush docs/rate-limits and open a PR.
What will run
gh pr create --title "Update rate limits"
Rejected. Nothing ran.
Open a pull request?
ExpiredPush docs/rate-limits and open a PR.
What will run
gh pr create --title "Update rate limits"
This request expired before a decision was made.
API
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | What the agent wants to do. |
| status | "pending" | "approved" | "rejected" | "expired" | — | Decision state. Buttons show only while pending. |
| description | ReactNode | — | Context for the decision. |
| detail | string | — | The exact action, e.g. a command or diff, shown in a well under "What will run". |
| onApprove / onReject | () => void | Promise<void> | — | Called once per decision; both buttons ignore clicks until it settles. Errors show a retry message. |
| approveLabel / rejectLabel | string | "Approve" / "Reject" | Button text. |
| detailLabel | string | "What will run" | Label above detail. |
Accessibility
- The decision state is a live region and also a text label and glyph, not color alone.
- While a decision is saving, the chosen button shows a busy state and keeps focus; repeated clicks are ignored.
- After a decision, a sentence states the outcome (for example "Rejected. Nothing ran.").
- The card only reports a decision. Your backend must verify the approval and the user's authorization before acting.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/agent/approval-card.tsxapproval-card- Also installs badge@0.2.0registry dependency
- Also installs button@0.2.0registry dependency
- Also installs agent-run@0.1.1registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/approval-card/0.2.0.json.
agent/approval-card.tsxShowHide
"use client";
import { type ComponentProps, type ReactNode, useRef, useState } from "react";
import { Badge, type BadgeTone } from "../ui/badge";
import { Button } from "../ui/button";
import type { ApprovalStatus } from "./agent-run";
const statuses: Record<ApprovalStatus, { label: string; tone: BadgeTone; outcome: string }> = {
pending: { label: "Waiting for you", tone: "warning", outcome: "" },
approved: { label: "Approved", tone: "success", outcome: "Approved. The agent can continue." },
rejected: { label: "Rejected", tone: "danger", outcome: "Rejected. Nothing ran." },
expired: { label: "Expired", tone: "neutral", outcome: "This request expired before a decision was made." },
};
const ease = "ease-[cubic-bezier(0.23,1,0.32,1)]";
export interface ApprovalCardProps extends Omit<ComponentProps<"section">, "title" | "children"> {
title: ReactNode;
status: ApprovalStatus;
description?: ReactNode;
/** What will happen if approved, such as a command or diff. Shown as preformatted text. */
detail?: string;
/** Called once per decision. While it runs, both buttons ignore further clicks. */
onApprove?: () => void | Promise<void>;
onReject?: () => void | Promise<void>;
approveLabel?: string;
rejectLabel?: string;
/** Label above `detail`. */
detailLabel?: string;
}
/**
* Asks a person to approve or reject an agent action: what will run, then the decision.
* The UI only reports the decision: your backend must verify the approval and the
* user's authorization before acting.
*/
export function ApprovalCard({
title,
status,
description,
detail,
onApprove,
onReject,
approveLabel = "Approve",
rejectLabel = "Reject",
detailLabel = "What will run",
className,
...props
}: ApprovalCardProps) {
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const meta = statuses[status];
const pending = status === "pending";
async function decide(decision: "approve" | "reject") {
// A ref (not state) guards against double clicks that land before React re-renders.
if (inFlight.current || status !== "pending") return;
inFlight.current = true;
setSubmitting(decision);
setError(null);
try {
await (decision === "approve" ? onApprove : onReject)?.();
} catch {
setError("Your decision was not saved. Try again.");
} finally {
inFlight.current = false;
setSubmitting(null);
}
}
return (
<section
aria-label={typeof title === "string" ? `Approval: ${title}` : "Approval request"}
data-status={status}
className={[
"overflow-hidden rounded-xl border bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color] duration-200 motion-reduce:transition-none",
"dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
pending ? "border-amber-500/40" : "border-zinc-950/10 dark:border-white/10",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<div className="flex items-start gap-3 p-4">
<DecisionGlyph key={status} status={status} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<h3 className="min-w-0 text-sm font-semibold">{title}</h3>
<span role="status" className="shrink-0">
<Badge tone={meta.tone}>{meta.label}</Badge>
</span>
</div>
{description ? <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{description}</p> : null}
{detail ? (
<div className="mt-3">
<p className="mb-1.5 text-xs font-medium text-zinc-500">{detailLabel}</p>
<pre className="overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300">
{detail}
</pre>
</div>
) : null}
</div>
</div>
{pending ? (
<div className="border-t border-zinc-950/[0.07] bg-amber-500/[0.04] px-4 py-3 dark:border-white/[0.08] dark:bg-amber-500/[0.05]">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2.5">
<p className="text-xs text-zinc-600 dark:text-zinc-400">Nothing runs until you decide.</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" loading={submitting === "reject"} disabled={submitting === "approve"} onClick={() => decide("reject")}>
{rejectLabel}
</Button>
<Button size="sm" loading={submitting === "approve"} disabled={submitting === "reject"} onClick={() => decide("approve")}>
{approveLabel}
</Button>
</div>
</div>
{error ? (
<p
role="alert"
className={`mt-2.5 text-sm text-red-600 transition-[opacity,translate] duration-200 ${ease} starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-400`}
>
{error}
</p>
) : null}
</div>
) : (
<p
key={status}
className={[
"border-t border-zinc-950/[0.07] px-4 py-3 text-xs dark:border-white/[0.08]",
`transition-[opacity,translate] duration-200 ${ease} starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none`,
status === "approved"
? "text-emerald-700 dark:text-emerald-400"
: status === "rejected"
? "text-red-700 dark:text-red-400"
: "text-zinc-500",
].join(" ")}
>
{meta.outcome}
</p>
)}
</section>
);
}
/** The shared status shapes at 20px: pause bars while waiting, then check, x, or slash. */
function DecisionGlyph({ status }: { status: ApprovalStatus }) {
const base = `mt-px size-5 shrink-0 transition-[opacity,scale] duration-200 ${ease} starting:scale-90 starting:opacity-0 motion-reduce:transition-none`;
// A 16 unit box drawn at 20px: a 1.2 stroke renders at 1.5px.
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round", strokeLinejoin: "round" } as const;
const tone = {
pending: "text-amber-600 dark:text-amber-400",
approved: "text-emerald-600 dark:text-emerald-400",
rejected: "text-red-600 dark:text-red-400",
expired: "text-zinc-500",
}[status];
return (
<svg aria-hidden="true" viewBox="0 0 16 16" {...stroke} className={`${base} ${tone}`}>
<circle cx="8" cy="8" r="6.25" />
{status === "pending" ? (
<path d="M6.6 5.9v4.2M9.4 5.9v4.2" />
) : status === "approved" ? (
<path
d="m5.35 8.25 1.75 1.75 3.55-3.75"
pathLength={1}
strokeDasharray="1"
className={`[stroke-dashoffset:0] transition-[stroke-dashoffset] delay-75 duration-300 ${ease} starting:[stroke-dashoffset:1] motion-reduce:transition-none`}
/>
) : status === "rejected" ? (
<path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
) : (
<path d="m5.6 10.4 4.8-4.8" />
)}
</svg>
);
}