LAB/ Tanishk Interface Lab
SystemsstableReactARIA live regions

Progressive Command Button

One button that shows ready, running, review and done — without a modal.

Live preview

Documents · shared workspace

Quarterly review — draft

Last edited 12 minutes ago · 12 pages

Revenue held steady through the quarter while support volume fell for the second time this year. The appendix lists every change to the pricing table; two passages are still waiting on legal review.

4 charts2 open commentsDraft v0.9

Nothing has run yet.

ActionReview stepAuto-run demoWidth

Press the button to walk the phases

01What it does

Long-running actions usually mean a spinner, then a toast, then a modal asking you to confirm what you just asked for. This button keeps the whole small workflow in one control: idle, running with real progress, a review beat, then a done state with a way to run again. The host owns the work; the button owns the honesty.

In this previewA plausible export/summarise action that walks its phases in place. Turn on auto-run to watch the whole cycle, or press the button yourself.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/progressive-action/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/progressive-action.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/progressive-action/progressive-action.tsxcomponent
  • components/lab/progressive-action/progressive-action.cssstyles

Machine-readable item: /r/progressive-action.json ↗

03Usage

progressive-action.usage.tsx
const [phase, setPhase] = useState<Phase>("ready");

<ProgressiveAction
phase={phase}
progress={progress}
labels={{ ready: "Generate summary", running: "Summarising…", review: "Check the summary" }}
reviewSummary={<SummaryPreview />}
onRun={start}
onAccept={() => setPhase("done")}
onReset={() => setPhase("ready")}
/>

04Props

Props accepted by Progressive Command Button
PropTypeDefaultNotes
phase"ready" | "running" | "review" | "done" | "error"—Controlled phase — the host owns the workflow.
progressnumber—0–1 while running; omit for an indeterminate bar.
onRun / onAccept / onRetry / onReset() => void—Callbacks per phase.
reviewSummaryReactNode—Short summary shown in the review phase.
labelsPartial<Record<Phase, string>>—Override any phase’s label.
fixedWidthnumber—Pins the control width so phase changes never resize it.

05Accessibility

  • Phase changes are announced through a polite live region (“Running”, “Ready for review”, “Done”).
  • The running state exposes a progressbar with a text value; cancel is a real button.
  • Every phase keeps one obvious primary action and visible focus.
  • No modal is ever opened; the control works the same with a keyboard alone.

06Source

2 files, 490 lines. Nothing private, no configuration required.

components/lab/progressive-action/progressive-action.tsx— press enter to show or hide the source
tsx
"use client";

