Scrim UI

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