Workflow builder

A drag-and-drop canvas for building step-by-step workflows from a plain JSON object.

Project intake
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/workflow-builder.json
Usage
Describe the canvas as one JSON object and hand it over. Every edit comes back through the callbacks.
TSImport
import { WorkflowBuilder } from "@/components/blocks/workflow-builder";
TSExample
"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)} />;
}
Composition
Anatomy of the builder, its step kinds, and the transitions it uses.
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 menus
Editor
Drag, connect, add and edit steps. Hover a line to insert a step or delete it, drag a connection into empty space to add one there, and undo with ⌘Z.
Project intake
Custom step types
Your own kinds with settings forms. Required fields, unwired branches and your own warnings show as badges, and errors block Save.
Approval flow
Run state
Press Run — each step's status comes back in through runState: a spinner and shimmer while it runs, then a check.
Project intake
Top to bottom
direction="vertical" moves the handles to the top and bottom of each step.
Project intake
Events
Every callback, logged as it fires — change, save, run, validate, select, add, update, remove, connect.
Project intake
Interact with the canvas — events land here.
Read-only
The same canvas with dragging, connecting and editing switched off.
Project intake
API Reference
Props for the builder, the fields of a node and an edge in the workflow JSON, and a step kind with its settings fields.

WorkflowBuilder

PropTypeDefaultDescription
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.
kindsRecord<string, kind>—Your own step types, merged over the built-ins (reuse a key to replace one). See the kind table below.
titleReactNode"Workflow"Toolbar title.
heightnumber | string560Canvas height.
direction"horizontal" | "vertical""horizontal"Flow direction: steps connect left → right, or top → bottom. Moves the handles; lay out x / y to match.
readOnlybooleanfalseView-only: no dragging, connecting, adding, deleting or editing. Steps stay selectable.
runStateRecord<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.
allowSaveWithErrorsbooleanfalseLet Save through while there are errors. Warnings never block.
autosaveboolean | numberfalseSave 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.
minimapbooleanfalseStart 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.
classNamestring—Classes on the outer card.

workflow.nodes[]

PropTypeDefaultDescription
idstring—Unique step id. Edges point at it.
kindstring—A key of `kinds` — built in: "trigger" · "action" · "condition" · "end". Unknown kinds render as an action.
titlestring—Step title. Blank is an error.
descriptionstring—Shown in the step's inner card, which the chevron folds away.
x / ynumber—Canvas position. Written back after every drag, to 2 decimals.
dataobject—The step's settings, keyed by its kind's `fields` — or your own config. Carried through every callback.
warningstring—Your own warning: an amber badge in the step's header.
errorstring—Your own error: a red badge in the step's header. Blocks Save unless `allowSaveWithErrors`.

workflow.edges[]

PropTypeDefaultDescription
sourcestring—Id of the step the edge leaves.
targetstring—Id of the step the edge enters.
handlestring—Which named output the edge leaves from — "yes" / "no" on a condition, or your kind's `outputs`.
labelstring—Badge drawn on the edge. Branch edges default to the branch's name — the default is never written back into the JSON.
idstring—Optional. Derived from source, handle and target when omitted.

kinds[key]

PropTypeDefaultDescription
labelstring—Shown on the step's badge, in the menus and in the inspector.
icon({ className }) => JSXgearAny icon component.
colorstring"brand"A Badge / FeaturedIcon color token: "brand", "amber", "purple", "success", "sky", "coral"…
inputsbooleantrueHas an input handle. `false` makes it a trigger.
outputsstring[] | null[]`[]` one output · names = branches, one handle each · `null` none (an end).
fieldsfield[]—Settings edited in the inspector, stored at `node.data[key]`.

kinds[key].fields[]

PropTypeDefaultDescription
keystring—Where the value lives: `node.data[key]`.
labelstring—Field label; also its name in the step's settings summary.
typestring"text""text" · "textarea" · "number" · "select" · "switch".
options{ value, label }[]—Choices for "select".
requiredboolean—Empty → a red "<label> required" badge on the step.
placeholderstring—Placeholder for text, textarea and select.
descriptionstring—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.