Workflow canvas

Connected steps you can move, inspect and follow as work flows through them.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/workflow-canvas.json

Overview

A compact canvas for showing how work gets done. Nodes sit on a quiet dot grid, connected by rounded paths. Drag a node and its connections follow in the same coordinate space; drag the background to pan, zoom around the centre, or fit the whole workflow. Select a node to inspect its details. Execution states come from your application: completed steps leave a coloured trail, running steps receive travelling pulses, and failures stay visible with their context.

States

ready / queued
Each node shows its title, subtitle and state indicator. Connections stay quiet until their source has completed.
running
The node’s outline gains colour and a pulse travels down incoming connections from completed steps. Pulses pause offscreen and in hidden tabs.
complete / error
Completion resolves to a check and leaves a fine coloured connection. Failure uses an error indicator and keeps the node’s details available.
move
Dragging updates a node and all attached SVG paths directly, in canvas coordinates. The final position is reported on release; cancellation restores it.
pan / zoom
Nodes and paths share one transform. Zoom stays centred on the viewport; Fit shows the full canvas with padding.
inspect
A details panel opens below the canvas and measures its content height. Selecting another node updates the panel without shifting the canvas.

Usage

usage.tsx
import { WorkflowCanvas } from "@/components/workflow-canvas";<WorkflowCanvas width={460} height={180}  nodes={[    { id: "input", title: "Receive brief", x: 20, y: 50, status: "complete" },    { id: "draft", title: "Create draft", x: 270, y: 50, status: "running", detail: "Preparing a first draft from the brief." },  ]}  edges={[{ id: "input-draft", from: "input", to: "draft" }]}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Drive it from your application

The component displays the state you provide. Connect your runner or event stream to node statuses, and persist positions after a drag.

example.tsx
"use client";import { useState } from "react";import { WorkflowCanvas, type WorkflowNode } from "@/components/workflow-canvas";export function Workflow() {  const [nodes, setNodes] = useState<WorkflowNode[]>([    { id: "brief", title: "Receive brief", x: 20, y: 50, status: "idle" },    { id: "draft", title: "Create proposal", x: 270, y: 50, status: "queued" },  ]);  return (    <WorkflowCanvas nodes={nodes} width={460} height={180}      edges={[{ id: "brief-draft", from: "brief", to: "draft" }]}      onNodeMove={(id, position) => setNodes((current) => current.map((node) => node.id === id ? { ...node, ...position } : node))}      onNodeSelect={(id) => console.log("Inspect", id)}    />  );}

Props

PropTypeDescription
nodesWorkflowNode[]Unique id, title and x/y canvas coordinates. Optional subtitle, icon, status and detail. Nodes measure 164 × 78px.
edges{ id: string; from: string; to: string }[]Unique connection IDs and source/destination node IDs. Missing endpoints are ignored.
width / heightnumberdefault 660 / 340Canvas bounds in CSS pixels before zoom. Viewport height is 300px.
labelstringdefault WorkflowRegion label and visible canvas heading.
onNodeMove(id: string, position: { x: number; y: number }) => voidFinal coordinates after a committed drag. Persist these in your application.
onNodeSelect(id: string | null) => voidThe inspected node ID, or null when its details close.
classNamestringAdditional root classes. Standard div attributes are forwarded.

Accessibility and motion

  • Nodes are real buttons. Tab reaches every node and control; arrows move focus in supplied reading order. Alt + arrows move a node by 12 canvas pixels. Enter or Space opens details; Escape closes details or cancels a drag.
  • Status uses text and icons as well as colour. Running, completion and error updates are announced in a polite live region.
  • Reduced motion removes connection pulses, spinning indicators and eased viewport/panel transitions. Direct manipulation stays attached to the pointer.
  • The canvas owns touch gestures inside its bounds. The initial zoom stays at least 85% for legibility; on narrow screens, pan to reach other nodes or use Fit for an overview. Focusing a node brings it into view.
  • Node positions are remembered locally after dragging. onNodeMove lets you persist them. Remount with a new key to reset a moved layout.
  • This is a workflow viewer, not a graph execution engine or connection editor. Your application owns execution, retries and data validation.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Text morph.

