affectUI › Components › Agents

Approval Card

An agent asks before it acts – risk badge, preview, Approve and Deny with shortcuts, a pausing countdown, and a smooth morph into a resolved row.

Part of affectUI Pro. Install: npx shadcn@latest add https://www.affectui.com/r/approval-card.json · Import from @/components/ui/approval-card

When to use

  • An agent is about to do something hard to undo – send email, delete files, spend money, deploy – and a person should say yes first
  • Human-in-the-loop tool calls in a chat or an agent run log, one card per pending call
  • Requests that should expire on their own when nobody answers

When not to use

  • Many pending requests at once that people triage in bulk – use Decision Inbox
  • Harmless, reversible actions – just do them and offer undo
  • Destructive actions the user triggered themselves – a regular confirm dialog fits better

Usage

import { ApprovalCard } from "@/components/ui/approval-card";

export function SendApproval({ call }: { call: PendingToolCall }) {
  return (
    <ApprovalCard
      title="Send campaign email"
      description="October product update · Newsletter"
      risk="medium"
      details={[
        { label: "To", value: "Customers – EU (42)" },
        { label: "Subject", value: "What's new in October" },
        { label: "Send", value: "Today, 9:00 AM" },
      ]}
      expiresIn={60}
      onApprove={({ always }) => call.approve({ always })} // return the promise – the button shows a spinner
      onDeny={({ reason }) => call.reject(reason)}
    />
  );
}

Props

NameTypeDefaultDescription
titlestringThe action, e.g. "Send campaign email" – also the card's accessible name
descriptionReactNodeOne line of context below the title; truncates
risk"low" | "medium" | "high"Tinted badge in the header (gray, amber, red); leave out to hide it
iconReactNodeshieldIcon in the 32px header tile; SVGs get a 16px stroke style
details{ label: string; value: ReactNode }[]Key-value rows in the preview panel
previewReactNodeCustom content above the details, e.g. recipients or a diff – children work the same
childrenReactNodeUsed as the preview when preview isn't set
status"pending" | "approved" | "denied"Controlled status; setting it back to "pending" starts a fresh request
defaultStatus"pending" | "approved" | "denied""pending"Starting status when uncontrolled
onApprove({ always }) => void | PromiseCalled on Approve or ⌘/Ctrl+Enter. Return a promise to show a spinner and lock the card; a rejection keeps it pending
onDeny({ reason: "user" | "timeout" }) => voidCalled on Deny, Escape, or when the countdown runs out
onEdit() => voidShows an Edit button before Deny
expiresInnumberSeconds until the request expires; shown as quiet "Expires in 0:42" text in the tray next to the buttons, amber in the last 10 seconds, paused while hovered or focused
onExpire() => voidCalled when the countdown runs out, right before onDeny({ reason: "timeout" })
allowAlwaysbooleantrueShow the "Always allow this action" checkbox
alwaysLabelstring"Always allow this action"Label of the checkbox
approveLabelstring"Approve"Primary button label
denyLabelstring"Deny"Quiet button label
editLabelstring"Edit"Edit button label
shortcutsbooleantrueShow the ⌘↵ / Esc hints on the buttons – the shortcuts work either way
labelReactNodeSmall label on the gray tray above the card, e.g. "Step 4 · gmail.send"
actions"tray" | "card""tray"Where the buttons sit: on the gray tray below the card, or inside the white card (handy in narrow side panels next to other cards)
classNamestringExtra class on the root

Keyboard

  • ⌘ / Enter: Approve while focus is anywhere in the card (Ctrl+Enter on Windows and Linux)
  • Escape: Deny while focus is in the card
  • Tab: Moves through the checkbox, Edit, Deny and Approve

Accessibility

  • role="group" named by the title and described by the description and the countdown
  • Real buttons with aria-keyshortcuts; the kbd hints are aria-hidden
  • The decision is announced through a polite live region ("Approved: Send campaign email")
  • The countdown has a full text description ("Expires in 24 seconds unless approved…") linked via aria-describedby; the visible clock is aria-hidden so it isn't read every second, and it pauses while the card is hovered or focused
  • While an approval is processing the card is aria-busy and the buttons are aria-disabled, so focus is never lost
  • After a decision focus moves to the resolved row (tabIndex -1) if it was inside the card

Responsive

  • Fills its container; below 400px card width the buttons share a full-width row and the shortcut hints hide
  • Below 340px the risk badge drops the word "risk" and the resolved row drops its time
  • Titles, descriptions and detail values truncate instead of wrapping; works down to 320px
  • Shortcut hints are hidden on touch screens
  • Dark mode built in: switches when an ancestor (usually <html>) has the class dark or data-theme="dark"

Requirements

  • React 19

Related

  • Tool Call Card – An agent's tool call in one line – live timer, status and summary, folds open to highlighted arguments and result with copy, retry on errors and a group that sums them up
  • Decision Inbox – Open decisions with two quick answers each – answered ones confirm and fold away
  • Thinking State – AI working panel with a live timer, searched sources and thinking steps that appear one by one
  • Thinking Loader – A small working pill for AI replies – an eight-ray spinner and a shimmering label that rolls through phases and settles on a check

Guides