Skip to content
AI Interfaces block

Approval Request with Blast Radius

The agent stops and asks: the effect stated before the verb, choices as radios in a fieldset so picking is separate from committing, no pre-focused Approve, and the decision left on screen afterwards.

247 lineslucide-reactAdded 13 Aug 2026Updated 10 Sept 2026
  • approval
  • human in the loop
  • consent
  • guardrail
  • agent

What's included

  • components/approval-request-card.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add approval-request-card

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Waiting on you

Who should receive the retention offer?

I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.

If you approve

  • Sends from retention@acme.com via the production Postmark key
  • Writes a "save offer sent" activity to each CRM account
  • Cannot be recalled once dispatched
Choose one

This sends real email from a production account.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/approval-request-card.tsx
/**
 * <ApprovalRequestCard> — the agent stops and asks before it acts.
 *
 * This is the block that decides whether an agent product is trustworthy,
 * and almost every version of it is too vague to approve safely. The rules
 * it follows:
 *
 *  - State the *effect*, not the intention. "Send 214 emails" is approvable;
 *    "proceed with outreach" is not. The consequence line is required, not
 *    decorative, and it names what changes and where.
 *  - Approve is never the pre-focused default. A card that appears under the
 *    user's hands with the destructive option focused will be approved by an
 *    Enter keypress meant for something else, so focus lands on the card's
 *    heading and the user must travel to a choice deliberately.
 *  - The options are radios in a `<fieldset>`, not buttons. Arrow keys move
 *    between them, the legend names the question for a screen reader, and —
 *    crucially — choosing is separate from committing, so a mis-click is not
 *    an irreversible action.
 *  - The decision is announced through `role="status"`, and the card keeps
 *    its resolved state on screen. An approval that vanishes leaves the user
 *    unable to answer "what did I just agree to".
 *
 * Real dialogs get `role="alertdialog"` and a focus trap. This one renders
 * inline in the thread, which is the shape most agents actually use, so it
 * is a `<section>` with a labelled heading instead.
 */

'use client'

import * as React from 'react'
import { AlertTriangle, ArrowRight, Check, ShieldAlert, X } from 'lucide-react'

export interface ApprovalOption {
  id: string
  label: string
  hint?: string
  /** Marks the choice that does the most damage if chosen carelessly. */
  destructive?: boolean
}

export interface ApprovalRequestCardProps {
  question?: string
  rationale?: string
  /** What changes if this is approved. Shown as a consequence list. */
  effects?: string[]
  options?: ApprovalOption[]
  /** Shown when the request touches something irreversible. */
  warning?: string
  className?: string
}

const DEFAULT_OPTIONS: ApprovalOption[] = [
  {
    id: 'core',
    label: 'Email the 38 accounts flagged high-risk',
    hint: 'Smallest blast radius — the cohort the model is most confident about',
  },
  {
    id: 'full',
    label: 'Email all 214 accounts in the at-risk segment',
    hint: 'Includes medium-confidence accounts; ~40 are likely false positives',
    destructive: true,
  },
  { id: 'none', label: 'Draft them for me to review, send nothing', hint: 'No mail leaves today' },
]

const DEFAULT_EFFECTS = [
  'Sends from retention@acme.com via the production Postmark key',
  'Writes a "save offer sent" activity to each CRM account',
  'Cannot be recalled once dispatched',
]