Source

components/workflow-canvas.tsx
'use client';import * as React from 'react';import { Circle, Maximize2, Minus, Plus } from 'lucide-react';import { WorkflowConnections } from './workflow-connections';import { useWorkflowViewport } from './use-workflow-viewport';import { INTERACTION_FOCUS, useInteractionReducedMotion, useInteractionActivity } from './ui-interaction-motion';import { WorkflowStatusMark, WORKFLOW_STATUS as STATUS } from './workflow-status';import { WorkflowInspector } from './workflow-inspector';import { NumberRoll } from './number-roll';import { TextMorph } from './text-morph';import { NODE_WIDTH, NODE_HEIGHT, type WorkflowNode, type WorkflowEdge, type WorkflowPoint, type WorkflowStatus } from './workflow-types';export type { WorkflowNode, WorkflowEdge, WorkflowPoint, WorkflowStatus } from './workflow-types';export interface WorkflowCanvasProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {  nodes: WorkflowNode[];  edges: WorkflowEdge[];  width?: number;  height?: number;  label?: string;  onNodeMove?: (id: string, position: WorkflowPoint) => void;  onNodeSelect?: (id: string | null) => void;}const CONTROL = `cursor-pointer disabled:cursor-default flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 ${INTERACTION_FOCUS}`;/** A bounded workflow viewer with movable nodes, a shared pan/zoom transform and live execution states. */export function WorkflowCanvas({ nodes, edges, width = 660, height = 340, label = 'Workflow', onNodeMove, onNodeSelect, className = '', ...props }: WorkflowCanvasProps) {  const reduced = useInteractionReducedMotion();  const activity = useInteractionActivity<HTMLDivElement>();  const [selected, setSelected] = React.useState<string | null>(null);  const nodeRefs = React.useRef(new Map<string, HTMLButtonElement>());  const canvasWidth = Math.max(NODE_WIDTH, width), canvasHeight = Math.max(NODE_HEIGHT, height);  const { viewport, world, positions, view, dragging, fit, zoom, revealNode, moveNode, handlers } = useWorkflowViewport(nodes, edges, canvasWidth, canvasHeight, reduced, onNodeMove);  const selectedNode = nodes.find((node) => node.id === selected);  const detailsId = React.useId();  const choose = (id: string | null) => { setSelected(id); onNodeSelect?.(id); };  return (    <div ref={activity.ref} role="region" aria-label={label} className={`w-full overflow-clip rounded-[20px] border border-border bg-background text-foreground ${className}`} {...props}>      <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">        <span className="text-[12px] font-medium">{label}</span>        <span className="font-mono text-[10px] text-muted-foreground"><NumberRoll value={nodes.length} /> nodes · <NumberRoll value={edges.length} /> connections</span>      </div>      <div ref={viewport} {...handlers} data-lenis-prevent className={`relative h-[300px] overflow-clip bg-muted/35 ${dragging ? 'cursor-grabbing' : 'cursor-grab'}`}        style={{ touchAction: 'none', backgroundImage: 'radial-gradient(var(--border) 1px, transparent 1px)', backgroundSize: '16px 16px' }}>        <div ref={world} className="absolute left-0 top-0 origin-top-left" style={{ width: canvasWidth, height: canvasHeight, transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}>          <WorkflowConnections nodes={nodes} edges={edges} positions={positions} reduced={reduced} width={canvasWidth} height={canvasHeight} />          {nodes.map((node, index) => {            const status = node.status ?? 'idle';            return <button key={node.id} type="button" ref={(el) => { if (el) nodeRefs.current.set(node.id, el); else nodeRefs.current.delete(node.id); }}              data-workflow-node={node.id} aria-label={`${node.title}, ${STATUS[status]}`} aria-expanded={selected === node.id} aria-controls={selected === node.id ? detailsId : undefined}              onClick={() => choose(selected === node.id ? null : node.id)}              onFocus={() => revealNode(node.id)}              onKeyDown={(event) => {                if (event.key === 'Escape') { event.preventDefault(); choose(null); }                if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {                  event.preventDefault();                  if (event.altKey) { moveNode(node.id, event.key === 'ArrowRight' ? 12 : event.key === 'ArrowLeft' ? -12 : 0, event.key === 'ArrowDown' ? 12 : event.key === 'ArrowUp' ? -12 : 0); return; }                  const offset = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1;                  nodeRefs.current.get(nodes[(index + offset + nodes.length) % nodes.length].id)?.focus({ preventScroll: true });                }              }}              className={`cursor-pointer absolute left-0 top-0 flex flex-col justify-between rounded-xl border bg-card px-3 py-2.5 text-left text-card-foreground transition-[border-color] duration-300 ${INTERACTION_FOCUS} ${status === 'error' ? 'border-destructive/50' : selected === node.id || status === 'running' ? 'border-primary/70' : 'border-border'}`}              style={{ width: NODE_WIDTH, height: NODE_HEIGHT, transform: `translate(${positions[node.id].x}px, ${positions[node.id].y}px)` }}>              <span className="flex w-full items-center gap-2"><span className="flex size-5 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 text-muted-foreground">{node.icon ?? <Circle className="size-3" aria-hidden="true" />}</span><span className="truncate text-[12px] font-medium"><TextMorph>{node.title}</TextMorph></span></span>              <span className="flex w-full items-center justify-between gap-2"><span className="truncate text-[11px] text-muted-foreground"><TextMorph>{node.subtitle ?? STATUS[status]}</TextMorph></span><WorkflowStatusMark status={status} reduced={reduced} active={activity.active} /></span>            </button>;          })}        </div>        <div data-workflow-control className="absolute bottom-3 left-3 flex items-center gap-0.5 rounded-xl border border-border bg-background p-1">          <button type="button" aria-label="Zoom out" disabled={view.scale <= 0.25} onClick={() => zoom(1 / 1.2)} className={CONTROL}><Minus className="size-3.5" aria-hidden="true" /></button>          <output aria-label="Zoom level" className="w-11 text-center font-mono text-[10px] tabular-nums text-muted-foreground"><NumberRoll value={Math.round(view.scale * 100)} />%</output>          <button type="button" aria-label="Zoom in" disabled={view.scale >= 1.75} onClick={() => zoom(1.2)} className={CONTROL}><Plus className="size-3.5" aria-hidden="true" /></button>          <span className="mx-1 h-3 w-px bg-border" />          <button type="button" aria-label="Fit workflow" onClick={fit} className={CONTROL}><Maximize2 className="size-3.5" aria-hidden="true" /></button>        </div>      </div>      <WorkflowInspector id={detailsId} node={selectedNode} reduced={reduced} onClose={() => { choose(null); if (selectedNode) nodeRefs.current.get(selectedNode.id)?.focus({ preventScroll: true }); }} />      <p role="status" aria-live="polite" className="sr-only">{nodes.filter((node) => node.status === 'running').map((node) => `${node.title} running`).join('. ') || (nodes.some((node) => node.status === 'error') ? 'Workflow has a failed step.' : nodes.length > 0 && nodes.every((node) => node.status === 'complete') ? 'Workflow complete.' : 'Workflow ready.')}</p>    </div>  );}
components/workflow-types.ts
import type * as React from 'react';export type WorkflowStatus = 'idle' | 'queued' | 'running' | 'complete' | 'error';export type WorkflowPoint = { x: number; y: number };export interface WorkflowNode extends WorkflowPoint {  id: string;  title: string;  subtitle?: string;  icon?: React.ReactNode;  status?: WorkflowStatus;  detail?: React.ReactNode;}export interface WorkflowEdge { id: string; from: string; to: string }export const NODE_WIDTH = 164;export const NODE_HEIGHT = 78;/** Rounded elbows for forward connections; a smooth loop for nodes dragged behind their source. */export function workflowPath(from: WorkflowPoint, to: WorkflowPoint) {  const x1 = from.x + NODE_WIDTH, y1 = from.y + NODE_HEIGHT / 2;  const x2 = to.x, y2 = to.y + NODE_HEIGHT / 2;  if (x2 - x1 < 40) {    const reach = Math.max(60, Math.abs(x2 - x1) * 0.6);    return `M ${x1} ${y1} C ${x1 + reach} ${y1}, ${x2 - reach} ${y2}, ${x2} ${y2}`;  }  if (Math.abs(y2 - y1) < 2) return `M ${x1} ${y1} H ${x2}`;  const mid = (x1 + x2) / 2, sign = Math.sign(y2 - y1), radius = Math.min(14, Math.abs(y2 - y1) / 2);  return `M ${x1} ${y1} H ${mid - radius} Q ${mid} ${y1} ${mid} ${y1 + sign * radius} V ${y2 - sign * radius} Q ${mid} ${y2} ${mid + radius} ${y2} H ${x2}`;}
components/workflow-connections.tsx
'use client';import * as React from 'react';import { workflowPath, type WorkflowEdge, type WorkflowNode, type WorkflowPoint } from './workflow-types';function Connection({ edge, nodes, positions, reduced, playing }: { edge: WorkflowEdge; nodes: WorkflowNode[]; positions: Record<string, WorkflowPoint>; reduced: boolean; playing: boolean }) {  const ref = React.useRef<SVGPathElement>(null);  const from = nodes.find((node) => node.id === edge.from), to = nodes.find((node) => node.id === edge.to);  const active = from?.status === 'complete' && to?.status === 'running';  const completed = from?.status === 'complete' && to?.status === 'complete';  const d = from && to ? workflowPath(positions[from.id] ?? from, positions[to.id] ?? to) : '';  React.useEffect(() => {    const path = ref.current;    if (!path || !active || !playing || reduced) return;    const length = path.getTotalLength();    path.style.strokeDasharray = `14 ${length}`;    const animation = path.animate([{ strokeDashoffset: '0' }, { strokeDashoffset: String(-length - 14) }], { duration: Math.max(700, length / 0.2), iterations: Infinity, easing: 'linear' });    return () => { animation.cancel(); path.style.strokeDasharray = ''; };  }, [active, playing, reduced, d]);  if (!from || !to) return null;  return (    <g>      <path data-workflow-edge={edge.id} d={d} fill="none" stroke={active || completed ? 'var(--primary)' : 'var(--border)'} strokeOpacity={active || completed ? 0.35 : 1} strokeWidth="1.5" />      {active && <path ref={ref} data-workflow-edge={edge.id} d={d} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" opacity={reduced ? 0.6 : 1} />}    </g>  );}export function WorkflowConnections({ nodes, edges, positions, reduced, width, height }: { nodes: WorkflowNode[]; edges: WorkflowEdge[]; positions: Record<string, WorkflowPoint>; reduced: boolean; width: number; height: number }) {  const ref = React.useRef<SVGSVGElement>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const svg = ref.current;    if (!svg) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(svg);    const visibility = () => setHidden(document.hidden);    visibility();    document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return <svg ref={ref} aria-hidden="true" width={width} height={height} className="pointer-events-none absolute inset-0 overflow-visible">    {edges.map((edge) => <Connection key={edge.id} edge={edge} nodes={nodes} positions={positions} reduced={reduced} playing={visible && !hidden} />)}  </svg>;}
components/workflow-status.tsx
'use client';import React from 'react';import { Circle, CircleAlert } from 'lucide-react';import { interactionIcon } from './ui-interaction-motion';import type { WorkflowStatus } from './workflow-types';export const WORKFLOW_STATUS: Record<WorkflowStatus, string> = { idle: 'Ready', queued: 'Queued', running: 'Running', complete: 'Complete', error: 'Failed' };export function WorkflowStatusMark({ status, reduced, active }: { status: WorkflowStatus; reduced: boolean; active: boolean }) {  return <span aria-hidden="true" className="relative size-3.5 shrink-0">    <span className="absolute inset-0 flex items-center justify-center" style={interactionIcon(status === 'idle' || status === 'queued', reduced)}><Circle className={`size-3 text-muted-foreground ${status === 'queued' ? 'fill-muted-foreground/20' : ''}`} /></span>    <span className="absolute inset-0" style={interactionIcon(status === 'running', reduced)}><span className={`block size-3.5 rounded-full border-[1.5px] border-primary/25 border-t-primary ${status === 'running' && !reduced ? 'animate-spin' : ''}`} style={{ animationPlayState: active ? 'running' : 'paused' }} /></span>    <svg viewBox="0 0 16 16" className="absolute inset-0 size-3.5 text-primary" style={interactionIcon(status === 'complete', reduced)}><path d="m3 8 3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" pathLength={1} style={{ strokeDasharray: 1, strokeDashoffset: status === 'complete' ? 0 : 1, transition: reduced ? 'none' : 'stroke-dashoffset 380ms ease-out 80ms' }} /></svg>    <CircleAlert className="absolute inset-0 size-3.5 text-destructive" style={interactionIcon(status === 'error', reduced)} />  </span>;}
components/workflow-inspector.tsx
'use client';import * as React from 'react';import { X } from 'lucide-react';import { TextMorph } from './text-morph';import { EASE_OUT, INTERACTION_FOCUS } from './ui-interaction-motion';import { WORKFLOW_STATUS } from './workflow-status';import type { WorkflowNode } from './workflow-types';export function WorkflowInspector({ node, onClose, reduced, id }: { node?: WorkflowNode; onClose: () => void; reduced: boolean; id: string }) {  const last = React.useRef<WorkflowNode | undefined>(undefined);  if (node) last.current = node;  const displayed = node ?? last.current;  const content = React.useRef<HTMLDivElement>(null);  const measure = React.useRef<HTMLDivElement>(null);  const [height, setHeight] = React.useState(0);  React.useLayoutEffect(() => {    const el = measure.current;    if (!el) return;    const observer = new ResizeObserver(() => setHeight((previous) => previous === el.offsetHeight ? previous : el.offsetHeight));    observer.observe(el);    return () => observer.disconnect();  }, [Boolean(displayed)]);  React.useLayoutEffect(() => {    const el = content.current;    if (!el || !node || reduced) return;    const animation = el.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 300, easing: EASE_OUT });    return () => animation.cancel();  }, [node?.id, reduced]);  return <div id={id} aria-hidden={!node || undefined} inert={!node} className="overflow-hidden" style={{ height: node ? height : 0, transition: reduced ? 'none' : `height 380ms ${EASE_OUT}` }}>      {displayed && <div ref={measure} className="border-t border-border bg-card p-4">        <div className="flex items-center justify-between gap-3">          <div><p className="text-[13px] font-medium"><TextMorph>{displayed.title}</TextMorph></p><p className={`mt-0.5 text-[12px] ${displayed.status === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}><TextMorph>{WORKFLOW_STATUS[displayed.status ?? 'idle']}</TextMorph></p></div>          <button type="button" aria-label="Close node details" onClick={onClose} className={`cursor-pointer flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${INTERACTION_FOCUS}`}><X className="size-3.5" aria-hidden="true" /></button>        </div>        <div ref={content} className="mt-3 text-[13px] leading-relaxed text-muted-foreground">{displayed.detail}</div>      </div>}  </div>;}
components/use-workflow-viewport.ts
'use client';import * as React from 'react';import { NODE_WIDTH, NODE_HEIGHT, workflowPath, type WorkflowNode, type WorkflowEdge, type WorkflowPoint } from './workflow-types';type View = { x: number; y: number; scale: number };type Gesture = { pointer: number; startX: number; startY: number; origin: WorkflowPoint; node?: string; moved: boolean };export function useWorkflowViewport(nodes: WorkflowNode[], edges: WorkflowEdge[], width: number, height: number, reduced: boolean, onNodeMove?: (id: string, point: WorkflowPoint) => void) {  const viewport = React.useRef<HTMLDivElement>(null);  const world = React.useRef<HTMLDivElement>(null);  const overrides = React.useRef<Record<string, WorkflowPoint>>({});  const positions: Record<string, WorkflowPoint> = Object.fromEntries(nodes.map((node) => [node.id, overrides.current[node.id] ?? { x: node.x, y: node.y }]));  const currentPositions = React.useRef(positions);  currentPositions.current = positions;  const viewRef = React.useRef<View>({ x: 0, y: 0, scale: 1 });  const gesture = React.useRef<Gesture | null>(null);  const suppressClick = React.useRef(false);  const [view, setView] = React.useState(viewRef.current);  const [, commit] = React.useReducer((n: number) => n + 1, 0);  const [dragging, setDragging] = React.useState(false);  const paintView = (next: View, animate = false) => {    viewRef.current = next;    if (world.current) {      world.current.style.transition = animate && !reduced ? 'transform 320ms cubic-bezier(0.16,1,0.3,1)' : 'none';      world.current.style.transform = `translate(${next.x}px, ${next.y}px) scale(${next.scale})`;    }  };  const fit = (minimum = 0.2) => {    const frame = viewport.current;    if (!frame) return;    const scale = Math.max(minimum, Math.min(1.25, (frame.clientWidth - 36) / width, (frame.clientHeight - 36) / height));    const next = { x: width * scale > frame.clientWidth - 36 ? 18 : (frame.clientWidth - width * scale) / 2, y: (frame.clientHeight - height * scale) / 2, scale };    paintView(next, true); setView(next);  };  React.useLayoutEffect(() => {    const frame = viewport.current;    if (!frame) return;    const observer = new ResizeObserver(() => fit(0.85));    observer.observe(frame);    return () => observer.disconnect();    // Refit when the canvas bounds change. Status updates preserve the view.    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [width, height, reduced]);  const zoom = (factor: number) => {    const frame = viewport.current;    if (!frame) return;    const previous = viewRef.current;    const scale = Math.max(0.25, Math.min(1.75, previous.scale * factor));    const cx = frame.clientWidth / 2, cy = frame.clientHeight / 2;    const next = { scale, x: cx - (cx - previous.x) * scale / previous.scale, y: cy - (cy - previous.y) * scale / previous.scale };    paintView(next, true); setView(next);  };  const revealNode = (id: string) => {    const point = currentPositions.current[id], frame = viewport.current;    if (!point || !frame) return;    const previous = viewRef.current;    const left = point.x * previous.scale + previous.x, top = point.y * previous.scale + previous.y;    let x = previous.x, y = previous.y;    if (left < 12) x += 12 - left;    else if (left + NODE_WIDTH * previous.scale > frame.clientWidth - 12) x -= left + NODE_WIDTH * previous.scale - frame.clientWidth + 12;    if (top < 12) y += 12 - top;    else if (top + NODE_HEIGHT * previous.scale > frame.clientHeight - 12) y -= top + NODE_HEIGHT * previous.scale - frame.clientHeight + 12;    if (x !== previous.x || y !== previous.y) { const next = { ...previous, x, y }; paintView(next, true); setView(next); }  };  const paintNode = (id: string, point: WorkflowPoint) => {    currentPositions.current[id] = point;    // Keep the draft position through status renders while the pointer is down.    overrides.current[id] = point;    const node = world.current?.querySelector<HTMLElement>(`[data-workflow-node="${CSS.escape(id)}"]`);    if (node) node.style.transform = `translate(${point.x}px, ${point.y}px)`;    edges.filter((edge) => edge.from === id || edge.to === id).forEach((edge) => {      const from = currentPositions.current[edge.from], to = currentPositions.current[edge.to];      if (from && to) world.current?.querySelectorAll<SVGPathElement>(`[data-workflow-edge="${CSS.escape(edge.id)}"]`).forEach((path) => path.setAttribute('d', workflowPath(from, to)));    });  };  const moveNode = (id: string, dx: number, dy: number) => {    const point = currentPositions.current[id];    if (!point) return;    const next = { x: Math.max(0, Math.min(width - NODE_WIDTH, point.x + dx)), y: Math.max(0, Math.min(height - NODE_HEIGHT, point.y + dy)) };    paintNode(id, next); commit(); onNodeMove?.(id, next); revealNode(id);  };  /**   * While a gesture runs, the moving part gets its own compositor layer so panning and dragging move pixels instead of   * repainting them. The layer is dropped afterwards so text re-rasterises crisply at the new zoom.   */  const layer = (el: HTMLElement | null | undefined, on: boolean) => {    if (el) el.style.willChange = on ? 'transform' : '';  };  const nodeEl = (id?: string) => (id ? world.current?.querySelector<HTMLElement>(`[data-workflow-node="${CSS.escape(id)}"]`) : null);  const finish = (event: React.PointerEvent, cancelled: boolean) => {    const state = gesture.current;    if (!state || state.pointer !== event.pointerId) return;    gesture.current = null;    layer(state.node ? nodeEl(state.node) : world.current, false);    setDragging(false);    suppressClick.current = state.moved;    if (state.node) {      if (cancelled) paintNode(state.node, state.origin);      else if (state.moved) {        const point = currentPositions.current[state.node];        overrides.current[state.node] = point;        onNodeMove?.(state.node, point);      }      commit();    } else {      if (cancelled) paintView({ ...viewRef.current, ...state.origin });      setView(viewRef.current);    }  };  return {    viewport, world, positions, view, dragging, fit: () => fit(), zoom, revealNode, moveNode,    handlers: {      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {        if (event.button !== 0 || !event.isPrimary || (event.target as HTMLElement).closest('[data-workflow-control]')) return;        const target = (event.target as HTMLElement).closest<HTMLElement>('[data-workflow-node]');        const node = target?.dataset.workflowNode;        suppressClick.current = false;        (target ?? event.currentTarget).setPointerCapture(event.pointerId);        if (target) target.focus({ preventScroll: true });        gesture.current = { pointer: event.pointerId, startX: event.clientX, startY: event.clientY, node, origin: node ? { ...currentPositions.current[node] } : { x: viewRef.current.x, y: viewRef.current.y }, moved: false };      },      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {        const state = gesture.current;        if (!state || state.pointer !== event.pointerId) return;        const dx = event.clientX - state.startX, dy = event.clientY - state.startY;        if (!state.moved && Math.hypot(dx, dy) < 5) return;        if (!state.moved) { state.moved = true; setDragging(true); layer(state.node ? nodeEl(state.node) : world.current, true); }        if (state.node) paintNode(state.node, {          x: Math.max(0, Math.min(width - NODE_WIDTH, state.origin.x + dx / viewRef.current.scale)),          y: Math.max(0, Math.min(height - NODE_HEIGHT, state.origin.y + dy / viewRef.current.scale)),        });        else paintView({ ...viewRef.current, x: Math.max(-width * viewRef.current.scale + 60, Math.min((viewport.current?.clientWidth ?? width) - 60, state.origin.x + dx)), y: Math.max(-height * viewRef.current.scale + 60, Math.min((viewport.current?.clientHeight ?? height) - 60, state.origin.y + dy)) });      },      onPointerUp: (event: React.PointerEvent) => finish(event, false),      onPointerCancel: (event: React.PointerEvent) => finish(event, true),      onLostPointerCapture: (event: React.PointerEvent) => finish(event, true),      onKeyDown(event: React.KeyboardEvent) {        const state = gesture.current;        if (event.key !== 'Escape' || !state) return;        event.preventDefault(); gesture.current = null; setDragging(false); suppressClick.current = true;        layer(state.node ? nodeEl(state.node) : world.current, false);        if (state.node) { paintNode(state.node, state.origin); commit(); }        else { paintView({ ...viewRef.current, ...state.origin }); setView(viewRef.current); }      },      onClickCapture(event: React.MouseEvent) { if (suppressClick.current) { event.stopPropagation(); suppressClick.current = false; } },    },  };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/workflow-canvas.json