nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsStaged action
Copy promptCopy prompt

Customize

Action heading

Provenance or citation note under the heading

Policy verification line in the footer

Loop a scripted inline-edit and approval sequence

JSX · copied with Copy prompt

<StagedAction autoPlay />

Staged actionFreeNew

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.

Installation

Free to install: no account, access key or components.json setup needed.

$pnpm dlx shadcn@latest add https://nextui.dev/r/staged-action.json

Usage

1"use client";
2
3import { StagedAction } from "@/components/nextui/staged-action";
4
5export function StageTransfer() {
6 return (
7 <StagedAction
8 title="Stage wire transfer"
9 subtitle="Pre-filled from invoice INV-2084"
10 guardrail="Within $25,000 single-approval policy"
11 onApprove={(values) => console.log("Approved staged values:", values)}
12 />
13 );
14}

API reference

Edit props live in the preview above, or see all 11 props
PropTypeDefaultDescription
titlestring"Stage wire transfer"Action heading
subtitlestring–Provenance or citation note under the heading
guardrailstring"Within $25,000 single-approval policy"Policy verification line in the footer
fieldsStagedField[]–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
badgestring'"Staged"'Pill text in the header
iconLucideIcon–Header icon
doneLabelstring–Text shown after Approve & Execute completes
autoPlaybooleanfalseLoop a scripted inline-edit and approval sequence
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/staged-action.tsx
"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>
  );
}
Sign in