export function ApprovalRequestCard({
  question = 'Who should receive the retention offer?',
  rationale = 'I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.',
  effects = DEFAULT_EFFECTS,
  options = DEFAULT_OPTIONS,
  warning = 'This sends real email from a production account.',
  className = '',
}: ApprovalRequestCardProps) {
  const [choice, setChoice] = React.useState(options[0]?.id ?? '')
  const [decision, setDecision] = React.useState<'approved' | 'declined' | null>(null)

  const selected = options.find((o) => o.id === choice)
  const headingId = React.useId()

  return (
    <section
      aria-labelledby={headingId}
      className={`mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-amber-500/30 bg-card ${className}`}
    >
      {/* -- Header ----------------------------------------------------- */}
      <div className="flex items-start gap-3 border-b border-border/60 bg-amber-500/5 px-5 py-4">
        <span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400">
          <ShieldAlert aria-hidden className="h-4 w-4" />
        </span>

        <div className="min-w-0">
          <p className="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
            Waiting on you
          </p>
          {question ? (
            <h3 id={headingId} data-stress-ignore className="mt-0.5 text-base font-semibold leading-snug">
              {question}
            </h3>
          ) : null}
        </div>
      </div>

      <div className="space-y-5 px-5 py-4">
        <p className="text-sm leading-relaxed text-muted-foreground">{rationale}</p>

        {/* -- What actually happens ----------------------------------- */}
        {effects.length > 0 ? (
          <div className="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
            <h4
              data-stress-ignore
              className="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground"
            >
              If you approve
            </h4>
            <ul className="space-y-1.5">
              {effects.map((effect) => (
                <li key={effect} className="flex gap-2 text-xs leading-relaxed">
                  <ArrowRight
                    aria-hidden
                    className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180"
                  />
                  {effect}
                </li>
              ))}
            </ul>
          </div>
        ) : null}

        {/* -- Choices --------------------------------------------------- */}
        <fieldset disabled={decision !== null} className="disabled:opacity-60">
          <legend className="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">
            Choose one
          </legend>

          <div className="space-y-2">
            {options.map((option) => {
              const active = option.id === choice

              return (
                <label
                  key={option.id}
                  className={`flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring ${
                    active ? 'border-primary bg-primary/5' : 'border-border/60 hover:bg-muted/50'
                  }`}
                >
                  <input
                    type="radio"
                    name={`${headingId}-choice`}
                    value={option.id}
                    checked={active}
                    onChange={() => setChoice(option.id)}
                    className="mt-0.5 h-4 w-4 shrink-0 accent-primary"
                  />

                  <span className="min-w-0">
                    <span className="flex flex-wrap items-center gap-2 text-sm font-medium">
                      {option.label}
                      {option.destructive ? (
                        <span className="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-rose-600 dark:text-rose-400">
                          <AlertTriangle aria-hidden className="h-2.5 w-2.5" />
                          Widest reach
                        </span>
                      ) : null}
                    </span>
                    {option.hint ? (
                      <span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
                        {option.hint}
                      </span>
                    ) : null}
                  </span>
                </label>
              )
            })}
          </div>
        </fieldset>

        {warning ? (
          <p className="flex gap-2 text-xs leading-relaxed text-amber-600 dark:text-amber-400">
            <AlertTriangle aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0" />
            {warning}
          </p>
        ) : null}
      </div>

      {/* -- Decision --------------------------------------------------- */}
      <div className="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-3">
        {decision === null ? (
          <>
            <button
              type="button"
              onClick={() => setDecision('declined')}
              className="inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3.5 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X aria-hidden className="h-4 w-4" />
              Decline
            </button>

            <button
              type="button"
              onClick={() => setDecision('approved')}
              className="ms-auto inline-flex items-center gap-1.5 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <Check aria-hidden className="h-4 w-4" />
              Approve
            </button>
          </>
        ) : (
          <>
            {/* The card resolves in place. A decision that erases what was
                decided is not an audit trail. */}
            <p role="status" className="flex items-center gap-2 text-sm">
              {decision === 'approved' ? (
                <>
                  <Check aria-hidden className="h-4 w-4 text-emerald-500" />
                  <span>
                    Approved — <span className="font-medium">{selected?.label}</span>
                  </span>
                </>
              ) : (
                <>
                  <X aria-hidden className="h-4 w-4 text-muted-foreground" />
                  <span className="text-muted-foreground">Declined. Nothing was sent.</span>
                </>
              )}
            </p>

            <button
              type="button"
              onClick={() => setDecision(null)}
              className="ms-auto rounded-lg px-2.5 py-1.5 text-xs font-medium text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Undo
            </button>
          </>
        )}
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/approval-request-card.tsx and import it where you need the section:

import { ApprovalRequestCard } from '@/components/approval-request-card'

Customize

2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
questionstring'Who should receive the retention offer?'
rationalestring—
effectsWhat changes if this is approved. Shown as a consequence list.string[]DEFAULT_EFFECTS
optionsApprovalOption[]DEFAULT_OPTIONS
warningShown when the request touches something irreversible.string'This sends real email from a production acco…
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

approval-request-card.html
<!--
  Approval Request with Blast Radius — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section aria-labelledby="_R_0_" class="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-amber-500/30 bg-card ">
  <div class="flex items-start gap-3 border-b border-border/60 bg-amber-500/5 px-5 py-4">
    <span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400">
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-alert h-4 w-4" aria-hidden="true">
        <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>
        <path d="M12 8v4"></path>
        <path d="M12 16h.01"></path>
      </svg>
    </span>
    <div class="min-w-0">
      <p class="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">Waiting on you</p>
      <h3 id="_R_0_" data-stress-ignore="true" class="mt-0.5 text-base font-semibold leading-snug">Who should receive the retention offer?</h3>
    </div>
  </div>
  <div class="space-y-5 px-5 py-4">
    <p class="text-sm leading-relaxed text-muted-foreground">I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.</p>
    <div class="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
      <h4 data-stress-ignore="true" class="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">If you approve</h4>
      <ul class="space-y-1.5">
        <li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Sends from retention@acme.com via the production Postmark key</li>
        <li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Writes a &quot;save offer sent&quot; activity to each CRM account</li>
        <li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Cannot be recalled once dispatched</li>
      </ul>
    </div>
    <fieldset class="disabled:opacity-60">
      <legend class="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">Choose one</legend>
      <div class="space-y-2">
        <label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-primary bg-primary/5">
          <input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" checked="" value="core" />
          <span class="min-w-0">
            <span class="flex flex-wrap items-center gap-2 text-sm font-medium">Email the 38 accounts flagged high-risk</span>
            <span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">Smallest blast radius — the cohort the model is most confident about</span>
          </span>
        </label>
        <label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-border/60 hover:bg-muted/50">
          <input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" value="full" />
          <span class="min-w-0">
            <span class="flex flex-wrap items-center gap-2 text-sm font-medium">Email all 214 accounts in the at-risk segment<span class="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-2.5 w-2.5" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>Widest reach</span></span>
            <span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">Includes medium-confidence accounts; ~40 are likely false positives</span>
          </span>
        </label>
        <label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-border/60 hover:bg-muted/50">
          <input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" value="none" />
          <span class="min-w-0">
            <span class="flex flex-wrap items-center gap-2 text-sm font-medium">Draft them for me to review, send nothing</span>
            <span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">No mail leaves today</span>
          </span>
        </label>
      </div>
    </fieldset>
    <p class="flex gap-2 text-xs leading-relaxed text-amber-600 dark:text-amber-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>This sends real email from a production account.</p>
  </div>
  <div class="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-3">
    <button type="button" class="inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3.5 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>Decline</button>
    <button type="button" class="ms-auto inline-flex items-center gap-1.5 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-4 w-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Approve</button>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Human in the Loop

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Human in the Loop

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Human in the Loop blocks

View category
Open the full page for this block

Proposed Edits Diff Table

Machine-proposed row edits accepted one at a time, with before and after in real del/ins elements, a tri-state select-all that actually sets indeterminate, and a running count of what will be written.

Human in the Loop269 lines1 dep
Open the full page for this block

Recommendation with Confidence Meter

A suggestion scored with a native meter rather than a styled bar, banded in words as well as numbers, and showing what the model rejected — the fastest way a human catches a bad recommendation.

Human in the Loop258 lines1 dep
Open the full page for this block

Agent Permission Scopes

Read and write scopes separated rather than bundled, writes off by default, a required expiry, and switches that are real checkboxes with role="switch" — so the grant is toggleable by keyboard and announced as on or off.

Human in the Loop297 lines1 dep
Open the full page for this block

Bulk Approval Queue

Forty decisions and an honest way to clear them: bulk approve that cannot reach the high-impact rows, a select-all that says how many it takes, and the auto-approve deadline on every line.

Human in the Loop307 lines1 dep
Open the full page for this block

Approval Policy List

Which agent actions run unattended and which need a signature, stated as a policy the operator can read before anything happens.

Human in the Loop156 linesNo deps
Open the full page for this block

Escalation Queue

What is waiting on a person, ordered by how long it has been waiting rather than when it arrived.

Human in the Loop148 linesNo deps
Open the full page for this block

Human Oversight Split

Where a person sits in an automated run, written as four guarantees rather than as a promise that a human is involved.

Human in the Loop156 linesNo deps