AI output
Approval card
A card in which an agent asks before it acts, showing the action, its details and an optional countdown, that folds into a one-line receipt once approved, declined or expired as its status label morphs and its icon swaps.
Live preview
Loading preview
An agent asks to "Delete 4 old drafts" from a shared folder, with details (folder, oldest draft, size) and a reason, and a 45-second countdown; Approve folds the card into "Approved" with a drawn check. A second card with a destructive Approve is declined, and a third expires. Replay resets all three to pending.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add approval-cardUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { ApprovalCard, type ApprovalStatus } from './approval-card';
const DETAILS = [
{ label: 'Folder', value: 'Marketing / Drafts' },
{ label: 'Oldest draft', value: 'March 2, 2026' },
{ label: 'Size', value: '3.4 MB' },
];
type Cards = { a: ApprovalStatus; b: ApprovalStatus; c: ApprovalStatus };
const PENDING: Cards = { a: 'pending', b: 'pending', c: 'pending' };
export default function ApprovalCardDemo({ replay = 0 }: { replay?: number }) {
const [status, setStatus] = React.useState<Cards>(PENDING);
// The countdowns start when the demo starts or replays; the third one is short so that it can lapse.
const [times, setTimes] = React.useState(() => ({ a: Date.now() + 45_000, c: Date.now() + 90_000 }));
const set = React.useCallback(
(key: keyof Cards, value: ApprovalStatus) => setStatus((now) => ({ ...now, [key]: value })),
[],
);
// Replay resets all three to pending, approves the first, declines the second and lets the third lapse.
React.useEffect(() => {
if (replay === 0) return;
setStatus(PENDING);
setTimes({ a: Date.now() + 45_000, c: Date.now() + 9_000 });
const timers = [
window.setTimeout(() => set('a', 'approved'), 2000),
window.setTimeout(() => set('b', 'denied'), 3800),
];
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay, set]);
return (
<div className="flex w-full max-w-[400px] flex-col gap-4">
<ApprovalCard
title="Delete 4 old drafts"
description="These drafts are in the shared Marketing folder and have not changed for 90 days."
details={DETAILS}
reason="Deleting files needs your approval in this workspace."
status={status.a}
expiresAt={times.a}
onApprove={() => set('a', 'approved')}
onDeny={() => set('a', 'denied')}
onExpire={() => set('a', 'expired')}
/>
<ApprovalCard
title="Remove 12 guests from the list"
description="They did not reply to the invite for the team dinner."
reason="Removing guests sends each of them an email."
approveLabel="Remove guests"
destructive
status={status.b}
onApprove={() => set('b', 'approved')}
onDeny={() => set('b', 'denied')}
/>
<ApprovalCard
title="Send the invoice to Pinewood Labs"
status={status.c}
expiresAt={times.c}
onApprove={() => set('c', 'approved')}
onDeny={() => set('c', 'denied')}
onExpire={() => set('c', 'expired')}
/>
</div>
);
}
API
Parts: root (group, focusable for focus handoff), header (also the folded line): status icon, status label (text morph), action title (slides in once decided), countdown (minutes and seconds, number roll), body (folds shut once decided), title, description, details (description list), reason, actions: Decline, Approve, status region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | - | What the agent wants to do, for example "Delete 4 old drafts". Required. |
| description | string | undefined | One or two lines about the action. |
| details | Array<{ label: string; value: string | Node }> | undefined | Facts worth checking before approving, shown as label and value pairs. |
| reason | string | undefined | Why the agent needs approval for this action. |
| status | "pending" | "approved" | "denied" | "expired" | undefined | Controlled status. "denied" shows as Declined, as in tool-call. |
| defaultStatus | "pending" | "approved" | "denied" | "expired" | "pending" | Status at first when not controlled; the card then keeps the choice itself. |
| approveLabel | string | "Approve" | Label of the approve button. |
| denyLabel | string | "Decline" | Label of the decline button. |
| destructive | boolean | false | Styles Approve as a destructive action (red). |
| expiresAt | number | undefined | Time in ms since the epoch when the request lapses; shows a countdown, then the card expires. |
| Event | Description |
|---|---|
| approve | The person pressed Approve. |
| deny | The person pressed Decline. |
| expire | The countdown reached 0:00 while pending. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| APC1 | Card mounts | root | opacity, translate y | 0, distance-rise → 1, 0 | duration-lg (320)ease-settle | Shows at once |
| APC2 | Status leaves or returns to pending | root | padding | 16px → 10px 12px | duration-3xl (460)ease-out | Resizes at once |
| APC3 | Status changes | status icons (shield, check, ban, clock; one leaves, one arrives) | opacity, blur | 0, blur-swap → 1, 0 | duration-md (260)ease-out | Swaps at once |
| APC4 | Status changes | status icons (one leaves, one arrives) | scale | scale-swap → 1 | duration-xl (380)ease-out | Swaps at once |
| APC5 | Pending to approved | check mark | stroke drawn | 0% → 100% | duration-2xl (420)ease-outdelay 160ms | Shows at once |
| APC6 | Status changes | status label | text morph | Waiting for your approval → Approved, Declined, Expired | duration-3xl (460)ease-out | Swaps at once |
| APC7 | Status leaves or returns to pending | status icon, status label | color | fg-quaternary, text-tertiary → fg-secondary, text-primary | duration-lg (320)ease-out | Swaps at once |
| APC8 | Status leaves or returns to pending | action title in the header, countdown | column width (grid columns; the title opens as the countdown closes) | 0fr → 1fr | duration-3xl (460)ease-out | Resizes at once |
| APC9 | Status leaves pending | action title in the header | opacity, blur | 0, blur-text → 1, 0 | duration-xl (380)ease-outdelay 160ms | Shows at once |
| APC10 | Status leaves pending | countdown | opacity, blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| APC11 | Status returns to pending | action title in the header | opacity, blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| APC12 | Status returns to pending | countdown, body content | opacity, blur | 0, blur-text → 1, 0 | duration-xl (380)ease-outdelay 120ms | Shows at once |
| APC13 | Each second while pending (countdown on) | minutes, seconds | number roll (seconds roll down) | old value → new value | duration-5xl (640)ease-out | Swaps at once |
| APC14 | Status leaves or returns to pending | body | height (grid rows) | 1fr → 0fr | duration-3xl (460)ease-out | Resizes at once |
| APC15 | Status leaves pending | body content | opacity, blur | 1, 0 → 0, blur-text | duration-xs (160)ease-exit | Hides at once |
| APC16 | Hover or press | Decline, Approve | background color | base Button xs default (Decline Secondary, Approve Primary) → base Button xs hover (Decline bg-primary_hover, Approve bg-brand-solid_hover) | duration-xs (160)ease-out | Swaps at once |
| APC17 | Press | Approve | scale | 1 → scale-press | duration-xs (160)ease-out | Stays still |
Accessibility
- The root is role="group" named "Waiting for your approval: <title>" while pending and "<status>: <title>" once decided.
- When Approve or Decline is pressed, focus moves to the card itself (tabindex -1) before the buttons fold away, so focus is not lost on the page.
- Once decided, the body is inert and aria-hidden, and a visually hidden role="status" region says the result once, for example "Approved: Delete 4 old drafts".
- Status icons are aria-hidden; the status label carries the meaning in words, so color is never the only cue (also for a destructive Approve, whose label says what it does).
- The countdown has a visually hidden prefix "Time left:" and is not a live region, so it is not read every second; it is aria-hidden once decided.
- Give a countdown of at least 20 seconds, or offer a way to ask again after Expired, so people who need more time can still respond (WCAG 2.2.1).
- Details are a description list (dt label, dd value).
- Decline and Approve are 32px tall (target at least 24px) and show Focus rings/focus-ring on keyboard focus.