A drag-and-drop canvas for building step-by-step workflows from a plain JSON object.
npx shadcn@latest add https://sdk-components.thesqd.com/r/workflow-builder.jsonimport { WorkflowBuilder } from "@/components/blocks/workflow-builder";"use client";
import { WorkflowBuilder } from "@/components/blocks/workflow-builder";
const workflow = {
nodes: [
{ id: "start", kind: "trigger", title: "Form submitted", x: 0, y: 0 },
{ id: "email", kind: "action", title: "Send confirmation", x: 320, y: 0 },
],
edges: [{ source: "start", target: "email" }],
};
export function Example() {
return <WorkflowBuilder workflow={workflow} onSave={(next) => console.log(next)} />;
}
WorkflowBuilder Card type="toolbar"
├── Toolbar band
│ ├── FlowArrow icon · title
│ ├── Issues badge "3 issues" — red with any error, amber otherwise;
│ │ hover lists them
│ ├── "Unsaved" badge while the canvas differs from the last save
│ ├── Undo · Redo ⌘Z · ⇧⌘Z (a burst of typing is one step)
│ ├── Add step DropdownMenu of every kind
│ ├── Run only when `onRun` is set; "Running" + spinner
│ │ while any step's runState is "running"
│ └── Save disk → check icon swap once `onSave` settles (⌘S);
│ disabled while there are errors
└── Canvas (React Flow)
├── Step tinted shell (--surface-band)
│ ├── title row chevron · title · issue Badge · kind Badge · run status
│ ├── body a raised card-surface: description + a summary of
│ │ the settings that have values; the chevron folds it
│ ├── input handle left, or top when vertical (not on inputs: false kinds)
│ └── output handle(s) right, or bottom — one, or one per named output
├── Edges custom smoothstep edge; its label is a Badge on the line
│ │ (yes / approved: green · no / rejected: red · else gray)
│ └── tool pill on hover or select: insert a step · delete the edge
├── Dropped connection let go of a dragged connection in empty space →
│ the kind menu opens there, and the new step is wired in
├── Controls bottom-left — zoom in · zoom out · fit · minimap · tidy up
├── Minimap bottom-right, pannable and zoomable, toggled from the controls
├── Alignment guides while one step drags, its start / center / end lines snap
│ to another step's within 6 screen-px; a dashed brand line
│ marks each match (a center match shows alone)
└── Inspector top-right — slides in on a click (not on press / drag):
issues · title · description · the kind's fields ·
Duplicate · Delete. A bottom Drawer under 640px wide.
Keyboard (while focus is in the builder, outside a text field)
├── ⌘Z / ⇧⌘Z / ⌘Y undo / redo
├── ⌘C / ⌘V copy / paste the selection (works across builders on a page)
├── ⌘D duplicate the selection
├── ⌘S save
└── Backspace delete the selection
Validation (built in, plus each step's own `warning` / `error`)
├── error a blank title · a required field left empty
└── warning no trigger at all · a step nothing connects to · a named output going nowhere
Connections can't loop a step onto itself or duplicate an existing edge.
Tidy up (broom)
Ranks steps along the flow (longest path from a trigger; loops don't reorder it),
lines each step up with the handle feeding it so one-to-one links run dead
straight (first output before second), spaces overlaps apart, then glides
everything into place.
Built-in kinds
├── trigger amber · Lightning · output only
├── action brand · GearSix · in + out
├── condition purple · GitBranch · in + yes / no
└── end success · FlagCheckered · input only
(an unknown kind renders as an action; `kinds` adds to or replaces these)
Transitions (transitions.dev)
├── Panel reveal steps, guides, edge tools, minimap fade in; the inspector slides in
├── Accordion a step's body folds under its chevron
├── Shimmer a highlight sweeps a running step's header
├── Card resize Tidy up's glide reads --resize-dur / reduced motion snaps
├── Icon swap Save's disk → check
└── Menu dropdown the Add step menusWorkflowBuilder
| Prop | Type | Default | Description |
|---|---|---|---|
workflow | { nodes, edges? } | — | The canvas as JSON. A new value (compared by content) re-seeds the canvas, so `workflow={state} onChange={setState}` works as a controlled pair. |
kinds | Record<string, kind> | — | Your own step types, merged over the built-ins (reuse a key to replace one). See the kind table below. |
title | ReactNode | "Workflow" | Toolbar title. |
height | number | string | 560 | Canvas height. |
direction | "horizontal" | "vertical" | "horizontal" | Flow direction: steps connect left → right, or top → bottom. Moves the handles; lay out x / y to match. |
readOnly | boolean | false | View-only: no dragging, connecting, adding, deleting or editing. Steps stay selectable. |
runState | Record<stepId, status> | — | Per-step run status — "running" (spinner, shimmer, brand ring), "success" (check), "error" (red ring), "skipped" (dimmed). Edges between finished steps turn brand; the one into a running step animates. |
allowSaveWithErrors | boolean | false | Let Save through while there are errors. Warnings never block. |
autosave | boolean | number | false | Save by itself: `onSave` fires this many ms after the last edit (`true` is 800) — never mid-drag, and not while errors block it. The Save button goes away; a "Saving…" badge stands in, "Unsaved" shows only while errors hold it back, and Run becomes the primary button. |
minimap | boolean | false | Start with the minimap shown. It can always be toggled from the controls. |
onChange | (workflow) => void | — | Every edit, with the whole workflow — including undo / redo. A drag reports once, on drop; selection alone never fires it. |
onSave | (workflow) => unknown | Promise<unknown> | — | Save button and ⌘S. A returned promise holds the button; a throw keeps the canvas marked unsaved. |
onRun | (workflow) => void | — | Shows a Run button when set. Report progress back through `runState`. |
onValidate | (issues) => void | — | Every time the set of issues changes, and once on mount. Each is `{ nodeId?, level: "error" | "warning", message }`. |
onSelect | (node | null) => void | — | The selected step, or null when the selection clears. |
onNodeAdd | (node) => void | — | A step was added — from the menu, an edge's +, a dropped connection, a paste or a duplicate. |
onNodeUpdate | (node) => void | — | A step's title, description or settings changed in the inspector. |
onNodeRemove | (node) => void | — | A step was deleted (inspector, Backspace or Delete). |
onConnect | (edge) => void | — | Two steps were connected — by dragging, or by an insert / drop / paste. |
onEdgeRemove | (edge) => void | — | An edge was deleted — directly, with a step it touched, or split by an insert. |
className | string | — | Classes on the outer card. |
workflow.nodes[]
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique step id. Edges point at it. |
kind | string | — | A key of `kinds` — built in: "trigger" · "action" · "condition" · "end". Unknown kinds render as an action. |
title | string | — | Step title. Blank is an error. |
description | string | — | Shown in the step's inner card, which the chevron folds away. |
x / y | number | — | Canvas position. Written back after every drag, to 2 decimals. |
data | object | — | The step's settings, keyed by its kind's `fields` — or your own config. Carried through every callback. |
warning | string | — | Your own warning: an amber badge in the step's header. |
error | string | — | Your own error: a red badge in the step's header. Blocks Save unless `allowSaveWithErrors`. |
workflow.edges[]
| Prop | Type | Default | Description |
|---|---|---|---|
source | string | — | Id of the step the edge leaves. |
target | string | — | Id of the step the edge enters. |
handle | string | — | Which named output the edge leaves from — "yes" / "no" on a condition, or your kind's `outputs`. |
label | string | — | Badge drawn on the edge. Branch edges default to the branch's name — the default is never written back into the JSON. |
id | string | — | Optional. Derived from source, handle and target when omitted. |
kinds[key]
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Shown on the step's badge, in the menus and in the inspector. |
icon | ({ className }) => JSX | gear | Any icon component. |
color | string | "brand" | A Badge / FeaturedIcon color token: "brand", "amber", "purple", "success", "sky", "coral"… |
inputs | boolean | true | Has an input handle. `false` makes it a trigger. |
outputs | string[] | null | [] | `[]` one output · names = branches, one handle each · `null` none (an end). |
fields | field[] | — | Settings edited in the inspector, stored at `node.data[key]`. |
kinds[key].fields[]
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | — | Where the value lives: `node.data[key]`. |
label | string | — | Field label; also its name in the step's settings summary. |
type | string | "text" | "text" · "textarea" · "number" · "select" · "switch". |
options | { value, label }[] | — | Choices for "select". |
required | boolean | — | Empty → a red "<label> required" badge on the step. |
placeholder | string | — | Placeholder for text, textarea and select. |
description | string | — | Helper text under the label. |
render | ({ value, onChange, disabled }) => ReactNode | — | Draw the control yourself — a filter builder, a people picker. Replaces the `type` control in the inspector; the value is still stored at `node.data[key]`. |
summary | (value) => string | null | — | How the step's settings summary shows the value; null hides it. A `render` field holding an object is hidden unless this is given. |