Skip to content
ilinxa/pro-ui

Flow Canvas

alphav0.3.0

Node-and-edge canvas with typed ports, pluggable node renderers, edge and port-type registries, and JSON save and load — built on React Flow.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-06Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/flow-canvas

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/flow-canvas-fixtures

CLI 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.

CanvasData · JSON
valid

70 lines · valid

Live preview

Nothing rendered yet

Edit the JSON on the left, then press Submit to render the live result on the right.

Demo source

demo.tsxtsx
"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&apos;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 → &quot;Paste JSON…&quot;. 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. Backspace AND Delete remove 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. Plug onBeforeConnect for semantic validation on top.
  • Drop / paste pipeline — drag any .json file from desktop, drag JSON from another draggable, or paste with Cmd/Ctrl-V while the canvas is focused. Three MIMEs accepted: application/json, application/reactflow, text/plain. onBeforeDrop intercepts. 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 + an onDragStart calling emitSubObjectDrag. Default copy, Alt-drag for move. Sub-objects without __type auto-coerce to custom-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 in readOnly; consumer items append via menuItems.{canvas, node, edge}.
  • Per-node lock — node.locked: true pins position; other ops allowed unless readOnly.
  • Read-only mode — readOnly kills drag / connect / mutation menu items; pan / zoom / select / view-only menus stay.
  • Performance — every component memoized; onlyRenderVisibleElements defaults to true in 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. onChange batches mid-drag position-only changes — fires on drag-end, not every tick. Stress demos ship with makeStressData(N) + makeHeavyStressData(N); the sandbox stress page at /sandbox/flow-stress exposes URL params for live N + fixture + lever toggles. Renderer-author perf rules in the procomp guide §8 (linked below).
  • Theming — --xy-* CSS variables in globals.css follow 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 / edgeTypes at module scope or via useMemo. 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 data tree, not just the root. Edges reference flat nodeId:portId; the tree-walker resolves location.
  • Never mutate nodes / edges arrays 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-in DefaultEdge follows this rule; the sealed-folder lib/shallow.ts exports a zero-dep shallow helper for selectors returning object shapes. Pattern + the locked portEqual example 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.onEditRequest slot (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 isValidConnection overrides (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.

Tags

flow-canvasflowcanvasnode-editorgraphworkflowxyflowreact-flowportsedges

Dependencies

shadcn primitives: button, context-menu, dialog, textarea
npm peer deps: @xyflow/react@^12.10.2, lucide-react@^1.11.0