Lunia DS

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.

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

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
titlestring-What the agent wants to do, for example "Delete 4 old drafts". Required.
descriptionstringundefinedOne or two lines about the action.
detailsArray<{ label: string; value: string | Node }>undefinedFacts worth checking before approving, shown as label and value pairs.
reasonstringundefinedWhy the agent needs approval for this action.
status"pending" | "approved" | "denied" | "expired"undefinedControlled 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.
approveLabelstring"Approve"Label of the approve button.
denyLabelstring"Decline"Label of the decline button.
destructivebooleanfalseStyles Approve as a destructive action (red).
expiresAtnumberundefinedTime in ms since the epoch when the request lapses; shows a countdown, then the card expires.
EventDescription
approveThe person pressed Approve.
denyThe person pressed Decline.
expireThe 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
APC1Card mountsrootopacity, translate y0, distance-rise → 1, 0duration-lg (320)ease-settleShows at once
APC2Status leaves or returns to pendingrootpadding16px → 10px 12pxduration-3xl (460)ease-outResizes at once
APC3Status changesstatus icons (shield, check, ban, clock; one leaves, one arrives)opacity, blur0, blur-swap → 1, 0duration-md (260)ease-outSwaps at once
APC4Status changesstatus icons (one leaves, one arrives)scalescale-swap → 1duration-xl (380)ease-outSwaps at once
APC5Pending to approvedcheck markstroke drawn0% → 100%duration-2xl (420)ease-outdelay 160msShows at once
APC6Status changesstatus labeltext morphWaiting for your approval → Approved, Declined, Expiredduration-3xl (460)ease-outSwaps at once
APC7Status leaves or returns to pendingstatus icon, status labelcolorfg-quaternary, text-tertiary → fg-secondary, text-primaryduration-lg (320)ease-outSwaps at once
APC8Status leaves or returns to pendingaction title in the header, countdowncolumn width (grid columns; the title opens as the countdown closes)0fr → 1frduration-3xl (460)ease-outResizes at once
APC9Status leaves pendingaction title in the headeropacity, blur0, blur-text → 1, 0duration-xl (380)ease-outdelay 160msShows at once
APC10Status leaves pendingcountdownopacity, blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
APC11Status returns to pendingaction title in the headeropacity, blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
APC12Status returns to pendingcountdown, body contentopacity, blur0, blur-text → 1, 0duration-xl (380)ease-outdelay 120msShows at once
APC13Each second while pending (countdown on)minutes, secondsnumber roll (seconds roll down)old value → new valueduration-5xl (640)ease-outSwaps at once
APC14Status leaves or returns to pendingbodyheight (grid rows)1fr → 0frduration-3xl (460)ease-outResizes at once
APC15Status leaves pendingbody contentopacity, blur1, 0 → 0, blur-textduration-xs (160)ease-exitHides at once
APC16Hover or pressDecline, Approvebackground colorbase Button xs default (Decline Secondary, Approve Primary) → base Button xs hover (Decline bg-primary_hover, Approve bg-brand-solid_hover)duration-xs (160)ease-outSwaps at once
APC17PressApprovescale1 → scale-pressduration-xs (160)ease-outStays 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.