Human-in-the-loop staged action card: pre-filled fields grounded in agent context that the user can edit inline before clicking Approve or Discard.
Free to install: no account, access key or components.json setup needed.
$pnpm dlx shadcn@latest add https://nextui.dev/r/staged-action.json
1"use client";23import { StagedAction } from "@/components/nextui/staged-action";45export function StageTransfer() {6return (7<StagedAction8title="Stage wire transfer"9subtitle="Pre-filled from invoice INV-2084"10guardrail="Within $25,000 single-approval policy"11onApprove={(values) => console.log("Approved staged values:", values)}12/>13);14}
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Stage wire transfer" | Action heading |
| subtitle | string | – | Provenance or citation note under the heading |
| guardrail | string | "Within $25,000 single-approval policy" | Policy verification line in the footer |
| fields | StagedField[] | – | Pre-filled key/label/value rows; rows with editable !== false can be edited inline |
| onApprove | (values: Record<string, string>) => void | – | Called with the current field values when Approve is clicked |
| onDiscard | () => void | – | Called when Discard is clicked |
| badge | string | '"Staged"' | Pill text in the header |
| icon | LucideIcon | – | Header icon |
| doneLabel | string | – | Text shown after Approve & Execute completes |
| autoPlay | boolean | false | Loop a scripted inline-edit and approval sequence |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Banknote, Mail, Pencil, RotateCcw, Rocket, ShieldCheck, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type StagedField = {
key: string;
label: string;
value: string;
editable?: boolean;
mono?: boolean;
};
export type StagedActionProps = {
title?: string;
subtitle?: string;
badge?: string;
guardrail?: string;
/** Icon in the header tile. */
icon?: React.ReactNode;
fields?: StagedField[];
/** Shown after approval, e.g. "Transfer sent · txn_8F2K91". */
doneLabel?: string;
/** Called with the final (possibly edited) key/value map when approved. */
onApprove?: (values: Record<string, string>) => void;
/** Called when the user discards the staged action. */
onDiscard?: () => void;
/** Loop a scripted edit → approve → execute demo across three action types. */
autoPlay?: boolean;
className?: string;
};
type Preset = {
title: string;
subtitle: string;
guardrail: string;
icon: typeof Banknote;
fields: StagedField[];
edit: { key: string; to: string };
done: string;
};
const PRESETS: Preset[] = [
{
title: "Wire transfer",
subtitle: "Drafted from invoice INV-2084",
guardrail: "Within the $25,000 single-approver limit",
icon: Banknote,
fields: [
{ key: "amount", label: "Amount", value: "$12,500.00", editable: true, mono: true },
{ key: "recipient", label: "Recipient", value: "Acme Cloud Infrastructure", editable: true },
{ key: "account", label: "From", value: "Operating ••4819", editable: false, mono: true },
{ key: "memo", label: "Memo", value: "Q4 GPU reservation · INV-2084", editable: true },
],
edit: { key: "amount", to: "$11,800.00" },
done: "Transfer sent · txn_8F2K91",
},
{
title: "Bulk email",
subtitle: "Drafted from the trial-expiry playbook",
guardrail: "Recipients opted in · unsubscribe link added",
icon: Mail,
fields: [
{ key: "to", label: "To", value: "248 trial users", editable: false },
{ key: "subject", label: "Subject", value: "Your trial ends in 3 days", editable: true },
{ key: "send", label: "Send at", value: "Tomorrow, 09:00", editable: true, mono: true },
{ key: "from", label: "From", value: "team@studio.test", editable: false, mono: true },
],
edit: { key: "send", to: "Today, 16:00" },
done: "Scheduled · 248 emails queued",
},
{
title: "Publish release",
subtitle: "Drafted from 12 merged pull requests",
guardrail: "CI green on main · changelog generated",
icon: Rocket,
fields: [
{ key: "tag", label: "Tag", value: "v2.4.0", editable: true, mono: true },
{ key: "name", label: "Title", value: "Offline mode & faster sync", editable: true },
{ key: "target", label: "Target", value: "main @ 9c1e2fa", editable: false, mono: true },
{ key: "notes", label: "Notes", value: "9 features · 3 fixes", editable: true },
],
edit: { key: "tag", to: "v2.5.0" },
done: "Release v2.5.0 published",
},
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 8200;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop, cycle: Math.floor(elapsed / loop) };
}
type Status = "staged" | "executing" | "done" | "discarded";
type View = { values: Record<string, string>; editing: string | null; status: Status; progress: number; pressing: boolean };
const initialValues = (fields: StagedField[]) => Object.fromEntries(fields.map((f) => [f.key, f.value]));
function demoView(t: number, p: Preset): View {
const values = initialValues(p.fields);
const from = values[p.edit.key];
let editing: string | null = null;
if (t >= 1200 && t < 2700) {
editing = p.edit.key;
// Clear the old value, then type the new one.
const clear = Math.max(0, from.length - Math.floor(Math.max(0, t - 1500) / 40));
const typed = t >= 1500 + from.length * 40 ? p.edit.to.slice(0, Math.floor((t - 1500 - from.length * 40) / 60) + 1) : "";
values[p.edit.key] = clear > 0 && !typed ? from.slice(0, clear) : typed;
}
if (t >= 2700) values[p.edit.key] = p.edit.to;
const status: Status = t >= 5000 ? "done" : t >= 3700 ? "executing" : "staged";
return { values, editing, status, progress: status === "executing" ? (t - 3700) / 1300 : status === "done" ? 1 : 0, pressing: t >= 3400 && t < 3700 };
}
/** A pre-filled action the agent staged (transfer, bulk email, release…). Fields edit inline before Approve & execute. */
export function StagedAction({
title,
subtitle,
badge = "Staged",
guardrail,
icon,
fields: fieldsProp,
doneLabel,
onApprove,
onDiscard,
autoPlay = false,
className,
}: StagedActionProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [frozen, setFrozen] = useState(0);
const demo = autoPlay && !reduce && !touched && !fieldsProp;
const { ref, t, cycle } = useDemoClock(demo, LOOP);
const preset = PRESETS[demo ? cycle % PRESETS.length : frozen];
const fields = fieldsProp ?? preset.fields;
const [view, setView] = useState<View>({ values: initialValues(fields), editing: null, status: "staged", progress: 0, pressing: false });
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t, preset));
}
const originals = initialValues(fields);
const Icon = preset.icon;
const shownTitle = title ?? preset.title;
const shownSubtitle = subtitle ?? preset.subtitle;
const shownGuardrail = guardrail ?? preset.guardrail;
const shownDone = doneLabel ?? (fieldsProp ? "Approved and executed" : preset.done);
const editedCount = fields.filter((f) => view.values[f.key] !== originals[f.key]).length;
const patch = (p: Partial<View>) => {
if (demo) setFrozen(cycle % PRESETS.length);
setTouched(true);
setView((v) => ({ ...v, ...p }));
};
const approve = () => {
patch({ editing: null, status: "executing", progress: 0 });
onApprove?.(view.values);
let p = 0;
const id = setInterval(() => {
p += 0.1;
setView((v) => ({ ...v, progress: Math.min(1, p), status: p >= 1 ? "done" : "executing" }));
if (p >= 1) clearInterval(id);
}, 90);
};
const discard = () => {
patch({ editing: null, status: "discarded" });
onDiscard?.();
};
const reset = () => patch({ values: originals, editing: null, status: "staged", progress: 0 });
const onKey = (e: KeyboardEvent<HTMLInputElement>, key: string) => {
if (e.key === "Enter") patch({ editing: null });
if (e.key === "Escape") patch({ editing: null, values: { ...view.values, [key]: originals[key] } });
};
const locked = view.status !== "staged";
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={shownTitle}
initial={reduce ? false : { opacity: 0, y: 10, filter: "blur(3px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? undefined : { opacity: 0, y: -10, filter: "blur(3px)" }}
transition={{ duration: 0.35, ease: EASE }}
>
{/* Header */}
<div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-xs">
{icon ?? <Icon className="size-4" aria-hidden />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{shownTitle}</span>
<span
className={cn(
"rounded-full border px-1.5 font-mono text-[10px] leading-4 transition-colors",
view.status === "done"
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: view.status === "discarded"
? "text-muted-foreground"
: "border-dashed border-foreground/30 text-foreground",
)}
>
{view.status === "done" ? "Executed" : view.status === "discarded" ? "Discarded" : view.status === "executing" ? "Running" : badge}
</span>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<Sparkles className="size-3 shrink-0" aria-hidden />
{shownSubtitle}
</p>
</div>
</div>
{/* Fields */}
<dl className="mx-3 divide-y rounded-xl border bg-background">
{fields.map((f) => {
const value = view.values[f.key] ?? "";
const editing = view.editing === f.key && !locked;
const edited = value !== originals[f.key] && !editing;
const canEdit = f.editable !== false && !locked;
return (
<div key={f.key} className={cn("group relative flex min-h-10 items-center gap-3 px-3 text-xs transition-colors", editing && "bg-muted/60")}>
<dt className="flex w-20 shrink-0 items-center gap-1.5 text-muted-foreground">
{f.label}
<AnimatePresence>
{edited && (
<motion.span
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className="size-1.5 rounded-full bg-sky-500"
title="Edited"
/>
)}
</AnimatePresence>
</dt>
<dd className="flex min-w-0 flex-1 items-center justify-end gap-2">
{editing ? (
<input
autoFocus={touched}
aria-label={f.label}
value={value}
onChange={(e) => patch({ values: { ...view.values, [f.key]: e.target.value } })}
onBlur={() => touched && patch({ editing: null })}
onKeyDown={(e) => onKey(e, f.key)}
className={cn(
"h-7 w-full max-w-60 rounded-md border border-sky-500/60 bg-background px-2 text-right text-xs shadow-[0_0_0_3px_rgb(14_165_233/0.15)] outline-none",
f.mono && "font-mono",
)}
/>
) : (
<button
type="button"
disabled={!canEdit}
onClick={() => patch({ editing: f.key })}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-right outline-none focus-visible:ring-2 focus-visible:ring-ring",
canEdit ? "cursor-text hover:bg-muted" : "cursor-default",
)}
>
{edited && <span className="truncate text-[10px] text-muted-foreground line-through">{originals[f.key]}</span>}
<motion.span
key={value}
initial={reduce || !edited ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className={cn("truncate font-medium", f.mono && "font-mono", view.status === "discarded" && "text-muted-foreground line-through")}
>
{value}
</motion.span>
{canEdit && <Pencil className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />}
</button>
)}
</dd>
</div>
);
})}
</dl>
</motion.div>
</AnimatePresence>
{/* Footer */}
<div className="mt-3 border-t bg-muted/30 px-3 py-2.5">
<AnimatePresence mode="wait" initial={false}>
{view.status === "done" || view.status === "discarded" ? (
<motion.div
key="result"
role="status"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex h-8 items-center gap-2 text-xs"
>
{view.status === "done" ? (
<svg viewBox="0 0 24 24" className="size-6 shrink-0" aria-hidden>
<motion.circle cx="12" cy="12" r="11" className="fill-emerald-500" initial={reduce ? false : { scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 18 }} style={{ transformOrigin: "center" }} />
<motion.path
d="M7 12.5l3.2 3.2L17 9"
fill="none"
stroke="white"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ delay: 0.15, duration: 0.35, ease: "easeOut" }}
/>
</svg>
) : (
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<X className="size-3.5" aria-hidden />
</span>
)}
<span className="min-w-0 flex-1 truncate font-medium">{view.status === "done" ? shownDone : "Discarded · nothing was executed"}</span>
<button
type="button"
onClick={reset}
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 text-[11px] text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" aria-hidden />
Reset
</button>
</motion.div>
) : (
<motion.div key="actions" exit={reduce ? undefined : { opacity: 0, y: -6 }} transition={{ duration: 0.2 }} className="flex h-8 items-center gap-2">
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
<span className="truncate">{editedCount > 0 ? `${editedCount} field edited · ${shownGuardrail}` : shownGuardrail}</span>
</span>
<button
type="button"
onClick={discard}
disabled={locked}
className="h-8 shrink-0 cursor-pointer rounded-md border bg-background px-2.5 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
Discard
</button>
<motion.button
type="button"
onClick={approve}
disabled={locked}
animate={{ scale: view.pressing ? 0.94 : 1 }}
transition={{ type: "spring", stiffness: 600, damping: 22 }}
className="relative h-8 shrink-0 cursor-pointer overflow-hidden rounded-md bg-foreground px-3 text-xs font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default"
>
{view.status === "executing" && (
<span className="absolute inset-y-0 left-0 bg-background/25 transition-[width] duration-100 ease-linear" style={{ width: `${view.progress * 100}%` }} aria-hidden />
)}
<span className="relative">{view.status === "executing" ? "Executing…" : "Approve & execute"}</span>
</motion.button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
export default function StagedActionDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<StagedAction autoPlay />
</div>
);
}