Flow Canvas
alphav0.3.0Node-and-edge canvas with typed ports, pluggable node renderers, edge and port-type registries, and JSON save and load — built on React Flow.
Context
Use FlowCanvas to build flow editors, workflow canvases, AI agent graphs, visual configuration UIs, or any port-and-edge surface where nodes should be JSON-first. Every node carries a __type field that keys into a renderer registry; unknown shapes fall back to a built-in custom-JSON renderer. Connection ports live inside the data, recursively, so sub-objects can be extracted as standalone nodes through the same drop pipeline. The library is xyflow (formerly React Flow) — MIT-licensed, no feature gates.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/flow-canvasAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/flow-canvas-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Five renderers — Prompt, LLM (with extractable tool chips), Display, Project card adapter, and the custom-JSON fallback. Drag handles to connect; the LLM's output has multi: true so it fans out. Drag a tool chip onto empty canvas to extract it as a node; Alt-drag to move.
Live playground
flow-canvas renders a node graph from a JSON CanvasData (nodes + edges). Nodes use a card renderer or fall back to the built-in custom-JSON node; edges connect nodeId:portId refs. Submit to render the live, interactive canvas.
70 lines · valid
Nothing rendered yet
Edit the JSON on the left, then press Submit to render the live result on the right.
Demo source
"use client"; import { useMemo } from "react";import { Bot, FileText, GripVertical, Sparkles, Wrench } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { ProjectCard } from "../project-card";import type { ProjectCardItem } from "../project-card/types";import { FlowCanvas } from "./flow-canvas";import { FLOW_CANVAS_RICH, makeStressData } from "./dummy-data";import { emitSubObjectDrag } from "./lib/emit-sub-object-drag";import { PortsAt } from "./parts/ports-at";import type { NodeRenderer } from "./types"; // ─────────────────────────────────────────────────────────────────────// Custom renderers — defined at module scope (CRITICAL — see the// xyflow-react-pro skill: recreating renderer maps in render triggers// teardown + remount on every render).// ───────────────────────────────────────────────────────────────────── const promptRenderer: NodeRenderer = { type: "prompt", label: "Prompt", defaultPorts: () => [ { id: "out", side: "right", dir: "out", type: "text" }, ], render: (data) => { const template = (data as { template?: string }).template ?? ""; return ( <div className="relative w-56 rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm"> <header className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground"> <FileText aria-hidden className="h-3.5 w-3.5" /> Prompt </header> <div className="rounded-sm bg-muted px-2 py-1 font-mono text-[11px] leading-snug"> {template} </div> <PortsAt ports={data.ports} position="right" /> </div> ); },}; type ToolItem = { __type?: string; name: string; description?: string }; const llmRenderer: NodeRenderer = { type: "llm", label: "LLM", defaultPorts: () => [ { id: "in", side: "left", dir: "in", type: "text" }, { id: "out", side: "right", dir: "out", type: "text", multi: true }, ], // Each tool chip is independently extractable as a standalone node. // The right-click menu adds "Extract tools[N]" entries for keyboard // accessibility; HTML5 drag-out works for mouse + touch (long-press). extractablePaths: (data) => { const tools = (data as unknown as { tools?: ToolItem[] }).tools; return Array.isArray(tools) ? tools.map((_, i) => `tools[${i}]`) : []; }, render: (data, ctx) => { const model = (data as { model?: string }).model ?? "claude-opus-4"; const tools = ((data as { tools?: ToolItem[] }).tools ?? []); return ( <div className="relative w-56 rounded-md border-2 border-primary/40 bg-card p-3 text-card-foreground shadow-sm"> <header className="mb-2 flex items-center gap-2 text-xs font-semibold text-foreground"> <Sparkles aria-hidden className="h-3.5 w-3.5 text-primary" /> LLM </header> <div className="font-mono text-[11px] text-muted-foreground">{model}</div> {tools.length > 0 && ( <div className="mt-2 flex flex-wrap gap-1"> {tools.map((tool, i) => ( <div key={`${tool.name}-${i}`} data-draggable-subobject={`tools[${i}]`} draggable onDragStart={(e) => emitSubObjectDrag(e, tool, `tools[${i}]`, ctx.nodeId) } className="group flex cursor-grab items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 text-[10px] hover:bg-accent active:cursor-grabbing" title="Drag out to extract — Alt+drag to move" > <GripVertical aria-hidden className="h-2.5 w-2.5 text-muted-foreground opacity-60 group-hover:opacity-100" /> {tool.name} </div> ))} </div> )} <PortsAt ports={data.ports} position="left" /> <PortsAt ports={data.ports} position="right" /> </div> ); },}; const toolRenderer: NodeRenderer = { type: "tool", label: "Tool", defaultPorts: () => [{ id: "in", side: "left", dir: "in", type: "text" }], render: (data) => { const name = (data as { name?: string }).name ?? "Tool"; const description = (data as { description?: string }).description; return ( <div className="relative w-44 rounded-md border border-border bg-card p-2 text-card-foreground shadow-sm"> <header className="mb-1 flex items-center gap-2 text-[11px] font-medium text-foreground"> <Wrench aria-hidden className="h-3 w-3 text-muted-foreground" /> {name} </header> {description && ( <div className="text-[10px] text-muted-foreground">{description}</div> )} <PortsAt ports={data.ports} position="left" /> </div> ); },}; const displayRenderer: NodeRenderer = { type: "display", label: "Display", defaultPorts: () => [{ id: "in", side: "left", dir: "in", type: "text" }], render: (data) => { const label = (data as { label?: string }).label ?? "Output"; return ( <div className="relative w-44 rounded-md border border-dashed border-border bg-background/60 p-3 text-foreground shadow-sm"> <header className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground"> <Bot aria-hidden className="h-3.5 w-3.5" /> {label} </header> <div className="text-[11px] text-muted-foreground"> Renders the upstream value. </div> <PortsAt ports={data.ports} position="left" /> </div> ); },}; // Adapter — wraps the existing ProjectCard registry component as a node.// The canonical "use any rich card from the registry as a node" pattern.const projectCardRenderer: NodeRenderer = { type: "project-card", label: "Project", defaultPorts: () => [ { id: "in", side: "left", dir: "in", type: "text" }, { id: "out", side: "right", dir: "out", type: "card" }, ], render: (data) => { const project = (data as unknown as { project: ProjectCardItem }).project; return ( <div className="relative w-72"> <ProjectCard project={project} variant="grid" loading="eager" /> <PortsAt ports={data.ports} position="left" /> <PortsAt ports={data.ports} position="right" /> </div> ); },}; const RICH_RENDERERS = [ promptRenderer, llmRenderer, toolRenderer, displayRenderer, projectCardRenderer,]; // Empty starter — just the built-in custom-json fallback. Drop or paste// any JSON to spawn a node.const EMPTY_DATA = { version: 1 as const, nodes: [], edges: [] }; export default function FlowCanvasDemo() { // Stress fixture is created once, never recreated on tab switch. const stressData = useMemo(() => makeStressData(200), []); return ( <Tabs defaultValue="workflow" className="w-full"> <SwipeTabsList> <TabsTrigger value="workflow">Workflow</TabsTrigger> <TabsTrigger value="readonly">Read-only viewer</TabsTrigger> <TabsTrigger value="custom-json">Custom JSON only</TabsTrigger> <TabsTrigger value="stress">Stress (200 nodes)</TabsTrigger> </SwipeTabsList> <TabsContent value="workflow" className="mt-4"> <p className="mb-2 text-xs text-muted-foreground"> Five renderers — Prompt, LLM (with extractable tool chips), Display, Project card adapter, and the custom-JSON fallback. Drag handles to connect; the LLM's output has <code>multi: true</code> so it fans out. Drag a tool chip onto empty canvas to extract it as a node; Alt-drag to move. </p> <div className="h-140 w-full"> <FlowCanvas renderers={RICH_RENDERERS} defaultData={FLOW_CANVAS_RICH} /> </div> </TabsContent> <TabsContent value="readonly" className="mt-4"> <p className="mb-2 text-xs text-muted-foreground"> Same data, <code>readOnly={"{true}"}</code>. Pan, zoom, select, and the right-click view-only menu still work; drag, connect, paste, delete, and mutation menu items are suppressed. The use-case: audit / share-link views of a saved graph. </p> <div className="h-140 w-full"> <FlowCanvas renderers={RICH_RENDERERS} defaultData={FLOW_CANVAS_RICH} readOnly /> </div> </TabsContent> <TabsContent value="custom-json" className="mt-4"> <p className="mb-2 text-xs text-muted-foreground"> No consumer renderers registered — only the built-in custom-JSON fallback. Drag a <code>.json</code> file from your desktop, drag JSON from another draggable, paste with <kbd>Ctrl/Cmd-V</kbd>, or right-click → "Paste JSON…". Every shape becomes a node. </p> <div className="h-140 w-full"> <FlowCanvas defaultData={EMPTY_DATA} /> </div> </TabsContent> <TabsContent value="stress" className="mt-4"> <p className="mb-2 text-xs text-muted-foreground"> 200 custom-JSON nodes laid out in a 20-column grid with sparse right-and-down edges. <code>onlyRenderVisibleElements</code> is on, so xyflow culls off-screen nodes/edges before rendering. Pan + zoom should stay smooth; click a node to select; Backspace deletes. </p> <div className="h-140 w-full"> <FlowCanvas defaultData={stressData} onlyRenderVisibleElements /> </div> </TabsContent> </Tabs> );} Usage
When to use
FlowCanvas is a node-and-edge canvas built on @xyflow/react. Reach for it when you need a flow editor, workflow canvas, AI agent graph, schema designer, or any port-and-edge UI where nodes are data-first — JSON objects discriminated by a __type field that keys into a renderer registry.
When NOT to use
- Force-directed graph layouts (use
force-graph-01). - Hierarchical org / tree visualizations — flow-canvas is free-form; tree layouts want a tree component.
- Pure timelines, Gantt charts, dense data tables, kanban boards — each has a dedicated component in the registry.
Three keystone registries
Every "what does this look like" decision flows through one of three consumer-extendable registries — built-ins ship, consumers append:
renderers—__type → React renderer. Built-in:customJsonRenderer(the fallback for unknown shapes).portTypes—type id → color/icon/label. Built-ins:data,text,image,card,event(mapped to design tokens).edgeTypes—type id → React edge renderer. Built-in:defaultEdgeRenderer(smoothstep, stroke pulled from source-port type color). Consumer-registered edge dispatch ships in v0.2.
Basic example
import { FlowCanvas, type NodeRenderer } from "@/components/flow-canvas"
// Register a renderer for your domain type. Define at module scope (or
// useMemo) — recreating the array on every render thrashes xyflow's
// nodeTypes registry.
const promptRenderer: NodeRenderer = {
type: "prompt",
label: "Prompt",
defaultPorts: () => [
{ id: "out", side: "right", dir: "out", type: "text" },
],
render: (data) => <PromptCard data={data} />,
}
const RENDERERS = [promptRenderer]
export function MyEditor() {
return (
<div className="h-screen w-full"> {/* parent MUST have explicit dims */}
<FlowCanvas
renderers={RENDERERS}
defaultData={{ version: 1, nodes: [], edges: [] }}
onChange={(next) => persist(next)}
/>
</div>
)
}Save / restore via exportRef
import { useRef } from "react"
import { type FlowCanvasExportHandle } from "@/components/flow-canvas"
const exportRef = useRef<FlowCanvasExportHandle>(null)
// Round-trip with ports + edges (canvas-instance state):
const portable = exportRef.current?.export({ withPorts: true })
// Strip ports + edges for source-shape persistence:
const sourceShape = exportRef.current?.export({ withPorts: false })Capabilities — v0.1.0
- Pan / zoom / fit-view / select / delete — pointer + keyboard.
BackspaceANDDeleteremove selected nodes/edges (cascades). - Typed connections — drag-to-connect; mismatched-type / wrong-direction /
multi: false-already-connected pairs rejected with the in-flight rejection indicator. PlugonBeforeConnectfor semantic validation on top. - Drop / paste pipeline — drag any
.jsonfile from desktop, drag JSON from another draggable, or paste withCmd/Ctrl-Vwhile the canvas is focused. Three MIMEs accepted:application/json,application/reactflow,text/plain.onBeforeDropintercepts. Default ports inflate at the drop boundary;ports: [](deliberate empty) is respected. - Sub-object drag-extract — renderers mark draggable sub-paths with
data-draggable-subobject={path}+draggable+ anonDragStartcallingemitSubObjectDrag. Default copy, Alt-drag for move. Sub-objects without__typeauto-coerce tocustom-json. - Right-click menus — three contexts (canvas, node, edge). Built-ins: Paste JSON…, Add custom node, Fit view, Reset zoom, Copy as JSON, Duplicate, Convert to custom-JSON, Extract
<path>(keyboard fallback for sub-object extract), Delete. Mutation items hide inreadOnly; consumer items append viamenuItems.{canvas, node, edge}. - Per-node lock —
node.locked: truepins position; other ops allowed unlessreadOnly. - Read-only mode —
readOnlykills drag / connect / mutation menu items; pan / zoom / select / view-only menus stay. - Performance — every component memoized;
onlyRenderVisibleElementsdefaults totruein v0.2.0 (xyflow culls offscreen nodes / edges; ~12× directional FPS lift at N=5000 heavy on one measured machine). Pass={false}to opt out — rare; only if you rely on offscreen-node DOM.onChangebatches mid-drag position-only changes — fires on drag-end, not every tick. Stress demos ship withmakeStressData(N)+makeHeavyStressData(N); the sandbox stress page at/sandbox/flow-stressexposes URL params for live N + fixture + lever toggles. Renderer-author perf rules in the procomp guide §8 (linked below). - Theming —
--xy-*CSS variables inglobals.cssfollow design tokens (signal-lime accent on ring + selected edges; OKLCH palette for handles). Light + dark via class-based toggle on a parent.
Critical rules
- Parent must have explicit width AND height. Putting
h-screenon the canvas itself doesn't work — xyflow measures its parent. - Define
renderers/portTypes/edgeTypesat module scope or viauseMemo. Recreating the arrays each render triggers teardown + remount of every node — flicker, lost focus, sometimes infinite loops. (We mitigate internally by routing all nodes through a single"ilinxa-node"xyflow type, but the rule still matters for the props you pass us.) - Port IDs must be unique within a node — across the entire
datatree, not just the root. Edges reference flatnodeId:portId; the tree-walker resolves location. - Never mutate
nodes/edgesarrays in place. Always spread:{ ...node, data: { ...node.data, x } }. Mutation breaks change detection. - Single source of truth for state. Pick controlled (
data+onChange) or uncontrolled (defaultData) per<FlowCanvas>instance. Don't flip mid-mount.
Performance & scale — renderer-author rules (v0.2.0)
- Custom edge selection state:if your custom edge needs to react to source / target node selection, query xyflow's store via
useStore— NOT per-edge React state. Per-edge state cascades into full-canvas re-renders at scale. The built-inDefaultEdgefollows this rule; the sealed-folderlib/shallow.tsexports a zero-depshallowhelper for selectors returning object shapes. Pattern + the lockedportEqualexample in procomp guide §8.2. - Popup-edit convention: heavy editable content (rich-text, code editors, multi-line forms) lives in a consumer-owned dialog opened on click, NOT inline in the node renderer. Inline editors mount the full state machine per node (e.g. 200 editor instances at N=200) — putting the editor in a single dialog scopes that cost to the one node being edited. Pattern in procomp guide §8.3 + the future
RenderContext.onEditRequestslot (v0.3 candidate).
Not implemented (Tier 3 + adjacent)
- Custom edge renderers dispatched per-edge via
edgeTypes(registry exists; dispatch is Tier 3 per perf description) - Per-handle
isValidConnectionoverrides (node-local connection-validation slot)
Future considerations (no scheduled version)
- Toast notifications on parse error (today:
console.warn+ silent abort) - Undo / redo, marquee selection, groups / frames, minimap, execution-state animation
- DB-ref nodes (
{ ref: 'post:abc123' }placeholders that fetch on demand) - Cross-canvas drag
Full reference: see docs/procomps/flow-canvas-procomp/flow-canvas-procomp-guide.md. Architecture decisions Q1–Q24 live in the description doc; the implementation contract lives in the plan doc.
Features
- Pan / zoom / fit-to-view with gradient background (light + dark themes)
- Renderer registry by __type — register any React component as a node renderer
- Built-in custom-JSON fallback for unrecognized shapes
- Port-type registry (6 built-in types: data / text / image / card / event / doc)
- Edge-type registry with smoothstep default
- Controlled and uncontrolled state (data / defaultData / onChange)
- Imperative export via exportRef ({ withPorts } toggles source vs canvas)
- Typed connection validation, multi-edge per port, sub-object drag-extract (M2+)
- Read-only mode that preserves pan / zoom / select
- Popup-edit renderer convention (v0.2.1) — onEditRequest + updateNodeData helper
- v0.2.7 — F-cross-13 path-b sweep: canvas ContextMenuTrigger drops `asChild` (`className="contents"` wrapper; right-click bubbles children → resolver → trigger in both backends). Zero public-API change.