# Approval Card

> A human-in-the-loop approval for an action an agent wants to take.

A shadcn/ui block from the [@spectrumui](https://ui.spectrumhq.in) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/spectrumui/approval-card).

- Page, with a live preview: https://shadcn.exchange/r/spectrumui/approval-card
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/spectrumui/approval-card-light-6c0a10b9162d89d5.png
- Registry item: https://ui.spectrumhq.in/r/approval-card.json
- Listed: 2026-10-11, source last changed 2026-10-11

## Install

```bash
npx shadcn@latest add https://ui.spectrumhq.in/r/approval-card.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`

## Files

### `components/spectrumui/blocks/ai-assistants/approval-card.tsx`

```tsx
'use client';

import { useState } from 'react';
import { Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';

const KEYFRAMES = `
@keyframes su-pop { 0% { opacity: 0; transform: scale(0.85) } 100% { opacity: 1; transform: none } }
@keyframes su-msg-in { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: none } }
`;

export type ApprovalDecision = 'approved' | 'rejected';

export interface ApprovalCardProps {
  title?: string;
  description?: string;
  meta?: string;
  approveLabel?: string;
  rejectLabel?: string;
  decision?: ApprovalDecision | null;
  onDecide?: (decision: ApprovalDecision) => void;
  className?: string;
}

export function ApprovalCard({
  title = 'Send carrier notice?',
  description = 'A formal SLA-breach notice to Meridian Lines for the Rotterdam–Felixstowe lane, citing 2.3 days over contracted transit.',
  meta = 'drafted by agent · draft_carrier_notice',
  approveLabel = 'Approve & send',
  rejectLabel = 'Dismiss',
  decision: decisionProp,
  onDecide,
  className,
}: ApprovalCardProps) {
  const [decisionState, setDecisionState] = useState<ApprovalDecision | null>(null);
  const decision = decisionProp !== undefined ? decisionProp : decisionState;

  function decide(next: ApprovalDecision) {
    setDecisionState(next);
    onDecide?.(next);
  }

  return (
    <div
      className={cn(
        'w-full max-w-[420px] rounded-2xl border border-black/[0.08] bg-white p-4 shadow-xs dark:border-white/[0.09] dark:bg-[#0B0B0D]',
        className,
      )}
    >
      <style dangerouslySetInnerHTML={{ __html: KEYFRAMES }} />

      {decision ? (
        <div className="flex flex-col items-center py-4 text-center motion-safe:animate-[su-msg-in_240ms_cubic-bezier(0.23,1,0.32,1)_both]">
          <span
            className={cn(
              'grid size-8 place-items-center rounded-full motion-safe:animate-[su-pop_200ms_cubic-bezier(0.23,1,0.32,1)_both]',
              decision === 'approved'
                ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
                : 'bg-neutral-500/10 text-neutral-500 dark:text-neutral-400',
            )}
          >
            {decision === 'approved' ? (
              <Check className="size-4" strokeWidth={2.5} />
            ) : (
              <X className="size-4" strokeWidth={2.5} />
            )}
          </span>
          <p className="mt-2.5 text-[13.5px] font-medium text-neutral-900 dark:text-neutral-50">
            {decision === 'approved' ? 'Approved — notice queued' : 'Dismissed'}
          </p>
          <p className="mt-1 font-mono text-[10.5px] text-neutral-400 dark:text-neutral-600">
            {meta}
          </p>
        </div>
      ) : (
        <>
          <h3 className="text-[14px] font-semibold tracking-[-0.1px] text-neutral-900 dark:text-neutral-50">
            {title}
          </h3>
          <p className="mt-1.5 text-[13px] leading-[1.6] text-neutral-500 dark:text-neutral-400">
            {description}
          </p>
          <p className="mt-2.5 font-mono text-[10.5px] text-neutral-400 dark:text-neutral-600">
            {meta}
          </p>

          <div className="mt-4 flex items-center justify-end gap-2">
            <button
              type="button"
              onClick={() => decide('rejected')}
              className="rounded-lg px-3 py-1.5 text-[12.5px] font-medium text-neutral-500 transition-[color,background-color,transform] duration-150 hover:bg-black/[0.04] hover:text-neutral-800 active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-neutral-200"
            >
              {rejectLabel}
            </button>
            <button
              type="button"
              onClick={() => decide('approved')}
              className="rounded-lg bg-neutral-900 px-3 py-1.5 text-[12.5px] font-medium text-white transition-transform duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 dark:bg-neutral-100 dark:text-neutral-900"
            >
              {approveLabel}
            </button>
          </div>
        </>
      )}
    </div>
  );
}

export default ApprovalCard;
```
