Skip to content

Flow canvas

The React Flow canvas: dotted grid, node cards, smooth-step edges and run states.

Status
Stable
Category
Editors
Adoption
Not used yet
import { FlowCanvas } from "@oration/canon/components/flow";
packages/canon/src/components/flow.tsx

Draft, not run yet

import { Button } from "@oration/canon/components/button";import {  FlowCanvas,  FlowEdge,  type FlowEdgeData,  FlowNodeCard,  type FlowRunState,} from "@oration/canon/components/flow";import { toast } from "@oration/canon/components/toast";import type { Edge, EdgeTypes, Node, NodeProps, NodeTypes } from "@xyflow/react";import {  CalendarClockIcon,  FileSearchIcon,  InboxIcon,  ListFilterIcon,  PlayIcon,  RotateCcwIcon,} from "lucide-react";import * as React from "react";export function Workflow() {    type StepData = {        icon: React.ReactNode;        category: string;        title: string;        description: string;        run: FlowRunState;        target: boolean;    };    type StepNode = Node<StepData, "step">;    const nodeTypes = React.useMemo<NodeTypes>(        () => ({            step: ({ data, selected }: NodeProps<StepNode>) => (                <FlowNodeCard                    icon={data.icon}                    category={data.category}                    title={data.title}                    description={data.description}                    run={data.run}                    target={data.target}                    selected={selected}                />            ),        }),        [],    );    const edgeTypes = React.useMemo<EdgeTypes>(        () => ({ default: FlowEdge }),        [],    );    const steps = [        {            id: "invoice",            icon: <InboxIcon />,            category: "Trigger",            title: "Invoice received",            description: "From ap@cedarline.com",        },        {            id: "match",            icon: <FileSearchIcon />,            category: "Matching",            title: "Match PO",            description: "Three-way match against the PO",        },        {            id: "approve",            icon: <ListFilterIcon />,            category: "Filter",            title: "Approve under $5k",            description: "Amount is less than $5,000",        },        {            id: "schedule",            icon: <CalendarClockIcon />,            category: "Payments",            title: "Schedule payment",            description: "Adds it to Friday's payment run",        },    ];    const total = steps.length;    // -1 is idle; 0 to 3 is the running step; 4 is finished.    const [step, setStep] = React.useState(-1);    const [selected, setSelected] = React.useState<string | null>("match");    const running = step >= 0 && step < total;    React.useEffect(() => {        if (step < 0 || step >= total) return;        const id = window.setTimeout(() => {            setStep(step + 1);            if (step + 1 === total)                toast.add({                    type: "success",                    title: "Test run passed",                    description:                        "Northwind Freight's invoice INV-20931 would be paid Friday, Oct 2.",                });        }, 1100);        return () => window.clearTimeout(id);    }, [step, total]);    const runFor = (index: number): FlowRunState => {        if (step < 0) return "idle";        if (index < step) return "success";        return index === step ? "running" : "queued";    };    const nodes: StepNode[] = steps.map((s, index) => ({        id: s.id,        type: "step",        position: { x: 0, y: index * 120 },        selected: s.id === selected,        ariaLabel: `${s.title}, ${runFor(index) === "success" ? "passed" : runFor(index)}`,        data: {            icon: s.icon,            category: s.category,            title: s.title,            description: s.description,            run: runFor(index),            target: index > 0,        },    }));    const edges: Edge<FlowEdgeData>[] = steps.flatMap((source, index) => {        const target = steps[index + 1];        if (!target) return [];        return [            {                id: `${source.id}-${target.id}`,                source: source.id,                target: target.id,                sourceHandle: "out",                type: "default",                ariaLabel: `From ${source.title} to ${target.title}`,                data: {                    active: step === index + 1,                    onInsert: running                        ? undefined                        : () =>                              toast.add({                                  title: "Add a step",                                  description: `Between ${source.title} and ${target.title}.`,                              }),                },            },        ];    });    return (        <div className="relative h-[420px] w-full overflow-hidden rounded-xl bg-surface shadow-border">            <FlowCanvas<StepNode, Edge<FlowEdgeData>>                nodes={nodes}                edges={edges}                nodeTypes={nodeTypes}                edgeTypes={edgeTypes}                minimap={false}                fitView                fitViewOptions={{ padding: 0.12, maxZoom: 1 }}                nodesDraggable={false}                nodesConnectable={false}                zoomOnScroll={false}                preventScrolling={false}                onNodeClick={(_, node) => setSelected(node.id)}                onPaneClick={() => setSelected(null)}                aria-label="Invoice approval workflow"            />            <div className="absolute top-3 right-3 left-3 z-10 flex items-center justify-between gap-3">                <p                    aria-live="polite"                    className="rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground tabular-nums"                >                    {step < 0                        ? "Draft, not run yet"                        : running                          ? `Running step ${step + 1} of ${total}`                          : "Test run passed"}                </p>                <Button                    type="button"                    size="sm"                    disabled={running}                    onClick={() => setStep(0)}                >                    {step >= total ? (                        <RotateCcwIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                    ) : (                        <PlayIcon data-icon="inline-start" aria-hidden="true" />                    )}                    {step >= total ? "Run again" : "Run test"}                </Button>            </div>        </div>    );}

Usage#

Flow canvas is the surface for building and test-running automations: a React Flow canvas on the dotted grid, 288px node cards, smooth-step edges and run states that light up step by step. FlowCanvas wraps React Flow with Canon's grid, controls and minimap, FlowNodeCard is what your custom node types render, and FlowEdge is the edge type. The usual mistake is treating run state as a color: every ring travels with a labelled badge, and the marching indigo dash belongs only to the edge the run is on right now.

When to use

  • For a multi-step automation people build and read as a graph: Invoice received, Match PO, Approve under $5k, Schedule payment.
  • To show a test run live, node by node, with the active edge marching.
  • When the logic branches: a branch node with Yes and No outputs and labelled edges.
  • For voice-agent flows and procedures, where each node is a stage of the conversation.
  • To show one node card outside the canvas, such as in a palette preview or a run summary, with target={false} and sourceHandles={[]}.

When not to use

  • For a fixed sequence someone completes once, like supplier onboarding. Use Stepper
  • For a record of what already happened, such as a run log or an audit trail. Use Timeline
  • For an ordered list people reorder by dragging, with no branches. Use Sortable list
  • For an agent's progress through a plan, shown as checkable tasks. Use Task list
  • For where the milliseconds went in one call. Use Latency timeline
  • For the whole editor page, with palette, inspector and test run. Use Builder canvas

Workflow Canvas (signature)

Nodes are 288px cards with container corners and hairline lift, a 32px Well Gray icon tile, a 12px category with its run badge, a 13px medium title and a caption description. Handles are 10px white circles with a 1.5px Firm Hairline stroke. Edges are 1.5px Firm Hairline smooth-steps. Zoom controls and the minimap sit in 10px-corner hairline-lift boxes.

The Label-Beside-Color Rule

A run ring never stands alone. Running, Passed, Failed, Queued and Skipped are always written in the node's badge, so a test run reads in grayscale and to a screen reader.

One marching edge

Set active only on the edge the run is traversing. The dashed indigo march means work in progress now; idle edges stay Firm Hairline, and selection turns an edge indigo without the dash.

The Option Hue Rule

The icon tile is Well Gray. Tint it through iconClassName only for identity, such as an agent's own color, never to color-code node kinds like triggers or integrations.

Anatomy#

FilterPassed
Approve under $5k

Runs before every payment

Amount is less than $5,000
  1. Card. 288px wide (w-72), rounded-xl on the card surface with shadow-border. Rings for selection and run state are drawn on it.
  2. Target handle. A 10px circle with a 1.5px Firm Hairline stroke at the top center. target={false} removes it for triggers.
  3. Icon tile. 32px, 10px corners, Well Gray with a 16px icon. Decorative; iconClassName overrides the tint.
  4. Category. 12px muted text naming the kind of step: Trigger, Filter, Payments.
  5. Run badge. FlowRunBadge, pushed to the end of the category row. Nothing renders while idle.
  6. Title. 13px at weight 500, one line, truncates.
  7. Description. 12px muted text, clamped to two lines.
  8. Footer. Optional children below a hairline, for conditions or an error. Padded 12px by 8px.
  9. Source handles. One per entry in sourceHandles, spaced evenly along the bottom edge. Each id is the edge's sourceHandle.

Examples#

Branches and edge labels

A branch node passes two sourceHandles, and each edge picks one with sourceHandle and names it with data.label. Click a node to select it.

import { FlowCanvas, FlowEdge, type FlowEdgeData, FlowNodeCard } from "@oration/canon/components/flow";import type { Edge, EdgeTypes, Node, NodeProps, NodeTypes } from "@xyflow/react";import { CalendarClockIcon, FileSearchIcon, SplitIcon, UserCheckIcon } from "lucide-react";import * as React from "react";export function Branches() {    type StepData = {        icon: React.ReactNode;        category: string;        title: string;        description?: string;        condition?: string;        handles: { id: string }[];        target: boolean;    };    type StepNode = Node<StepData, "step">;    const nodeTypes = React.useMemo<NodeTypes>(        () => ({            step: ({ data, selected }: NodeProps<StepNode>) => (                <FlowNodeCard                    icon={data.icon}                    category={data.category}                    title={data.title}                    description={data.description}                    sourceHandles={data.handles}                    target={data.target}                    selected={selected}                >                    {data.condition ? (                        <span className="block truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground">                            {data.condition}                        </span>                    ) : null}                </FlowNodeCard>            ),        }),        [],    );    const edgeTypes = React.useMemo<EdgeTypes>(        () => ({ default: FlowEdge }),        [],    );    const [selected, setSelected] = React.useState<string | null>(null);    const steps: StepNode[] = [        {            id: "match",            type: "step",            position: { x: 0, y: 0 },            data: {                icon: <FileSearchIcon />,                category: "Matching",                title: "Match PO",                description: "Three-way match against the PO",                handles: [{ id: "out" }],                target: false,            },        },        {            id: "approve",            type: "step",            position: { x: 0, y: 124 },            data: {                icon: <SplitIcon />,                category: "Branch",                title: "Approve under $5k",                condition: "Amount is less than $5,000",                handles: [{ id: "yes" }, { id: "no" }],                target: true,            },        },        {            id: "schedule",            type: "step",            position: { x: -170, y: 272 },            data: {                icon: <CalendarClockIcon />,                category: "Payments",                title: "Schedule payment",                description: "Adds it to Friday's payment run",                handles: [{ id: "out" }],                target: true,            },        },        {            id: "review",            type: "step",            position: { x: 170, y: 272 },            data: {                icon: <UserCheckIcon />,                category: "Approval",                title: "Request approval",                description: "Asks Maya Okafor to approve it",                handles: [{ id: "out" }],                target: true,            },        },    ];    const nodes = steps.map((node) => ({        ...node,        selected: node.id === selected,    }));    const edges: Edge<FlowEdgeData>[] = [        {            id: "match-approve",            source: "match",            target: "approve",            sourceHandle: "out",            type: "default",        },        {            id: "approve-schedule",            source: "approve",            target: "schedule",            sourceHandle: "yes",            type: "default",            data: { label: "Yes" },        },        {            id: "approve-review",            source: "approve",            target: "review",            sourceHandle: "no",            type: "default",            data: { label: "No" },        },    ];    return (        <div className="relative h-[380px] w-full overflow-hidden rounded-xl bg-surface shadow-border">            <FlowCanvas<StepNode, Edge<FlowEdgeData>>                nodes={nodes}                edges={edges}                nodeTypes={nodeTypes}                edgeTypes={edgeTypes}                minimap={false}                fitView                fitViewOptions={{ padding: 0.12, maxZoom: 1 }}                nodesDraggable={false}                nodesConnectable={false}                zoomOnScroll={false}                preventScrolling={false}                onNodeClick={(_, node) => setSelected(node.id)}                onPaneClick={() => setSelected(null)}                aria-label="Approval branch"            />        </div>    );}

Node content

children become a footer below a hairline, for conditions or a problem that blocks the step. Tint the icon tile only for identity, like an agent's own color.

Filter
Approve under $5k
All of theseAmount is less than $5,000Supplier W-9 is on file
Payments
Schedule payment

Adds it to Friday's payment run

Connect a payment account to run this step.

Nora
Call Northwind Freight

Confirms the remittance address before the run

import { FlowNodeCard } from "@oration/canon/components/flow";import { BotIcon, CalendarClockIcon, CircleAlertIcon, ListFilterIcon } from "lucide-react";export function NodeContent() {    return (        <div className="flex w-full flex-wrap items-start justify-center gap-4">            <FlowNodeCard                target={false}                sourceHandles={[]}                icon={<ListFilterIcon />}                category="Filter"                title="Approve under $5k"            >                <div className="flex flex-col gap-1">                    <span className="text-xs text-muted-foreground">                        All of these                    </span>                    <span className="truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground">                        Amount is less than $5,000                    </span>                    <span className="truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground">                        Supplier W-9 is on file                    </span>                </div>            </FlowNodeCard>            <FlowNodeCard                target={false}                sourceHandles={[]}                icon={<CalendarClockIcon />}                category="Payments"                title="Schedule payment"                description="Adds it to Friday's payment run"            >                <p className="flex items-start gap-1.5 text-xs text-destructive">                    <CircleAlertIcon                        aria-hidden="true"                        className="mt-px size-3.5 shrink-0"                    />                    <span className="line-clamp-2">                        Connect a payment account to run this step.                    </span>                </p>            </FlowNodeCard>            <FlowNodeCard                target={false}                sourceHandles={[]}                icon={<BotIcon />}                iconClassName="bg-(--tag-violet) text-(--tag-violet-fg)"                category="Nora"                title="Call Northwind Freight"                description="Confirms the remittance address before the run"            />        </div>    );}

Run badge on its own

FlowRunBadge pushes itself to the end of a flex row, so it drops into a run summary beside the canvas.

Test run

Monday, Sep 28 at 10:42 AM

  1. 1Invoice received0.2 sPassed
  2. 2Match PO1.4 sFailed
  3. 3Approve under $5kSkipped
  4. 4Schedule paymentSkipped

Match PO failed: PO 4471 has no line for the $180 freight surcharge.

import { Button } from "@oration/canon/components/button";import { FlowRunBadge, type FlowRunState } from "@oration/canon/components/flow";import { toast } from "@oration/canon/components/toast";import { RotateCcwIcon } from "lucide-react";export function RunSummary() {    const steps: { title: string; run: FlowRunState; time: string }[] = [        { title: "Invoice received", run: "success", time: "0.2 s" },        { title: "Match PO", run: "error", time: "1.4 s" },        { title: "Approve under $5k", run: "skipped", time: "" },        { title: "Schedule payment", run: "skipped", time: "" },    ];    return (        <div className="w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border">            <div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2">                <div className="min-w-0">                    <p className="text-13 font-medium text-foreground">                        Test run                    </p>                    <p className="text-xs text-muted-foreground">                        Monday, Sep 28 at 10:42 AM                    </p>                </div>                <Button                    type="button"                    variant="outline"                    size="sm"                    onClick={() =>                        toast.add({                            title: "Test run started",                            description:                                "Using invoice INV-20931 from Northwind Freight.",                        })                    }                >                    <RotateCcwIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Run again                </Button>            </div>            <ol className="flex flex-col divide-y divide-border border-t border-border">                {steps.map((s, index) => (                    <li                        key={s.title}                        className="flex items-center gap-3 px-4 py-2.5"                    >                        <span className="w-4 text-xs text-muted-foreground tabular-nums">                            {index + 1}                        </span>                        <span className="min-w-0 truncate text-13 text-foreground">                            {s.title}                        </span>                        {s.time ? (                            <span className="text-xs text-muted-foreground tabular-nums">                                {s.time}                            </span>                        ) : null}                        <FlowRunBadge run={s.run} />                    </li>                ))}            </ol>            <p className="border-t border-border bg-muted/50 px-4 py-2.5 text-xs text-pretty text-muted-foreground">                Match PO failed: PO 4471 has no line for the $180 freight                surcharge.            </p>        </div>    );}

States#

Matching
Match PO

Three-way match against the PO

Idle run="idle"
MatchingQueued
Match PO

Three-way match against the PO

Queued run="queued"
MatchingRunning
Match PO

Three-way match against the PO

Running run="running"
MatchingPassed
Match PO

Three-way match against the PO

Passed run="success"
MatchingFailed
Match PO

Three-way match against the PO

Failed run="error"
MatchingSkipped
Match PO

Three-way match against the PO

Skipped run="skipped"
import { FlowNodeCard, type FlowRunState } from "@oration/canon/components/flow";import { FileSearchIcon } from "lucide-react";export function RunStates() {    const states: { run: FlowRunState; label: string }[] = [        { run: "idle", label: "Idle" },        { run: "queued", label: "Queued" },        { run: "running", label: "Running" },        { run: "success", label: "Passed" },        { run: "error", label: "Failed" },        { run: "skipped", label: "Skipped" },    ];    return (        <div className="grid w-full grid-cols-1 justify-items-center gap-x-4 gap-y-5 sm:grid-cols-2">            {states.map((state) => (                <figure                    key={state.run}                    className="flex flex-col items-center gap-2"                >                    <FlowNodeCard                        target={false}                        sourceHandles={[]}                        icon={<FileSearchIcon />}                        category="Matching"                        title="Match PO"                        description="Three-way match against the PO"                        run={state.run}                    />                    <figcaption className="text-xs text-muted-foreground">                        {state.label}                        <span className="font-mono">{` run="${state.run}"`}</span>                    </figcaption>                </figure>            ))}        </div>    );}
States
StateTreatment
IdleNo ring and no badge. The resting state while editing.
QueuedNo ring; a muted Queued label. The run will reach this step.
Running2px Focus Indigo ring at 60% and an indigo spinner with Running. The spinner only turns when motion is allowed.
Passed1px Ledger Green ring at 50% and a check with Passed. The value is success.
Failed2px Signal Red ring at 60% and an x with Failed. The value is error. Put the reason in the footer.
SkippedThe whole card fades to 60% opacity with a muted Skipped label, for steps a branch didn't take.
Selected2px indigo ring from data-selected. It wins over the run ring's color, so a selected failed node relies on its badge.
Edge active1.75px indigo dashes (5 5) that march at 0.9s linear. Under reduced motion the dashes hold still and stay indigo.
Edge selectedSolid indigo at 1.5px. Stroke color changes over 200ms.
Insert button hover and focusThe plus darkens to ink and scales to 1.1 on hover, 0.95 while pressed. Keyboard focus adds a 3px ring at 40%.

Behavior#

  • FlowCanvas passes every React Flow prop through. Its defaults (hidden attribution, zoom from 0.4 to 1.6, fitViewOptions of 0.25 padding and zoom 1 at most) are spread first, so your props override them.
  • The wrapper is relative size-full. Give its parent an explicit height; React Flow draws nothing in a box with no height.
  • It always draws the dotted grid (18px gap, 1.2px dots in Firm Hairline) and zoom controls at the bottom left without the lock button. minimap adds a pannable, zoomable minimap at the bottom right; the workflows editor turns it off.
  • Register the pieces once, outside render or memoized: edgeTypes={{ default: FlowEdge }} and a nodeTypes map whose components render FlowNodeCard. New objects on every render make React Flow warn and remount nodes.
  • FlowEdge reads data.active, data.label and data.onInsert. The label chip and insert button sit at the path midpoint with nodrag nopan, so clicking them never pans the canvas. Drop onInsert while a run is in progress.
  • Edges are smooth-steps with 10px bends. Stroke color eases over 200ms on the house curve, cubic-bezier(0.23, 1, 0.32, 1); card rings and opacity ease over 200ms.
  • Pass selected from NodeProps into the card. Branches pass sourceHandles={[{ id: "yes" }, { id: "no" }]} and edges pick one with sourceHandle.
  • FlowNodeCard renders React Flow Handles, which only work inside a node. For a static card outside the canvas, pass target={false} and sourceHandles={[]}.
  • On a long page, set preventScrolling={false} and zoomOnScroll={false} so the wheel scrolls the page instead of zooming the canvas.

Do and don't#

MatchingFailed
Match PO

Three-way match against the PO

PO 4471 has no line for the $180 freight surcharge.

Do. Write the run state in the badge and the reason in the footer, so a failed step says what failed.
Matching
Match PO

Three-way match against the PO

Don't. Signal failure with a red ring alone. In grayscale, and to a screen reader, nothing happened.
Trigger
Invoice received

From ap@cedarline.com

Payments
Schedule payment

Adds it to Friday's payment run

Do. Keep icon tiles Well Gray and let the category and title say what the step is.
Trigger
Invoice received

From ap@cedarline.com

Payments
Schedule payment

Adds it to Friday's payment run

Don't. Tint every node kind its own hue. Color-coded kinds compete with the run states and break the Option Hue Rule.
Filter
Approve under $5k

Amount is less than $5,000

Do. Name the step for what it does: Match PO, Approve under $5k, Schedule payment.
Step 3
Filter

Checks a condition

Don't. Use the step type as the title: Filter, Action, Step 3.

Content#

  • Titles are short verb phrases in sentence case: Match PO, Schedule payment, Request approval from Maya Okafor.
  • The category names the kind of step in one or two words: Trigger, Filter, Payments, Agent.
  • Descriptions say what this instance does with real values: Amount is less than $5,000, not Checks a condition.
  • Error footers state the problem and the fix: Connect a payment account to run this step.
  • Edge labels are one word, usually Yes and No from a branch.

Accessibility#

  • Give the canvas an aria-label, such as Invoice approval workflow. React Flow makes nodes and edges focusable and gives them a role description.
  • Set ariaLabel on each node and edge with the step and its run state, like Match PO, passed. The card itself has no name.
  • Run states are written words, so they don't depend on the ring color. The spinner and the marching dash sit behind motion-safe.
  • The icon tile is aria-hidden. Handles are pointer-only: offer another way to connect steps, such as the insert button or an inspector field.
  • Every insert button is named Add step here. On a canvas with several edges they all sound the same; see Known gaps.
Keyboard interactions
KeysAction
TabMoves through nodes, then edges, then the insert buttons and zoom controls.
EnterSelects the focused node or edge, or activates the focused insert button.
Arrow keysMove the selected nodes when nodesDraggable is on. Hold Shift to move further.
BackspaceDeletes the selected nodes and edges. Pass deleteKeyCode={null} to turn this off, as the workflows editor does.

Design tokens#

Design tokens
TokenUsed for
--border-strongGrid dots, edge stroke, handle stroke, minimap node stroke
--cardNode card surface
shadow-borderHairline lift on cards, edge labels, insert buttons, controls and minimap
--mutedIcon tile and minimap nodes
--primaryRunning ring at 60%, selected ring, active and selected edge stroke, Running badge
--successPassed ring at 50% and badge
--destructiveFailed ring at 60% and badge
--muted-foregroundCategory, description, Queued and Skipped labels, edge label text
--backgroundHandle fill, edge label chip, insert button, minimap mask at 60%
--ringInsert button focus ring at 40%
animate-dash0.9s linear march on the active edge
--radius-xlCard corners; icon tile, controls and minimap use rounded-lg

API reference#

FlowCanvas

React Flow with Canon's grid, controls and optional minimap. Generic over your node and edge types.

Other props spread onto React Flow ReactFlowProps<NodeType, EdgeType>, spread onto <ReactFlow>.

Props of FlowCanvas
PropTypeDefaultDescription
minimapbooleantrueShows the minimap at the bottom right.
nodesNodeType[]No defaultControlled nodes. Pair with onNodesChange, or use defaultNodes.
edgesEdgeType[]No defaultControlled edges. Give them type: "default" and register FlowEdge.
nodeTypesNodeTypesNo defaultYour node components, each rendering FlowNodeCard. Define once.
edgeTypesEdgeTypesNo default{ default: FlowEdge }. Define once.
minZoomnumber0.4Smallest zoom.
maxZoomnumber1.6Largest zoom.
fitViewOptionsFitViewOptions{ padding: 0.25, maxZoom: 1 }Used by fitView and the fit button. Never zooms past 100% by default.
proOptionsProOptions{ hideAttribution: true }Hides the React Flow attribution.
classNamestringNo defaultOn the wrapping div, which is relative size-full.
childrenReactNodeNo defaultRendered inside React Flow after the grid, controls and minimap, e.g. a <Panel>.

FlowNodeCard

The 288px node body. Render it from a custom node component.

Other props spread onto Doesn't spread props; renders <div data-slot="flow-node" data-run data-selected>.

Props of FlowNodeCard
PropTypeDefaultDescription
iconRequiredReactNodeNo defaultA Lucide icon or avatar for the 32px tile. Sized to 16px.
iconClassNamestringNo defaultOverrides the tile's Well Gray, for identity tints only.
categoryRequiredstringNo defaultThe kind of step, above the title.
titleRequiredstringNo defaultThe step's name. Truncates to one line.
descriptionstringNo defaultWhat this step does. Clamps to two lines.
selectedbooleanNo defaultDraws the 2px indigo ring. Pass selected from NodeProps.
runFlowRunState"idle"Run state: ring, opacity and badge.
sourceHandles{ id: string; label?: string }[][{ id: "out" }]Outputs along the bottom edge. [] for none. label is not drawn.
targetbooleantrueDraws the input handle. false for triggers and static cards.
childrenReactNodeNo defaultFooter content below a hairline: conditions, an error.
classNamestringNo defaultMerged onto the card, e.g. a drop-target ring.

FlowEdge

Smooth-step edge with an optional label chip, insert button and active march. Register it as an edge type.

Other props spread onto React Flow EdgeProps<Edge<FlowEdgeData>>.

Props of FlowEdge
PropTypeDefaultDescription
data.activebooleanNo defaultDashed indigo, 1.75px, marching. Only for the edge a run is on.
data.labelstringNo defaultA small chip at the midpoint, like Yes or No.
data.onInsert(edgeId: string) => voidNo defaultShows the 20px plus at the midpoint. Called with the edge id.
selectedbooleanNo defaultFrom React Flow. Turns the stroke indigo.
markerEndstringNo defaultFrom React Flow, passed to the path. The product draws none.

FlowRunBadge

The icon and label for a run state. Renders nothing for idle. Pushes itself right with ml-auto.

Props of FlowRunBadge
PropTypeDefaultDescription
runRequiredFlowRunStateNo defaultWhich state to show.

FlowRunState

Type export.

Props of FlowRunState
PropTypeDefaultDescription
FlowRunState"idle" | "queued" | "running" | "success" | "error" | "skipped"No defaultsuccess reads Passed and error reads Failed.

FlowEdgeData

Type export for Edge<FlowEdgeData>.

Props of FlowEdgeData
PropTypeDefaultDescription
FlowEdgeData{ label?: string; active?: boolean; onInsert?: (edgeId: string) => void }No defaultThe edge data that FlowEdge reads.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The insert button is 20px, as DESIGN.md specifies, which is under the 24px minimum hit area. Every one is named Add step here, with no hint of which two steps it sits between.

sourceHandles[].label is in the type but never drawn. Branch labels come from the edge's data.label instead.

Run state values are success and error, while the badge and DESIGN.md say Passed and Failed.

The selected ring overrides the run ring's color, so a selected node that failed shows an indigo ring and only its badge says Failed.

FlowNodeCard doesn't spread props, so it takes no aria-*, data-* or ref. Names have to go on the React Flow node as ariaLabel.

The workflows editor tints trigger tiles with --tag-indigo through iconClassName. That color-codes a node kind; DESIGN.md specifies Well Gray tiles.

FlowRunBadge isn't used on its own anywhere in the app; the run log draws its own status icons.