A split button that drives an entity through workflow stages. The primary button advances one step — its label changes with the stage — and the caret menu lists every stage so you can jump anywhere the flow allows. Reachability is a predicate, so a one-way (DAG) flow that forbids going back is a single line.
Install: npx shadcn@latest add @lloydhumphreys/workflow-button
⚠️ This component is experimental — the API is still settling and will change in breaking ways. Pin what you install and check the diff before updating.
In DAG mode the menu greys out every stage at or before the current one
(canMoveTo: forwardOnly) — once you've moved on you can't move back. In
Linear mode any stage is reachable. The primary advances to the next stage in
order and re-labels itself (e.g. advanceLabel: "Submit for review"); at the
final stage it settles into a quiet readout.
Branching passes the workflow definition as data — each step declares its
transitions (to: ['approved', 'draft']): to[0] is the happy
path on the primary, the rest are menu-only alternates ("Draft" from review =
request changes), and to: [] is terminal. It renders two synced
buttons, one per persona — the same steps and stage, a different
context each — so you can see both sides at once: Lloyd submits and
publishes, Astrid approves and sends back. When it's not your move, your button
quietens into a readout and the picker hides. Moves are stamped with whoever's button
did them (meta replaces the step's hint line in the menu).
Per-step emphasis demonstrates the prominence model: the control takes a quiet
base (variant: "outline") and each destination declares how arriving at it
should present — advanceVariant: "default" makes "Submit for review" loud,
"Approve" stays quiet (mid-flow, someone else's call), and "Publish" is
"destructive" to mark the irreversible step. Role-aware prominence (loud
only for the person who should act) plugs in via variantFor(target, from).
The vanilla core (workflow-button.ts, zero dependencies). The React
wrapper (workflow-button-react.tsx) exposes the same options as props;
workflow-button-shadcn.tsx is the same control composed from your app's
shadcn <Button> + <DropdownMenu>, with ReactNode
icons and slots.
import { createWorkflowButton } from './workflow-button'
const wf = createWorkflowButton({
steps: [
{ id: 'draft', label: 'Draft', to: ['review'] },
{ id: 'review', label: 'In review', advanceLabel: 'Submit for review',
advanceVariant: 'primary', to: ['approved', 'draft'] },
{ id: 'approved', label: 'Approved', advanceLabel: 'Approve', to: ['published'] },
{ id: 'published', label: 'Published', advanceLabel: 'Publish', to: [] },
],
current: 'draft',
variant: 'outline', // quiet base; steps declare their own emphasis
context: { role: user.role }, // reaches every resolver below
canMoveTo: (to, from, steps, ctx) => ctx.role === roleFor(to),
onMove(toId, fromId) { save(toId) },
})
host.appendChild(wf.element)
createWorkflowButton(options) → WorkflowButton| Option | Type | |
|---|---|---|
steps | WorkflowStep[] | The flow, in order. Required. |
current | string | Id of the current stage. Required. |
onMove | (toId, fromId) → void | false | Fired on advance or a menu pick. Return false to veto the built-in
state update (when a parent owns current); reflect the move via
setState. Required. |
context | TCtx | App data (viewer role, permissions, assignee…) passed verbatim to every
resolver. Update with setState({ context }). |
next | (current, steps, ctx) → string | null | Advance-target resolver. Default defaultNext: the step's
to[0] when declared, else next in array order. Null = terminal. |
canMoveTo | (to, from, steps, ctx) → boolean | Reachability — governs the primary, each menu item, and whether the picker
shows at all. Default defaultCanMoveTo: to-membership
when declared, else any non-disabled step. Also your role-gating hook. |
variant | 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | Base presentation ('primary' is an alias for 'default'). Per-step
advanceVariant / variantFor win over it. |
variantFor | (target, from, ctx) → variant | null | Dynamic emphasis (role-aware prominence). Null falls through to
advanceVariant → variant. |
advanceLabelFor | (target, from, ctx) → string | Override the primary's label. Falls back to
target.advanceLabel ?? target.label. |
renderPrimary | ({ target, current }) → Node | string | null | Own the primary's content. target is null at a terminal stage;
return null to keep the default content. |
renderItem | (step, { isCurrent, reachable }) → Node | null | Own a menu row (left of the current-step check). Null keeps the default. |
size | 'sm' | 'default' | 'lg' | shadcn button heights: 32 / 36 / 40 px. |
manageState | boolean = true | Advance the control's own current on a move. Set false when a
parent owns state (the React wrapper does this). |
menuLabel | string = 'Choose stage' | Accessible name for the caret trigger. |
injectStyles | boolean = true | Inject the stylesheet on first use; set false and ship
workflowStyles() yourself (SSR). |
className | string | Extra class(es) on the root, for overrides. |
WorkflowStep| Field | Type | |
|---|---|---|
id | string | Stable identity. Required. |
label | string | Menu label; the primary's fallback label. Required. |
advanceLabel | string | Primary label when advancing to this step — a verb, on the destination ("Submit for review"). |
advanceVariant | variant | Prominence when this step is the advance target — emphasis lives on the destination. Idiom: quiet base, loud decision points. |
to | string[] | Transitions out of this step — the definition as data. to[0] is
the happy path; to: [] is terminal; absent = array order. |
description | string | Hint line under the menu label. |
meta | string | Instance annotation ("Astrid · 2d"). Replaces description
when present — what happened beats what was planned. |
icon | () → Node | Leading icon factory (16px slot). Wins over color. |
color | string | Status-dot color (any CSS color). |
disabled | boolean | Hard-disable jumping to this step, regardless of canMoveTo. |
| Member | |
|---|---|
element |
The control root (role="group"). Append it anywhere. |
getCurrent() | Current stage id. |
getAdvanceTarget() |
Id the primary would advance to, or null if terminal/blocked. |
setState({ steps?, current?, context? }) |
Patch state and re-render (reachability, emphasis, picker visibility). |
advance() | Programmatic primary click. |
moveTo(id) | Programmatic jump, if reachable. |
destroy() |
Detach listeners; then element.remove(). |
Primary label: advanceLabelFor → target.advanceLabel →
target.label. Emphasis: variantFor →
target.advanceVariant → variant. At a terminal or
blocked stage the primary becomes a full-opacity secondary readout of
the current step (outline/ghost bases stay put), and when no step is
reachable the caret hides entirely.
| Export | |
|---|---|
defaultNext |
to[0] when declared, else array order — the built-in
next. |
defaultCanMoveTo |
to-membership when declared, else any non-disabled step — the
built-in canMoveTo. Compose role gates on top. |
nextInOrder | Plain array-order advance. |
forwardOnly |
Array-order DAG predicate: never backwards. (Flows with to lists
don't need it.) |
normalizeVariant |
Collapses the 'primary' alias to 'default'. |
injectWorkflowStyles / workflowStyles |
Inject the stylesheet / get its CSS text (SSR or CSP setups). |
Consumes shadcn theme tokens when present (--primary,
--secondary, --destructive, --accent,
--popover, --border/--input,
--ring, --radius) with zinc light-dark()
fallbacks — inside a shadcn app it matches <Button> untouched;
standalone it still reads correctly in both themes. Override independently of the app
theme via --wf-primary-bg, --wf-primary-fg,
--wf-border, --wf-hover, --wf-menu-bg,
--wf-menu-fg, --wf-muted, --wf-ring,
--wf-radius.