ComponentsSteps
Steps
Show the current step and progress through a task.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport the component into your page or component file.
import {
Steps,
StepsPanel,
StepsProvider,
useCurrentStep,
useNextSteps,
useSteps,
useStepsState,
type StepItemData,
type StepRenderState,
} from "@dethink/components";Production-oriented flows that keep domain state outside the component while preserving stable step identity.
Branching agent launch
Choose a guarded production rollout or a team sandbox, then insert or remove an optional Privacy review. Provider hooks preserve Policy while the future suffix changes, and each step's typed panel key resolves through a consumer-owned component registry.
Billing copilot
DraftConfigure a grounded support agent, then choose its release path.
Decision point
How should this agent be released?
The answer changes only the next and future steps. Policy remains current while the branch is replaced.
Show sourceexamples/steps/branching-agent-launch.tsx
"use client";
import { useState, type ComponentType, type ReactNode } from "react";
import {
Badge,
Button,
Steps,
StepsPanel,
StepsProvider,
useCurrentStep,
useNextSteps,
useSteps,
useStepsState,
type StepItemData,
type StepsPanelRenderContext,
} from "@dethink/components";
import {
ArrowLeft,
ArrowRight,
Bot,
BrainCircuit,
CheckCircle2,
Fingerprint,
KeyRound,
LockKeyhole,
Rocket,
RotateCcw,
ShieldCheck,
Sparkles,
Wrench,
} from "lucide-react";
type RolloutPolicy = "guarded" | "sandbox";
type AgentPanelKey =
| "objective"
| "context"
| "policy"
| "security"
| "privacy"
| "approval"
| "dry-run"
| "launch";
type AgentStepMeta = {
panelKey: AgentPanelKey;
eyebrow: string;
panelTitle: string;
panelDescription: string;
};
const sharedItems: StepItemData<AgentStepMeta>[] = [
{
id: "objective",
label: "Objective",
description: "Define the job.",
icon: <Sparkles aria-hidden="true" className="size-4" />,
data: {
panelKey: "objective",
eyebrow: "Agent brief",
panelTitle: "Resolve billing questions",
panelDescription:
"The agent can explain invoices, retrieve account context, and draft a response for the support team.",
},
},
{
id: "context",
label: "Knowledge",
description: "Connect sources.",
icon: <BrainCircuit aria-hidden="true" className="size-4" />,
data: {
panelKey: "context",
eyebrow: "Grounding",
panelTitle: "Three trusted sources connected",
panelDescription:
"Billing policy, invoice events, and the customer profile are available to the agent at run time.",
},
},
{
id: "policy",
label: "Policy",
description: "Choose guardrails.",
icon: <ShieldCheck aria-hidden="true" className="size-4" />,
data: {
panelKey: "policy",
eyebrow: "Decision point",
panelTitle: "How should this agent be released?",
panelDescription:
"The answer changes only the next and future steps. Policy remains current while the branch is replaced.",
},
},
];
const guardedBranch: StepItemData<AgentStepMeta>[] = [
{
id: "security",
label: "Security",
description: "Review tool access.",
icon: <LockKeyhole aria-hidden="true" className="size-4" />,
data: {
panelKey: "security",
eyebrow: "Guarded rollout",
panelTitle: "Review sensitive tool access",
panelDescription:
"Security confirms that invoice lookup is read-only and that payment changes always require a person.",
},
},
{
id: "approval",
label: "Approval",
description: "Human sign-off.",
icon: <KeyRound aria-hidden="true" className="size-4" />,
data: {
panelKey: "approval",
eyebrow: "Guarded rollout",
panelTitle: "Support lead approval",
panelDescription:
"A support lead reviews the dry-run transcript before the agent can answer live conversations.",
},
},
{
id: "launch",
label: "Launch",
description: "Release gradually.",
icon: <Rocket aria-hidden="true" className="size-4" />,
data: {
panelKey: "launch",
eyebrow: "Ready",
panelTitle: "Launch to 10% of billing conversations",
panelDescription:
"Monitor handoff rate and answer quality before increasing traffic.",
},
},
];
const sandboxBranch: StepItemData<AgentStepMeta>[] = [
{
id: "dry-run",
label: "Dry run",
description: "Test with fixtures.",
icon: <Wrench aria-hidden="true" className="size-4" />,
data: {
panelKey: "dry-run",
eyebrow: "Sandbox path",
panelTitle: "Run the evaluation set",
panelDescription:
"The agent answers 50 synthetic billing cases without access to live customer conversations.",
},
},
{
id: "launch",
label: "Launch",
description: "Enable sandbox.",
icon: <Rocket aria-hidden="true" className="size-4" />,
data: {
panelKey: "launch",
eyebrow: "Ready",
panelTitle: "Open the team sandbox",
panelDescription:
"Invite the support team to test prompts and flag responses before a guarded production rollout.",
},
},
];
const privacyReview: StepItemData<AgentStepMeta> = {
id: "privacy",
label: "Privacy",
description: "Inspect data use.",
icon: <Fingerprint aria-hidden="true" className="size-4" />,
optional: true,
data: {
panelKey: "privacy",
eyebrow: "Optional review",
panelTitle: "Verify customer-data boundaries",
panelDescription:
"Privacy confirms that customer context is used only for the active support request and is not retained for training.",
},
};
function getItems(policy: RolloutPolicy) {
return [
...sharedItems,
...(policy === "guarded" ? guardedBranch : sandboxBranch),
];
}
type AgentPanelProps = StepsPanelRenderContext<AgentStepMeta> & {
policy: RolloutPolicy;
onEditPolicy: () => void;
onPolicyChange: (policy: RolloutPolicy) => void;
};
function AgentPanelShell({
children,
policy,
step,
}: AgentPanelProps & { children?: ReactNode }) {
return (
<section
aria-labelledby="agent-step-panel-title"
className="border-border bg-muted/30 rounded-xl border p-4 sm:p-5"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="max-w-2xl">
<p className="text-primary text-xs font-semibold tracking-[0.12em] uppercase">
{step.data?.eyebrow}
</p>
<h4
id="agent-step-panel-title"
className="font-heading mt-1 text-lg font-semibold"
>
{step.data?.panelTitle}
</h4>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{step.data?.panelDescription}
</p>
</div>
<Badge size="sm" variant="outline">
{policy === "guarded" ? "Production guardrails" : "Sandbox only"}
</Badge>
</div>
{children}
</section>
);
}
function StandardAgentPanel(props: AgentPanelProps) {
return (
<AgentPanelShell {...props}>
<Button
className="mt-5"
size="sm"
variant="soft"
onClick={props.onEditPolicy}
>
Edit rollout policy
</Button>
</AgentPanelShell>
);
}
function PolicyAgentPanel(props: AgentPanelProps) {
const { nextSteps, insertNextStep, removeNextStep } =
useNextSteps<AgentStepMeta>();
const includesPrivacyReview = nextSteps.some(
(step) => step.id === privacyReview.id,
);
function togglePrivacyReview() {
if (includesPrivacyReview) {
removeNextStep(privacyReview.id);
return;
}
insertNextStep(0, privacyReview);
}
return (
<AgentPanelShell {...props}>
<div
role="group"
aria-label="Agent rollout policy"
className="mt-5 grid gap-3 sm:grid-cols-2"
>
<button
type="button"
aria-pressed={props.policy === "guarded"}
className="border-border bg-background focus-visible:ring-ring aria-pressed:border-primary aria-pressed:bg-primary/5 rounded-lg border p-4 text-start transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={() => props.onPolicyChange("guarded")}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck aria-hidden="true" className="text-primary size-4" />
Guarded production
</span>
<span className="text-muted-foreground mt-1.5 block text-xs leading-5">
Add security review and human approval before a gradual launch.
</span>
</button>
<button
type="button"
aria-pressed={props.policy === "sandbox"}
className="border-border bg-background focus-visible:ring-ring aria-pressed:border-primary aria-pressed:bg-primary/5 rounded-lg border p-4 text-start transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={() => props.onPolicyChange("sandbox")}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<Wrench aria-hidden="true" className="text-primary size-4" />
Team sandbox
</span>
<span className="text-muted-foreground mt-1.5 block text-xs leading-5">
Replace approvals with a fixture-based dry run and sandbox launch.
</span>
</button>
</div>
<button
type="button"
aria-pressed={includesPrivacyReview}
className="border-border bg-background focus-visible:ring-ring aria-pressed:border-primary aria-pressed:bg-primary/5 mt-3 flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-start transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={togglePrivacyReview}
>
<span className="flex min-w-0 items-center gap-3">
<span className="bg-primary/10 text-primary grid size-8 shrink-0 place-items-center rounded-lg">
<Fingerprint aria-hidden="true" className="size-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold">
Optional privacy review
</span>
<span className="text-muted-foreground mt-0.5 block text-xs">
Insert or remove a real future step with its own registered panel.
</span>
</span>
</span>
<Badge size="xs" tone={includesPrivacyReview ? "primary" : "neutral"}>
{includesPrivacyReview ? "Added" : "Not added"}
</Badge>
</button>
</AgentPanelShell>
);
}
const agentPanelRegistry: Record<
AgentPanelKey,
ComponentType<AgentPanelProps>
> = {
approval: StandardAgentPanel,
context: StandardAgentPanel,
"dry-run": StandardAgentPanel,
launch: StandardAgentPanel,
objective: StandardAgentPanel,
policy: PolicyAgentPanel,
privacy: StandardAgentPanel,
security: StandardAgentPanel,
};
function AgentLaunchWorkflow({
launched,
onLaunchedChange,
onPolicyChange,
onReset,
policy,
}: {
launched: boolean;
onLaunchedChange: (launched: boolean) => void;
onPolicyChange: (policy: RolloutPolicy) => void;
onReset: () => void;
policy: RolloutPolicy;
}) {
const steps = useSteps<AgentStepMeta>();
const { currentIndex, isFirstStep, isLastStep } =
useCurrentStep<AgentStepMeta>();
const { replaceNextSteps } = useNextSteps<AgentStepMeta>();
function selectPolicy(nextPolicy: RolloutPolicy) {
onPolicyChange(nextPolicy);
replaceNextSteps(nextPolicy === "guarded" ? guardedBranch : sandboxBranch);
onLaunchedChange(false);
}
function selectStep(nextValue: string) {
steps.setValue(nextValue);
onLaunchedChange(false);
}
function goBack() {
const previous = steps.items[currentIndex - 1];
if (previous) {
selectStep(previous.id);
}
}
function goForward() {
const next = steps.items[currentIndex + 1];
if (next) {
selectStep(next.id);
return;
}
onLaunchedChange(true);
}
return (
<div className="border-border bg-background overflow-hidden rounded-2xl border shadow-sm">
<header className="border-border bg-muted/25 flex flex-wrap items-start justify-between gap-4 border-b px-5 py-4 sm:px-6">
<div className="flex min-w-0 items-start gap-3">
<span className="bg-primary/10 text-primary grid size-10 shrink-0 place-items-center rounded-xl">
<Bot aria-hidden="true" className="size-5" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-heading text-base font-semibold">
Billing copilot
</h3>
<Badge
size="xs"
tone={launched ? "success" : "primary"}
variant="soft"
leadingIcon={
launched ? (
<CheckCircle2 aria-hidden="true" />
) : (
<Sparkles aria-hidden="true" />
)
}
>
{launched ? "Queued" : "Draft"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-sm">
Configure a grounded support agent, then choose its release path.
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
leftIcon={<RotateCcw aria-hidden="true" />}
onClick={onReset}
>
Reset
</Button>
</header>
<div className="grid gap-6 p-5 sm:p-6">
<Steps<AgentStepMeta>
interactive
showProgress
aria-label="Agent launch progress"
motionPreset="expressive"
{...steps.stepsProps}
onValueChange={selectStep}
formatProgress={(percentage, context) =>
`${context.currentIndex + 1} of ${context.count} · ${Math.round(percentage)}%`
}
/>
<StepsPanel<AgentStepMeta>
fallback={
<p className="text-muted-foreground text-sm">
Select a visible step to continue.
</p>
}
render={(context) => {
const panelKey = context.step.data?.panelKey;
if (!panelKey) {
return null;
}
const Panel = agentPanelRegistry[panelKey];
return (
<Panel
{...context}
policy={policy}
onPolicyChange={selectPolicy}
onEditPolicy={() => selectStep("policy")}
/>
);
}}
/>
</div>
<footer className="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 border-t px-5 py-4 sm:px-6">
<p aria-live="polite" className="text-muted-foreground text-sm">
{launched
? policy === "guarded"
? "Guarded rollout queued for review."
: "Team sandbox is ready to open."
: "Provider hooks keep the indicator, branch, panels, and controls in sync."}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={isFirstStep}
leftIcon={<ArrowLeft aria-hidden="true" />}
onClick={goBack}
>
Back
</Button>
<Button
size="sm"
rightIcon={
isLastStep ? (
<Rocket aria-hidden="true" />
) : (
<ArrowRight aria-hidden="true" />
)
}
onClick={goForward}
>
{isLastStep ? "Queue launch" : "Continue"}
</Button>
</div>
</footer>
</div>
);
}
export function StepsBranchingAgentLaunch() {
const [policy, setPolicy] = useState<RolloutPolicy>("guarded");
const [items, setItems] = useState(() => getItems("guarded"));
const [current, setCurrent] = useState("policy");
const [launched, setLaunched] = useState(false);
const steps = useStepsState({
items,
onItemsChange: setItems,
value: current,
onValueChange: setCurrent,
});
function reset() {
setPolicy("guarded");
setItems(getItems("guarded"));
setCurrent("policy");
setLaunched(false);
}
return (
<StepsProvider state={steps}>
<AgentLaunchWorkflow
launched={launched}
onLaunchedChange={setLaunched}
onPolicyChange={setPolicy}
onReset={reset}
policy={policy}
/>
</StepsProvider>
);
}Deployment command center
A vertical operational flow starts at an error gate. Retry restores the current stage, unlocks the canary, and lets the operator complete or explicitly skip an optional observation hold.
checkout-api
productionGate blockedRelease 2026.07.09 · commit 9d42f7a
Active stage
Health gate
Compare error rate and latency against production
Error rate
1.8%
baseline 0.7%
p95 latency
486 ms
baseline 320 ms
Healthy pods
11 / 12
baseline 12 / 12
A cold cache caused the latency spike. Retry the health gate after the warm-up window.
Health gate blocked by elevated latency.
Show sourceexamples/steps/deployment-command-center.tsx
"use client";
import { useState } from "react";
import { Badge, Button, Steps, type StepItemData } from "@dethink/components";
import {
Activity,
CheckCircle2,
CircleDot,
CloudCog,
Gauge,
GitCommitHorizontal,
RefreshCw,
RotateCcw,
ServerCog,
ShieldCheck,
} from "lucide-react";
type DeploymentStepMeta = {
detail: string;
};
const baseDeploymentItems: StepItemData<DeploymentStepMeta>[] = [
{
id: "build",
label: "Build",
description: "Image signed",
status: "complete",
data: { detail: "sha256:8af3 · 182 MB" },
},
{
id: "tests",
label: "Tests",
description: "428 checks",
status: "complete",
data: { detail: "Unit, integration, and policy suites passed" },
},
{
id: "verify",
label: "Health gate",
description: "Observe baseline",
data: { detail: "Compare error rate and latency against production" },
},
{
id: "canary",
label: "Canary",
description: "10% traffic",
data: { detail: "Route a small cohort to the new release" },
},
{
id: "observe",
label: "Observation",
description: "15 minute hold",
optional: true,
data: { detail: "Watch SLOs before global promotion" },
},
{
id: "rollout",
label: "Global",
description: "100% traffic",
data: { detail: "Complete the production rollout" },
},
];
const metrics = [
{ label: "Error rate", value: "1.8%", baseline: "0.7%" },
{ label: "p95 latency", value: "486 ms", baseline: "320 ms" },
{ label: "Healthy pods", value: "11 / 12", baseline: "12 / 12" },
];
export function StepsDeploymentCommandCenter() {
const [current, setCurrent] = useState("verify");
const [recovered, setRecovered] = useState(false);
const [observationSkipped, setObservationSkipped] = useState(false);
const [announcement, setAnnouncement] = useState(
"Health gate blocked by elevated latency.",
);
const items = baseDeploymentItems.map((item) => {
if (item.id === "verify" && !recovered) {
return { ...item, status: "error" as const };
}
if (item.id === "observe" && observationSkipped) {
return { ...item, status: "skipped" as const };
}
if (["canary", "observe", "rollout"].includes(item.id) && !recovered) {
return { ...item, disabled: true };
}
return item;
});
const currentItem = items.find((item) => item.id === current);
const isCanary = current === "canary";
const isObservation = current === "observe";
const isGlobal = current === "rollout";
function reset() {
setCurrent("verify");
setRecovered(false);
setObservationSkipped(false);
setAnnouncement("Health gate blocked by elevated latency.");
}
return (
<div className="border-border bg-background overflow-hidden rounded-2xl border shadow-sm">
<header className="border-border bg-muted/25 flex flex-wrap items-start justify-between gap-4 border-b px-5 py-4 sm:px-6">
<div className="flex items-start gap-3">
<span className="bg-primary/10 text-primary grid size-10 shrink-0 place-items-center rounded-xl">
<CloudCog aria-hidden="true" className="size-5" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-heading text-base font-semibold">
checkout-api
</h3>
<Badge size="xs" variant="outline">
production
</Badge>
<Badge
size="xs"
tone={recovered ? "success" : "destructive"}
variant="soft"
leadingIcon={
recovered ? (
<CheckCircle2 aria-hidden="true" />
) : (
<CircleDot aria-hidden="true" />
)
}
>
{recovered ? "Healthy" : "Gate blocked"}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-sm">
Release 2026.07.09 · commit 9d42f7a
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
leftIcon={<RotateCcw aria-hidden="true" />}
onClick={reset}
>
Reset incident
</Button>
</header>
<div className="grid gap-6 p-5 sm:p-6 lg:grid-cols-[minmax(15rem,0.72fr)_minmax(0,1.28fr)]">
<Steps<DeploymentStepMeta>
interactive
showProgress
aria-label="Production deployment"
orientation="vertical"
items={items}
value={current}
onValueChange={(value) => {
setCurrent(value);
setAnnouncement(`Opened ${value} deployment stage.`);
}}
formatProgress={(percentage) => `${Math.round(percentage)}% rollout`}
/>
<section
aria-labelledby="deployment-stage-title"
className="border-border bg-muted/25 min-w-0 rounded-xl border p-4 sm:p-5"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-primary text-xs font-semibold tracking-[0.12em] uppercase">
Active stage
</p>
<h4
id="deployment-stage-title"
className="font-heading mt-1 text-xl font-semibold"
>
{currentItem?.label}
</h4>
<p className="text-muted-foreground mt-1 text-sm">
{currentItem?.data?.detail}
</p>
</div>
<Badge
tone={
!recovered && current === "verify"
? "destructive"
: isGlobal
? "success"
: "primary"
}
variant="soft"
>
{!recovered && current === "verify"
? "Action required"
: isGlobal
? "Ready to complete"
: "In progress"}
</Badge>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{metrics.map((metric) => (
<div
key={metric.label}
className="border-border bg-background rounded-lg border p-3"
>
<p className="text-muted-foreground text-xs">{metric.label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{recovered ? metric.baseline : metric.value}
</p>
<p className="text-muted-foreground mt-1 text-xs">
baseline {metric.baseline}
</p>
</div>
))}
</div>
<div className="border-border bg-background mt-4 rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm font-semibold">
{current === "verify" ? (
<Activity aria-hidden="true" className="text-primary size-4" />
) : current === "canary" ? (
<Gauge aria-hidden="true" className="text-primary size-4" />
) : current === "rollout" ? (
<ServerCog aria-hidden="true" className="text-primary size-4" />
) : (
<GitCommitHorizontal
aria-hidden="true"
className="text-primary size-4"
/>
)}
Operator action
</div>
<p className="text-muted-foreground mt-2 text-sm leading-6">
{!recovered && current === "verify"
? "A cold cache caused the latency spike. Retry the health gate after the warm-up window."
: current === "verify"
? "Metrics returned to baseline. The canary destination is now enabled."
: isCanary
? "Canary metrics are stable. Continue to the observation hold or explicitly skip it."
: isObservation
? "Hold traffic at 10% while the final SLO window completes."
: isGlobal
? "All gates passed. Promote the release to every production pod."
: "Review the completed stage or choose another enabled destination."}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{!recovered && current === "verify" ? (
<Button
size="sm"
leftIcon={<RefreshCw aria-hidden="true" />}
onClick={() => {
setRecovered(true);
setAnnouncement(
"Health gate recovered. Canary stage enabled.",
);
}}
>
Retry health gate
</Button>
) : current === "verify" ? (
<Button
size="sm"
rightIcon={<Gauge aria-hidden="true" />}
onClick={() => {
setCurrent("canary");
setAnnouncement(
"Canary deployment started at 10% traffic.",
);
}}
>
Start canary
</Button>
) : isCanary ? (
<>
<Button
size="sm"
onClick={() => {
setCurrent("observe");
setObservationSkipped(false);
setAnnouncement("Observation hold started.");
}}
>
Begin observation
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setObservationSkipped(true);
setCurrent("rollout");
setAnnouncement(
"Optional observation skipped. Global stage opened.",
);
}}
>
Skip optional hold
</Button>
</>
) : isObservation ? (
<Button
size="sm"
rightIcon={<ServerCog aria-hidden="true" />}
onClick={() => {
setCurrent("rollout");
setAnnouncement(
"Observation complete. Global stage opened.",
);
}}
>
Complete hold
</Button>
) : isGlobal ? (
<Button
size="sm"
leftIcon={<ShieldCheck aria-hidden="true" />}
onClick={() =>
setAnnouncement("Global rollout approved and queued.")
}
>
Promote globally
</Button>
) : null}
</div>
</div>
<p aria-live="polite" className="text-muted-foreground mt-4 text-sm">
{announcement}
</p>
</section>
</div>
</div>
);
}Typed approval routing
Domain data drives owner and due-date labels through renderItem. Security and Budget approvals unlock later destinations while product-specific progress tracks approvals rather than the current ordinal.
Summer launch approvals
3 reviews leftA typed approval route with gated destinations and rich step labels.
Current review
Arun Rao
Product security · due Today, 16:00
Confirm that the audience export excludes restricted account fields.
- Data classification reviewed
- Export scope verified
- Retention window documented
Budget unlocks after Security approval. Launch unlocks after Budget approval.
Security review is ready for Arun Rao.
Show sourceexamples/steps/approval-routing.tsx
"use client";
import { useState } from "react";
import {
Badge,
Button,
Steps,
type BadgeTone,
type StepItemData,
type StepStatus,
} from "@dethink/components";
import {
ArrowRight,
Check,
FileCheck2,
MessageSquareWarning,
RotateCcw,
Send,
UsersRound,
} from "lucide-react";
type ApprovalMeta = {
owner: string;
initials: string;
role: string;
due: string;
note: string;
checklist: string[];
};
const approvalItems: StepItemData<ApprovalMeta>[] = [
{
id: "intake",
label: "Intake",
data: {
owner: "Mina Shah",
initials: "MS",
role: "Campaign operations",
due: "Complete",
note: "The campaign brief and target audience are locked for review.",
checklist: ["Brief attached", "Audience defined", "Launch date set"],
},
},
{
id: "legal",
label: "Legal",
data: {
owner: "Jon Bell",
initials: "JB",
role: "Commercial counsel",
due: "Complete",
note: "Claims, regional terms, and customer consent language are approved.",
checklist: ["Claims verified", "Terms linked", "Consent copy approved"],
},
},
{
id: "security",
label: "Security",
data: {
owner: "Arun Rao",
initials: "AR",
role: "Product security",
due: "Today, 16:00",
note: "Confirm that the audience export excludes restricted account fields.",
checklist: [
"Data classification reviewed",
"Export scope verified",
"Retention window documented",
],
},
},
{
id: "finance",
label: "Budget",
data: {
owner: "Leah Kim",
initials: "LK",
role: "Growth finance",
due: "Tomorrow",
note: "Approve the regional media split and the 8% contingency reserve.",
checklist: ["Media plan reconciled", "FX buffer added", "PO reserved"],
},
},
{
id: "launch",
label: "Launch",
data: {
owner: "Noah Webb",
initials: "NW",
role: "Lifecycle marketing",
due: "Friday, 09:00",
note: "Schedule the approved campaign and monitor the first delivery cohort.",
checklist: [
"Segments synced",
"Suppression list fresh",
"Alerts enabled",
],
},
},
];
const statusTone: Record<StepStatus, BadgeTone> = {
complete: "primary",
current: "primary",
upcoming: "neutral",
error: "destructive",
skipped: "neutral",
};
const statusLabel: Record<StepStatus, string> = {
complete: "Approved",
current: "Active",
upcoming: "Queued",
error: "Changes",
skipped: "Skipped",
};
export function StepsApprovalRouting() {
const [current, setCurrent] = useState("security");
const [approved, setApproved] = useState(() => new Set(["intake", "legal"]));
const [changesRequested, setChangesRequested] = useState<string>();
const [announcement, setAnnouncement] = useState(
"Security review is ready for Arun Rao.",
);
const securityApproved = approved.has("security");
const financeApproved = approved.has("finance");
const items = approvalItems.map((item) => ({
...item,
disabled:
(item.id === "finance" && !securityApproved) ||
(item.id === "launch" && !financeApproved),
status: approved.has(item.id)
? ("complete" as const)
: changesRequested === item.id
? ("error" as const)
: undefined,
}));
const currentItem = items.find((item) => item.id === current);
const approvedPercentage = (approved.size / items.length) * 100;
const currentApproved = approved.has(current);
function reset() {
setCurrent("security");
setApproved(new Set(["intake", "legal"]));
setChangesRequested(undefined);
setAnnouncement("Security review is ready for Arun Rao.");
}
function approveCurrent() {
const nextApproved = new Set(approved);
nextApproved.add(current);
setApproved(nextApproved);
setChangesRequested(undefined);
const nextId =
current === "security"
? "finance"
: current === "finance"
? "launch"
: undefined;
if (nextId) {
setCurrent(nextId);
setAnnouncement(
`${String(currentItem?.label)} approved. ${String(
approvalItems.find((item) => item.id === nextId)?.label,
)} is now active.`,
);
return;
}
setAnnouncement(`${String(currentItem?.label)} approved.`);
}
return (
<div className="border-border bg-background overflow-hidden rounded-2xl border shadow-sm">
<header className="border-border bg-muted/25 flex flex-wrap items-start justify-between gap-4 border-b px-5 py-4 sm:px-6">
<div className="flex items-start gap-3">
<span className="bg-primary/10 text-primary grid size-10 shrink-0 place-items-center rounded-xl">
<FileCheck2 aria-hidden="true" className="size-5" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-heading text-base font-semibold">
Summer launch approvals
</h3>
<Badge
size="xs"
tone={approved.size === items.length ? "success" : "warning"}
variant="soft"
leadingIcon={<UsersRound aria-hidden="true" />}
>
{approved.size === items.length
? "Ready to launch"
: `${items.length - approved.size} reviews left`}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-sm">
A typed approval route with gated destinations and rich step
labels.
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
leftIcon={<RotateCcw aria-hidden="true" />}
onClick={reset}
>
Reset route
</Button>
</header>
<div className="grid gap-6 p-5 sm:p-6">
<Steps<ApprovalMeta>
interactive
showProgress
aria-label="Campaign approval route"
items={items}
progressValue={approvedPercentage}
value={current}
onValueChange={(value) => {
setCurrent(value);
setAnnouncement(
`Opened ${String(
approvalItems.find((item) => item.id === value)?.label,
)} review.`,
);
}}
formatProgress={(percentage) =>
`${approved.size} of ${items.length} approved · ${Math.round(percentage)}%`
}
renderItem={(item, state) => (
<span className="flex min-w-0 flex-col gap-1">
<span className="flex flex-wrap items-center justify-center gap-1.5 text-sm font-semibold sm:justify-start">
<span>{item.label}</span>
<Badge
size="xs"
tone={statusTone[state.status]}
variant="subtle"
>
{statusLabel[state.status]}
</Badge>
</span>
<span className="text-muted-foreground text-xs leading-4">
{item.data?.owner} · {item.data?.due}
</span>
</span>
)}
/>
<section
aria-labelledby="approval-panel-title"
className="border-border bg-muted/25 grid gap-5 rounded-xl border p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.72fr)]"
>
<div className="min-w-0">
<p className="text-primary text-xs font-semibold tracking-[0.12em] uppercase">
Current review
</p>
<div className="mt-3 flex items-start gap-3">
<span className="bg-primary text-primary-foreground grid size-10 shrink-0 place-items-center rounded-full text-sm font-semibold">
{currentItem?.data?.initials}
</span>
<div className="min-w-0">
<h4
id="approval-panel-title"
className="font-heading text-lg font-semibold"
>
{currentItem?.data?.owner}
</h4>
<p className="text-muted-foreground text-sm">
{currentItem?.data?.role} · due {currentItem?.data?.due}
</p>
</div>
</div>
<p className="text-muted-foreground mt-4 text-sm leading-6">
{currentItem?.data?.note}
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Button
size="sm"
disabled={currentApproved}
leftIcon={<Check aria-hidden="true" />}
rightIcon={<ArrowRight aria-hidden="true" />}
onClick={approveCurrent}
>
{currentApproved ? "Already approved" : "Approve & continue"}
</Button>
<Button
size="sm"
variant="outline"
disabled={currentApproved}
leftIcon={<MessageSquareWarning aria-hidden="true" />}
onClick={() => {
setChangesRequested(current);
setAnnouncement(
`Changes requested from ${currentItem?.data?.owner}.`,
);
}}
>
Request changes
</Button>
</div>
</div>
<div className="border-border bg-background rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Send aria-hidden="true" className="text-primary size-4" />
Review checklist
</div>
<ul className="mt-3 space-y-2">
{currentItem?.data?.checklist.map((check) => (
<li key={check} className="flex items-start gap-2 text-sm">
<Check
aria-hidden="true"
className="text-success mt-0.5 size-4 shrink-0"
/>
<span>{check}</span>
</li>
))}
</ul>
<p className="text-muted-foreground mt-4 text-xs leading-5">
Budget unlocks after Security approval. Launch unlocks after
Budget approval.
</p>
</div>
</section>
<p aria-live="polite" className="text-muted-foreground text-sm">
{announcement}
</p>
</div>
</div>
);
}Use the visual component alone or connect it to the optional headless controller. Current identity remains separate from domain status, and future mutations cannot remove the current prefix.
| Prop | What it does | Default |
|---|---|---|
itemsStepItemData<TData>[] | The visible workflow branch. Every item requires a stable, unique id and readable label. | Not set |
value / defaultValue / onValueChangestring / string / (value: string) => void | Controlled or uncontrolled current-step identity. Keep the current id present when the branch changes. | first item |
interactiveboolean | Renders each step surface as a native button and requests navigation to any enabled item. | false |
orientation"horizontal" | "vertical" | Uses a horizontal process track with safe inline overflow or an explicit vertical rail. | "horizontal" |
size"sm" | "md" | "lg" | Controls indicator and type scale. | "md" |
motionPreset"none" | "subtle" | "standard" | "expressive" | Controls branch presence, surviving-item layout, current-marker, and progress choreography. | "standard" |
showProgressboolean | Shows a named progressbar derived from the current ordinal and visible branch length. | false |
progressValuenumber | Overrides ordinal progress with a product-specific percentage, clamped to 0–100. | derived |
formatProgress(percentage, context) => ReactNode | Formats visible progress and aria-valuetext when the result is a string or number. | rounded percent |
renderItem(item, state) => ReactNode | Replaces visible item content while Steps retains list, activation, current, status, and progress semantics. | built-in body |
aria-label / aria-labelledbystring | Provides an accessible name for the ordered process list. | "Progress steps" |
| Prop | What it does | Default |
|---|---|---|
idstring | Stable, unique state and animation identity. Required for every item. | Not set |
label / descriptionReactNode / ReactNode | Required visible label and optional supporting description used by the default renderer. | Not set |
iconReactNode | Optional decorative indicator content. | ordinal/status icon |
disabled / optionalboolean / boolean | Blocks interactive activation or marks a step as optional without removing it from the branch. | false / false |
status"complete" | "upcoming" | "error" | "skipped" | Overrides visual/domain status. aria-current remains independently tied to the root value. | derived |
dataTData | Consumer-owned typed payload passed unchanged to renderItem. | Not set |
| Prop | What it does | Default |
|---|---|---|
items / defaultItemsStepItemData<TData>[] | Controlled or uncontrolled visible collection shared by the provider hooks. | [] |
value / defaultValuestring / string | Controlled or uncontrolled current-step identity used by indicators, panels, and controls. | first item |
onItemsChange(items) => void | Receives the complete proposed collection after a guarded future-step mutation. | Not set |
onValueChange(value) => void | Receives an enabled visible destination. | Not set |
progressValuenumber | Overrides the current ordinal divided by the visible collection length. | derived |
| Prop | What it does | Default |
|---|---|---|
render(context) => ReactNode | Renders consumer-owned content for the provider's current step with step, index, count, and value. | Not set |
fallbackReactNode | Shown when the provider cannot resolve a current step. | null |