import {
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import "./progressive-action.css";

export type ActionPhase = "ready" | "running" | "review" | "done" | "error";

export type ProgressiveActionLabels = Partial<Record<ActionPhase, string>>;

export type ProgressiveActionProps = {
/** Controlled phase — the host owns the workflow. */
phase: ActionPhase;
/** 0–1 while running; omit for an indeterminate bar. */
progress?: number;
onRun?: () => void;
onAccept?: () => void;
onRetry?: () => void;
onReset?: () => void;
/** Short summary shown in the review phase. */
reviewSummary?: ReactNode;
/** Override any phase's label. */
labels?: ProgressiveActionLabels;
/** Pins the control width so phase changes never resize it. */
fixedWidth?: number;
className?: string;
};

const DEFAULT_LABELS: Record<ActionPhase, string> = {
ready: "Run",
running: "Running…",
review: "Ready for review",
done: "Done",
error: "Something went wrong",
};

const ANNOUNCEMENTS: Record<ActionPhase, string> = {
ready: "Ready",
running: "Running",
review: "Ready for review",
done: "Done",
error: "Failed",
};

function CheckIcon() {
return (
<svg
className="pa-icon"
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path
d="M3 8.5 6.2 11.6 13 4.7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

function AlertIcon() {
return (
<svg
className="pa-icon"
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<circle cx="8" cy="8" r="6.3" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path
d="M8 4.7v4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
</svg>
);
}

/**
* PROGRESSIVE ACTION — one control that walks a small workflow in
* place: ready, running with real progress, an optional review beat,
* then done or error. No modal, no toast; the host owns the work and
* this button owns the honesty. Phase changes are announced politely.
*/
export function ProgressiveAction({
phase,
progress,
onRun,
onAccept,
onRetry,
onReset,
reviewSummary,
labels,
fixedWidth,
className,
}: ProgressiveActionProps) {
const [announcement, setAnnouncement] = useState("");
const lastPhaseRef = useRef<ActionPhase | null>(null);

useEffect(() => {
if (lastPhaseRef.current === null) {
lastPhaseRef.current = phase;
return;
}
if (lastPhaseRef.current === phase) return;
lastPhaseRef.current = phase;
setAnnouncement(ANNOUNCEMENTS[phase]);
}, [phase]);

const labelFor = (key: ActionPhase) => labels?.[key] ?? DEFAULT_LABELS[key];

const raw =
typeof progress === "number" && Number.isFinite(progress) ? progress : undefined;
const clamped = raw === undefined ? undefined : Math.min(1, Math.max(0, raw));
const pct = clamped === undefined ? undefined : Math.round(clamped * 100);
const valueText = pct === undefined ? "In progress" : `${pct}%`;

return (
<div
className={`pa-root${className ? ` ${className}` : ""}`}
data-phase={phase}
style={fixedWidth ? { inlineSize: `${fixedWidth}px` } : undefined}
>
<div className="pa-row">
<span className="pa-slot">
<button
type="button"
className="pa-main"
data-state={phase}
aria-disabled={phase !== "ready"}
onClick={phase === "ready" ? onRun : undefined}
>
<span className="pa-face">
{phase === "done" ? <CheckIcon /> : null}
{phase === "error" ? <AlertIcon /> : null}
<span className="pa-face-label">{labelFor(phase)}</span>
</span>
</button>

{phase === "running" ? (
<span
className="pa-rail"
role="progressbar"
aria-label="Action progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={valueText}
data-indeterminate={clamped === undefined ? "true" : "false"}
>
<span
className="pa-rail-fill"
style={
clamped !== undefined
? { transform: `scaleX(${clamped})` }
: undefined
}
/>
</span>
) : null}
</span>

{phase === "running" ? (
<button type="button" className="pa-action" onClick={onReset}>
Cancel
</button>
) : null}
{phase === "done" ? (
<button type="button" className="pa-action pa-action--go" onClick={onReset}>
Run again
</button>
) : null}
{phase === "error" ? (
<button type="button" className="pa-action pa-action--go" onClick={onRetry}>
Retry
</button>
) : null}
</div>

{phase === "review" ? (
<div className="pa-review">
<div className="pa-review-summary">
{reviewSummary ?? (
<p className="pa-review-default">
The step finished. Check the result before accepting.
</p>
)}
</div>
<div className="pa-review-actions">
<button
type="button"
className="pa-action pa-action--go"
onClick={onAccept}
>
Accept
</button>
<button type="button" className="pa-action" onClick={onReset}>
Run again
</button>
</div>
</div>
) : null}

<span className="pa-sr" role="status" aria-live="polite">
{announcement}
</span>
</div>
);
}
components/lab/progressive-action/progressive-action.css— press enter to show or hide the source
css
/* ============================================================
PROGRESSIVE ACTION — self-contained styles.
Tokens are scoped on the root with fallbacks, so the control
carries its own theme into any codebase.
============================================================ */

.pa-root {
--pa-accent: #4f27e0;
--pa-accent-deep: #32119c;
--pa-accent-soft: rgba(79, 39, 224, 0.09);
--pa-ok: #137a4e;
--pa-ok-ink: #0d5c3b;
--pa-ok-soft: rgba(19, 122, 78, 0.1);
--pa-danger: #b3261e;
--pa-danger-ink: #8f1d17;
--pa-danger-soft: rgba(179, 38, 30, 0.09);
--pa-ink: #141418;
--pa-muted: #5f5f68;
--pa-line: rgba(20, 20, 24, 0.14);
--pa-surface: #ffffff;
--pa-surface-soft: #f8f8f6;
--pa-focus: #4f27e0;
--pa-radius: 10px;

position: relative;
display: inline-flex;
flex-direction: column;
gap: 8px;
max-inline-size: 100%;
font-size: 0.875rem;
line-height: 1.35;
color: var(--pa-ink);
transition: inline-size 220ms cubic-bezier(0.22, 1, 0.36, 1);
}

.pa-row {
display: flex;
align-items: stretch;
gap: 8px;
inline-size: 100%;
}

/* Keeps the progress rail pinned to the main button's bottom edge. */
.pa-slot {
position: relative;
display: flex;
flex: 1 1 auto;
min-inline-size: 0;
}

.pa-main {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 100%;
min-height: 42px;
padding: 0 16px;
border: 1px solid transparent;
border-radius: var(--pa-radius);
font: inherit;
font-weight: 600;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}

.pa-main[data-state="ready"] {
background: var(--pa-accent);
color: #ffffff;
}

.pa-main[data-state="ready"]:hover {
background: var(--pa-accent-deep);
}

.pa-main[data-state="running"],
.pa-main[data-state="review"] {
background: var(--pa-accent-soft);
border-color: rgba(79, 39, 224, 0.22);
color: var(--pa-accent-deep);
cursor: default;
}

.pa-main[data-state="running"] {
padding-block-end: 4px;
}

.pa-main[data-state="done"] {
background: var(--pa-ok-soft);
border-color: rgba(19, 122, 78, 0.26);
color: var(--pa-ok-ink);
cursor: default;
}

.pa-main[data-state="error"] {
background: var(--pa-danger-soft);
border-color: rgba(179, 38, 30, 0.26);
color: var(--pa-danger-ink);
cursor: default;
}

.pa-main:focus-visible,
.pa-action:focus-visible {
outline: 2px solid var(--pa-focus);
outline-offset: 2px;
}

.pa-main[data-state="ready"]:active {
transform: translateY(0.5px);
}

.pa-face {
display: inline-flex;
align-items: center;
gap: 7px;
min-inline-size: 0;
}

.pa-face-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.pa-icon {
flex: none;
}

/* Thin determinate / indeterminate bar along the button's bottom edge. */
.pa-rail {
position: absolute;
inset: auto 12px 6px 12px;
block-size: 3px;
border-radius: 999px;
background: rgba(79, 39, 224, 0.16);
overflow: hidden;
pointer-events: none;
}

.pa-rail-fill {
display: block;
block-size: 100%;
inline-size: 100%;
background: var(--pa-accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform 120ms linear;
}

.pa-rail[data-indeterminate="true"] .pa-rail-fill {
inline-size: 36%;
transform: none;
transition: none;
animation: pa-sweep 1.1s ease-in-out infinite;
}

@keyframes pa-sweep {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(320%);
}
}

/* Secondary actions: quiet by default, filled when they are the one. */
.pa-action {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 14px;
border: 1px solid var(--pa-line);
border-radius: var(--pa-radius);
background: var(--pa-surface);
color: var(--pa-ink);
font: inherit;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease;
}

.pa-action:hover {
background: var(--pa-surface-soft);
border-color: rgba(20, 20, 24, 0.24);
}

.pa-action--go {
background: var(--pa-accent);
border-color: transparent;
color: #ffffff;
}

.pa-action--go:hover {
background: var(--pa-accent-deep);
border-color: transparent;
}

/* Inline review beat — attached to the control, never a modal. */
.pa-review {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--pa-line);
border-radius: 12px;
background: var(--pa-surface-soft);
}

.pa-review-summary {
font-size: 0.8125rem;
}

.pa-review-default {
margin: 0;
color: var(--pa-muted);
}

.pa-review-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}

.pa-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

@media (prefers-reduced-motion: reduce) {
.pa-root,
.pa-main,
.pa-action,
.pa-rail-fill {
transition: none;
}

.pa-main[data-state="ready"]:active {
transform: none;
}

/* Indeterminate stays legible as a static mid-bar. */
.pa-rail[data-indeterminate="true"] .pa-rail-fill {
animation: none;
inline-size: 40%;
transform: none;
}
}