Skip to content
Seamless UI

Approval Card

Save

Ask 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

Approval Card preview

Open a pull request?

Waiting for you

The 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-card

Pre-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-run

Usage

approval-card-example.tsx
"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.

Approval Card states

Open a pull request?

Waiting for you

Push 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?

Approved

Push 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?

Rejected

Push docs/rate-limits and open a PR.

What will run

gh pr create --title "Update rate limits"

Rejected. Nothing ran.

Open a pull request?

Expired

Push 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

PropTypeDefaultDescription
titleReactNode—What the agent wants to do.
status"pending" | "approved" | "rejected" | "expired"—Decision state. Buttons show only while pending.
descriptionReactNode—Context for the decision.
detailstring—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 / rejectLabelstring"Approve" / "Reject"Button text.
detailLabelstring"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.tsxShow
agent/approval-card.tsx
"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>
  );
}