← all demos

workflow-buttonExperimental

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.

Flow
Size
Variant
Emphasis
Steps show
Theme
Click the primary to advance, or the caret to jump.

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).

API

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

OptionType
stepsWorkflowStep[] The flow, in order. Required.
currentstring 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.
contextTCtx 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 advanceVariantvariant.
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.
manageStateboolean = true Advance the control's own current on a move. Set false when a parent owns state (the React wrapper does this).
menuLabelstring = 'Choose stage' Accessible name for the caret trigger.
injectStylesboolean = true Inject the stylesheet on first use; set false and ship workflowStyles() yourself (SSR).
classNamestring Extra class(es) on the root, for overrides.

WorkflowStep

FieldType
idstring Stable identity. Required.
labelstring Menu label; the primary's fallback label. Required.
advanceLabelstring Primary label when advancing to this step — a verb, on the destination ("Submit for review").
advanceVariantvariant Prominence when this step is the advance target — emphasis lives on the destination. Idiom: quiet base, loud decision points.
tostring[] Transitions out of this step — the definition as data. to[0] is the happy path; to: [] is terminal; absent = array order.
descriptionstring Hint line under the menu label.
metastring Instance annotation ("Astrid · 2d"). Replaces description when present — what happened beats what was planned.
icon() → Node Leading icon factory (16px slot). Wins over color.
colorstring Status-dot color (any CSS color).
disabledboolean Hard-disable jumping to this step, regardless of canMoveTo.

Instance

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().

Resolution order

Primary label: advanceLabelFortarget.advanceLabeltarget.label.  Emphasis: variantFortarget.advanceVariantvariant.  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.

Helpers

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.
nextInOrderPlain 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).

Theming

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.