Approval Card
A human-in-the-loop decision surface for questions, review steps, and consequential actions.
Usage
import { ApprovalCard } from "@/components/ai/approval-card"
export function Example() {
return <ApprovalCard title="Publish these changes?" onDecision={console.log} />
}
Install
pnpm dlx shadcn@latest add @pastaui/approval-cardnpx shadcn@latest add @pastaui/approval-cardyarn dlx shadcn@latest add @pastaui/approval-cardbunx --bun shadcn@latest add @pastaui/approval-cardSource
import { QuestionCircleIcon as CircleHelpIcon } from '@solar-icons/react/linear/question-circle'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from 'cn'
interface ApprovalOption {
description?: string
label: string
value: string
}
interface ApprovalCardProps extends React.ComponentProps<'section'> {
description?: string
onDecision?: (value: string) => void
options?: ApprovalOption[]
status?: 'approved' | 'denied' | 'pending'
title: string
}
const defaultOptions: ApprovalOption[] = [
{ label: 'Approve', value: 'approve' },
{ label: 'Deny', value: 'deny' },
]
function ApprovalCard({
className,
description,
onDecision,
options = defaultOptions,
status = 'pending',
title,
...props
}: ApprovalCardProps) {
return (
<section
data-slot="approval-card"
className={cn(
'rounded-[16px] bg-card p-5 font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground shadow-card transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:starting:translate-y-0',
className,
)}
{...props}
>
<div className="flex gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-muted text-muted-foreground">
<CircleHelpIcon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h3 className="card-heading">{title}</h3>
{status !== 'pending' && (
<span className="rounded-full bg-muted px-2 py-1 text-sm font-[450] text-muted-foreground capitalize transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] starting:scale-95 starting:opacity-0 motion-reduce:starting:scale-100">
{status}
</span>
)}
</div>
{description && (
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
)}
</div>
</div>
{status === 'pending' && (
<div
className={cn(
'mt-4 gap-2',
options.some((option) => option.description)
? 'grid sm:grid-cols-2'
: 'flex flex-wrap justify-end',
)}
>
{options.map((option, index) => (
<Button
className={cn(
'border-0 px-3 text-sm font-[450] tracking-[-0.05px] shadow-none',
option.description
? 'h-auto min-h-9 justify-start rounded-[10px] py-2 text-left whitespace-normal'
: 'h-7 rounded-full',
index === 0 &&
'bg-primary bg-linear-to-b from-[color-mix(in_oklch,var(--primary),var(--primary-foreground)_30%)] to-primary text-primary-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.18)] hover:brightness-110 dark:bg-none',
index !== 0 && 'text-muted-foreground hover:text-muted-foreground',
)}
key={option.value}
onClick={() => onDecision?.(option.value)}
size="sm"
variant={index === 0 ? 'default' : 'ghost'}
>
<span>
<span className="block">{option.label}</span>
{option.description && (
<span className="mt-0.5 block text-sm font-[450] opacity-70">
{option.description}
</span>
)}
</span>
</Button>
))}
</div>
)}
</section>
)
}
export { ApprovalCard }
export type { ApprovalCardProps, ApprovalOption }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | = — | States the decision being requested. |
| options | ApprovalOption[] | = Approve / Deny | Provides one or more possible responses. |
| onDecision | (value: string) => void | = — | Receives the selected decision. |