Approval Card
An approval request: details at a glance, Approve or Reject with an optional note, a resolved state and an undo window.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/approval-card.json'use client'
import { Fragment, useEffect, useId, useState } from 'react'
import './approval-card.css'
export interface Request { who: string; team: string; title: string; amount: string; fields: [string, string][]; flag?: string; age: string }
export interface ApprovalCardProps {
request: Request
onDecide?: (d: 'approved' | 'rejected', note?: string) => void
requireReason?: boolean
showFlag?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Approve / reject card with reason and undo. */
export function ApprovalCard({ request: q, onDecide, requireReason = true, showFlag = true, fill = 'dark', className }: ApprovalCardProps) {
const [mode, setMode] = useState<'idle' | 'rejecting'>('idle')
const [note, setNote] = useState('')
const [done, setDone] = useState<null | { d: 'approved' | 'rejected'; note?: string }>(null)
const [undo, setUndo] = useState(0)
const nid = useId()
useEffect(() => { if (!undo) return; const t = setTimeout(() => setUndo((u) => u - 1), 1000); return () => clearTimeout(t) }, [undo])
const decide = (d: 'approved' | 'rejected') => { const n = note.trim() || undefined; setDone({ d, note: n }); setUndo(5); setMode('idle'); onDecide?.(d, n) }
const ini = q.who.split(' ').map((p) => p[0]).join('').slice(0, 2)
if (done) return (
<div className={['sd-apr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-apr__res" role="status" data-d={done.d}>
<i aria-hidden>{done.d === 'approved' ? '✓' : '×'}</i>
<span>{done.d === 'approved' ? 'Approved' : 'Rejected'} · {q.title}<small>{done.note ? `“${done.note}”` : `${q.amount} for ${q.who}`}</small></span>
{undo > 0 && <button type="button" onClick={() => { setDone(null); setUndo(0) }}>Undo {undo}s</button>}
</div>
</div>
)
return (
<div className={['sd-apr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-apr__h">
<span className="sd-apr__av" aria-hidden>{ini}</span>
<span className="sd-apr__who"><b>{q.who}</b>{q.team} · {q.age}</span>
<span className="sd-apr__pill">Needs review</span>
</div>
<div className="sd-apr__body">
<p className="sd-apr__t">{q.title}</p>
<p className="sd-apr__amt">{q.amount}</p>
<dl className="sd-apr__dl">{q.fields.map(([k, v]) => <Fragment key={k}><dt>{k}</dt><dd>{v}</dd></Fragment>)}</dl>
{showFlag && q.flag && <p className="sd-apr__flag"><svg viewBox="0 0 16 16" aria-hidden><circle cx="8" cy="8" r="6.2" /><path d="M8 4.8v3.6M8 11h0" /></svg>{q.flag}</p>}
</div>
{mode === 'rejecting' && (
<div className="sd-apr__note">
<label htmlFor={nid}>Reason {requireReason ? '' : '(optional)'}</label>
<textarea id={nid} rows={2} autoFocus value={note} onChange={(e) => setNote(e.target.value)} placeholder="Tell them what to change" onKeyDown={(e) => { if (e.key === 'Escape') setMode('idle') }} />
</div>
)}
<div className="sd-apr__acts">
{mode === 'idle' ? (
<><button type="button" onClick={() => setMode('rejecting')}>Reject</button><button type="button" className="pri" onClick={() => decide('approved')}>Approve</button></>
) : (
<><button type="button" onClick={() => { setMode('idle'); setNote('') }}>Cancel</button><button type="button" className="pri" disabled={requireReason && !note.trim()} onClick={() => decide('rejected')}>Send rejection</button></>
)}
</div>
</div>
)
}.sd-apr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-apr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-apr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-apr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-apr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-apr { width: min(100%, 330px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-apr__h { display: flex; align-items: center; gap: 10px; padding: 14px 16px 0; }
.sd-apr__av { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: 99px; background: var(--soft); font-size: 12.5px; font-weight: 700; }
.sd-apr__who { flex: 1; min-width: 0; font-size: 13px; color: var(--mute); }
.sd-apr__who b { display: block; color: var(--fg); font-size: 14px; font-weight: 600; }
.sd-apr__pill { padding: 3px 9px; border-radius: 99px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-apr__body { padding: 14px 16px; }
.sd-apr__t { margin: 0; font-size: 15px; font-weight: 600; }
.sd-apr__amt { margin: 4px 0 10px; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.sd-apr__dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0; font-size: 13px; }
.sd-apr__dl dt { color: var(--mute); }
.sd-apr__dl dd { margin: 0; text-align: right; }
.sd-apr__flag { display: flex; gap: 8px; margin-top: 12px; padding: 9px 11px; border-radius: 10px; background: var(--soft); font-size: 12.5px; line-height: 1.4; }
.sd-apr__flag svg { width: 15px; height: 15px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.sd-apr__note { padding: 0 16px 4px; animation: sd-apr-in 220ms ease-out; }
.sd-apr__note label { display: block; margin-bottom: 6px; font-size: 12.5px; color: var(--mute); }
.sd-apr__note textarea { width: 100%; padding: 9px 11px; border: 0; border-radius: 10px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; resize: none; outline: 0; }
.sd-apr__note textarea:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-apr__acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 16px 16px; }
.sd-apr__acts button { height: 42px; border-radius: 12px; font-size: 14px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); transition: transform 90ms; }
.sd-apr__acts button:active { transform: scale(0.97); }
.sd-apr__acts .pri { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-apr__acts button:disabled { opacity: 0.35; }
.sd-apr__res { display: flex; align-items: center; gap: 10px; padding: 16px; font-size: 13.5px; animation: sd-apr-in 260ms ease-out; }
.sd-apr__res i { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 99px; background: var(--strong); color: var(--on); font-style: normal; font-weight: 700; }
.sd-apr__res[data-d='rejected'] i { background: transparent; color: var(--fg); box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-apr__res span { flex: 1; min-width: 0; }
.sd-apr__res small { display: block; color: var(--mute); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-apr__res button { padding: 6px 10px; border-radius: 8px; font-size: 12.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
@keyframes sd-apr-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-apr *, .sd-apr *::before, .sd-apr *::after { animation: none !important; transition: none !important; } }Overview
The card shows who asked, what and how much, with the policy check that triggered review. Approve resolves straight away; Reject asks for a reason first.
After a decision the card collapses to a resolved line with a five-second undo.
When to use
- Expense, access, refund and content approval queues.
When not to use
- Multi-step reviews with several approvers: show a timeline.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/approval-card.json$
npx shadcn@latest add https://sundaeui.dev/r/approval-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/approval-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/approval-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy approval-card.tsx and approval-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ApprovalCard } from '@/components/sundae/approval-card'
<ApprovalCard />Props
| Prop | Type | Default |
|---|---|---|
request*Requester, title, amount, reason, flags. | Request | — |
onDecideDecision callback. | (d: 'approved' | 'rejected', note?: string) => void | — |
requireReasonReject needs a note. | boolean | true |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Actions are buttons; the reject reason is a labelled textarea that receives focus.
- The decision and undo are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-apr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Approval Card free?
- Yes. Approval Card is free for personal and commercial projects, with full source on this page.
- How do I install Approval Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/approval-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Approval Card support keyboard and screen readers?
- Actions are buttons; the reject reason is a labelled textarea that receives focus. The decision and undo are announced.
- Does Approval Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Toast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined- Pro
Focus Timer
Components · Refined