Approval Request UI
Human-in-the-loop approval card with action details and allow/deny controls.
approvalhuman-in-the-loopagent
Run database migration
Deploy Agent is requesting approval
Runs an irreversible migration on the production database. 4 tables, ~2 min estimated downtime.
npm run migrate -- --env=production --confirm
Auto-deny in 4:32
Props
Also takes onAllow, onDeny.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
approval-request.tsx
"use client";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ApprovalState = "pending" | "approved" | "denied";
export type ApprovalRequestProps = {
title: string;
requester?: string;
description?: string;
detail?: string;
status?: ApprovalState;
onAllow?: () => void;
onDeny?: () => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function ShieldIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function XIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* ApprovalRequest */
/* ------------------------------------------------------------------ */
export function ApprovalRequest({
title,
requester,
description,
detail,
status = "pending",
onAllow,
onDeny,
className = "",
}: ApprovalRequestProps) {
return (
<div className={`rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 ${className}`}>
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400">
<ShieldIcon />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{title}</p>
{requester && (
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{requester} is requesting approval</p>
)}
{description && (
<p className="mt-1.5 text-[13px] leading-5 text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
{detail && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-zinc-50 p-2.5 font-mono text-xs leading-5 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
{detail}
</pre>
)}
{status === "pending" ? (
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={onAllow}
/* emerald-700, not -600: white on emerald-600 is 3.65:1, under
the 4.5 floor for this 12px label. -700 is 5.48:1. */
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-emerald-700 px-3.5 text-xs font-medium text-white transition-opacity hover:opacity-90"
>
<CheckIcon />
Allow
</button>
<button
type="button"
onClick={onDeny}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-zinc-200 px-3.5 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<XIcon />
Deny
</button>
<span className="ml-auto text-[11px] text-zinc-500 dark:text-zinc-400">Auto-deny in 4:32</span>
</div>
) : (
<p
className={`mt-3 inline-flex items-center gap-1.5 text-xs font-medium ${
status === "approved"
? /* -700 in light: emerald-600 on white is 3.65:1 at 12px. */
"text-emerald-700 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{status === "approved" ? <CheckIcon /> : <XIcon />}
{status === "approved" ? "Approved — action executed" : "Denied — action blocked"}
</p>
)}
</div>
</div>
</div>
);
}
Usage Guidelines
- Ask for approval only for irreversible, costly or sensitive actions — not every step.
- Always show the concrete action (command, diff, recipients), not a vague 'proceed?'
- Include an auto-deny timeout for unattended requests.
- Say what happened after the decision — approved actions should confirm execution.
- Make Allow/Deny keyboard accessible and order them consistently.
Common UX Mistakes
- Asking for approval with no context — users can't consent to what they can't see.
- No timeout — pending requests pile up and the user returns to a stale queue.
- Hiding the cost or blast radius of the action (recipients, rows affected, money).
- Treating approval as a formality the agent can bypass with retries.
Related Components
Agent Status
Status UI for autonomous AI agents — running, waiting, completed and failed.
Prompt Input
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt Input + Attachments
Prompt input with file attachment chips, upload progress and removal.
Prompt Input + Model Selector
Prompt input with an inline model picker and capability badges.