Workflow builder
NewAn automation editor: a canvas of nodes and curved connectors, a palette to add them, a test run and node settings.
workflow-builder/workflow-builder.tsxNodes are buttons: arrows move a node, Enter opens its settings, Delete removes a node you added. The plus after a node adds the next one.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- An automation product where people build a flow of steps themselves: triggers, tools, logic and actions joined in order.
- Flows that branch or join, where seeing every connector at once matters more than reading a list.
- An editor that has to show a test run in place: which step ran, which is running, how long each took.
- A desktop screen of 1000 px or more. It folds down to a phone, but the canvas is at its best with room.
When not to use
- A fixed sequence of three or four steps. A numbered form or a stepper reads faster.
- Showing a flow nobody edits, such as a process in a report. Use a static diagram, like the flowchart.
- Flows of hundreds of nodes: every node is a live element, with no culling or virtualisation.
- A first, guided setup. Templates and a short wizard are friendlier than an empty canvas.
You can add this component with the shadcn CLI or by hand:
Into components/workflow-builder of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/workflow-builder.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { WorkflowBuilder } from './workflow-builder';export function Editor() { return ( <WorkflowBuilder autoplay={false} defaultSidebarOpen={false} workflowName="Invoice intake" onWorkflowChange={({ nodes, edges }) => console.log(nodes.length, edges.length)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The whole screen of a workflow app. Two triggers, an AI agent, a transformation and a database write sit on a dotted canvas; open the palette and pick a tool, a piece of logic or an action, and it lands after the node you came from, already connected. Pan, zoom, drag nodes, draw connectors, run a test and watch each step come in, or open a node to set it up.
- The canvas is a world of absolutely placed nodes and one SVG of connectors, moved by a single transform for pan and zoom. Nodes have a fixed height and a width of their own, so every port and every connector comes from the data, without measuring the page.
- A connector leaves an output to the right, turns through two rounded corners halfway and comes into the next input; when the target sits behind, it becomes a soft S. A new connector draws itself in with a dash offset, and the one running in a test carries a moving dash.
- Every node with nothing after it shows a plus. It opens the palette with that node as the anchor, and the kind you pick lands to its right, connected; the agent’s Model and Tools pluses do the same below it. Dragging from an output port to any node connects them too.
- Pointer events drive the canvas: a drag on the background pans (or draws a lasso that selects nodes), a drag on a node moves it and the others selected, and a press without a drag opens its settings. Ctrl or ⌘ with the wheel, or a trackpad pinch, zooms around the pointer; a plain wheel still scrolls the page.
- Run opens the report from the bottom and moves the view up by half its height (and, with the palette closed, to the middle of the screen). The steps follow the connectors from each trigger, one every 300 ms: each row slides in under a sweep of light while its node glows on the canvas, then takes its check.
- The sidebar folds to a rail of icons by a clip-path, its headings closing up and its items becoming squares, while the breadcrumb, the header’s line and the palette slide by the same distance; a fold button sits in the open sidebar’s top row and a button on its edge opens it again; panels slide and fade, and the settings’ sections come in one after another. Below 760 px the open sidebar lies over the canvas, and below 520 px the rail goes and it becomes a drawer.
- The view fits the workflow into the part of the canvas the palette and the open sidebar leave free, so on a stage with room for both they never cover a node, and glides to the new free part whenever either opens or closes. Once the reader pans or zooms, the view is theirs: a panel opening over the workflow only nudges it right, as far as the canvas allows.
- While on screen and untouched, a demo pointer goes through it: the palette’s tooltips, folding the sidebar, a test run, the upload’s plus with its tooltip, then the upload’s settings, where it drops a file type, and a second run. Any input of the reader’s stops it for good, so it never undoes their work.
- Every colour is a light-dark() token, so it follows the page’s color-scheme, or the colorScheme prop.
| Prop | Type | Default | Description |
|---|---|---|---|
autoplay | boolean | true | While on screen and untouched, a demo pointer shows the builder off. Any input of the reader’s stops it for good. |
defaultSidebarOpen | boolean | true | The sidebar starts open. Below 1000 px, where it would leave the workflow too little room, it always starts folded. |
defaultPaletteOpen | boolean | true | The node palette starts open. Below 560 px it always starts closed. |
workflowName | string | 'Customer intake' | The workflow’s name, which labels the canvas for screen readers. |
onWorkflowChange | (workflow: { nodes: Node[]; edges: Edge[] }) => void | — | Called with the nodes and connectors whenever they change: added, moved, connected or deleted. |
defaultState | WorkflowState | — | Opens in this state: added ({ kind, after }[], nodes already added after a node’s output and connected, their ids `<kind>-<n>`), attach (the palette open to add after this node), tip (the palette’s tooltip for a kind), addTip (the Add node tooltip on a node’s plus), menu ('org' | 'project' | 'branch', a breadcrumb switcher open), selected (node ids), settings (a node’s settings open), repeat ('hourly' | 'daily' | 'weekly', the schedule’s) and run ({ at?, tab? }, the test run’s report held at step at, or finished without it). The starting nodes are schedule, upload, agent, transform and database. Turns the demo off; a run it opens holds until the next run. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s. Without it, the component follows the page’s color-scheme. |
className | string | — | Classes for the root, to size or place it. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { AGENT_PORTS, BRANCHES, COST, GROUPS, KINDS, NAV, NAV_OTHER,// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type Ref,} from 'react';import { COST, EXTENSIONS, type Node } from './data';import { Icon, type IconName } from './icons';import styles from './workflow-builder.module.css';/* * The two panels that slide over the canvas: a node's settings, from the right, and the test * run's report, from the bottom. They only draw what the builder hands them; every change goes * back up through a callback, so the canvas and the panels always show the same workflow. */export type Config = { /** The upload trigger: where files come from, which types it takes, and whether it is on. */ source: string;// Copyright (c) 2026 Bruno Delorence. MIT License.import type { IconName } from './icons';/* * The builder's data: the kinds of node the palette offers, the workflow it opens on, and the * numbers a test run reports. All of it is invented. * * Positions are in the canvas's own px (its "world"), which the view pans and zooms. Every node * is NODE_HEIGHT tall and as wide as its entry says, so the connectors can be drawn from the data * alone, without measuring the page. */export type Group = 'Tools' | 'Logic' | 'Action';export type Kind = { id: string; label: string; group: Group; icon: IconName; /** What it does: the palette's tooltip. */ description: string; /** The line under its name on the canvas, until it is set up. */ sub: string;// Copyright (c) 2026 Bruno Delorence. MIT License.import { AGENT_PORTS, NODE_HEIGHT, STEM, type Edge, type Node } from './data';/* * The canvas's geometry, in world px: where each node's ports are, the path of a connector, the * box around the whole workflow, and the view (pan and zoom) that fits it in the canvas. */export type Point = { x: number; y: number };export type View = { x: number; y: number; k: number };export const ZOOM_MIN = 0.25;export const ZOOM_MAX = 1.6;/** How far right of a node its "add node" plus sits, from the output port. */export const ADD_REACH = 36;export const inPort = (node: Node): Point => ({ x: node.x, y: node.y + NODE_HEIGHT / 2 });export const outPort = (node: Node): Point => ({ x: node.x + node.w, y: node.y + NODE_HEIGHT / 2 });/** Where a connector leaves a node: its output port, or under the plus of one of the agent's stems. */export function startOf(node: Node, port: Edge['port']): Point { const stem = AGENT_PORTS.find((item) => item.id === port); if (!stem) return outPort(node);// Copyright (c) 2026 Bruno Delorence. MIT License./* * The builder's icons: line drawings on a 24-unit grid, each one or a few path strings, stroked * with the current colour, and framed on the middle 20 units so the drawings fill their box as * the interface's do. They are decorative (aria-hidden): the button or the row around each * one says what it is. Their size comes from the stylesheet (.icon), never from the SVG. *//** A circle as a path, so every icon is paths only. */const ring = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;export const ICONS = { home: ['M4.5 10.2 12 4.2l7.5 6V19a1 1 0 0 1-1 1H15v-5.5H9V20H5.5a1 1 0 0 1-1-1z'], folder: [ 'M3.5 7A1.5 1.5 0 0 1 5 5.5h4.3l2 2.2H19A1.5 1.5 0 0 1 20.5 9.2V17A1.5 1.5 0 0 1 19 18.5H5A1.5 1.5 0 0 1 3.5 17z', 'M3.5 10h17', ], rocket: [ 'M13.6 5.1c2.8-1.2 5.9-.9 5.9-.9s.3 3.1-.9 5.9L13 15.7 8.3 11z', 'M8.3 11 5 11.4l-1.6 2.4 3.9.6M13 15.7l-.4 3.3-2.4 1.6-.6-3.9', 'M6.8 17.2 4.5 19.5', ring(15.2, 8.8, 1.2),// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = { /** Resolves after `ms`. */// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input, or never when `idle` is null (a builder the * reader has edited is theirs: the demo would undo their work). */export function useAutoplay<T extends HTMLElement>(idle: number | null = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);/* Workflow builder (workflow-builder.tsx): a whole app screen. A sidebar on the left, a header along the top, and under it a dotted canvas with the workflow, its palette, toolbar and minimap; the run's report rises from the bottom and a node's settings slide in from the right. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The dark scheme is near-black greys with one blue, a red for what needs attention and a green for Run. Memory and motion. The stage is a layout boundary of a fixed height. What moves moves by transform or opacity: the world of nodes (pan and zoom), the panels, the toolbar and the minimap; the sidebar folds by a clip-path. Sizes are in px for a 1200–1440 px app and shrink by container queries down to 320 px. */.root { --app: #fbfbfb; --canvas: #f6f6f6; --surface: #ffffff; --raised: #f1f1f1; --field: #f7f7f7; --line: #e7e7e7; --line-strong: #dadada; --ink: #141414; --ink-2: #5b5b5b;- The canvas is a labelled group with a hidden description of its keys. Nodes are buttons: arrows move a node (Shift for larger steps), Enter opens its settings, Delete removes a node you added.
- Every plus is a button named after what it adds and where (“Add a node after Document upload”, “Add a model to AI Agent”); its “Add node” tooltip shows on focus as well as hover.
- The palette is a labelled region of buttons, grouped under headings; each one’s description is its tooltip, tied by aria-describedby while it shows. Search in the header filters it.
- The toolbar is a role="toolbar" that arrows move along; Pan and the lasso are toggle buttons (aria-pressed). The breadcrumb’s switchers are menu buttons with radio items, opened with Down and closed with Escape.
- The run report’s tabs are a real tablist; its rows are disclosure buttons. Adding, connecting, deleting and the end of a run are announced in a polite live region.
- Opening a node’s settings moves focus to its heading, and closing them (the button or Escape) returns it to the node. Closed panels are inert. Under reduced motion nothing slides, draws or sweeps, and the demo does not play.