Pan and zoom a canvas and connect its nodes.

createViewport pans and zooms a canvas, and createConnect connects its ports.

Import them from @ui-sdk/core. For moving nodes, choosing them, navigating with the keyboard, and finding them, use Dragging and Resizing, Selection, Focus and Keys, and Find in Page.

Panning and Zooming

The viewport is { x, y, zoom }. x and y place the canvas origin inside its frame, in screen pixels. zoom: 1 means its normal size.

Pan and Zoom

Start with a viewport in state and mark it as live:

state: () => ({ viewport: { x: 0, y: 0, zoom: 1 } }),
live: ["viewport"],

In api, create the behavior and return it with the rest of your API:

const viewport = createViewport(context, {
  key: "viewport",
  surface: () => context.element("root"),
})

The root is the frame; world holds everything inside it. Connect the frame's events and the world's live style:

root: slot({
  attrs: () => ({
    tabindex: "0",
    style: css({ position: "relative", overflow: "clip", "touch-action": "none" }),
  }),
  on: {
    wheel: (event, { api }) => api.viewport.wheel(event),
    pointerdown: (event, { el, api }) => {
      if (event.target === el) api.viewport.pointerdown(event)
    },
    keydown: (event, { api }) => api.viewport.keydown(event),
  },
}),
world: slot({
  attrs: () => ({ style: css({ position: "absolute", left: "0", top: "0" }) }),
  live: live((state, { api }) => ({ style: api.viewport.style(state) })),
}),

Give the frame a visible size and an accessible name in your component. One transform moves the world; the live style keeps a pan from rendering every node again.

import * as React from "react"
import {
  createViewport,
  css,
  definePrimitive,
  live,
  type Viewport,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type BoardState = { viewport: Viewport }

type BoardNoteProps = { value: string; x: number; y: number }

const home: Viewport = { x: 0, y: 0, zoom: 1 }

const board = definePrimitive({
  name: "board",
  state: (): BoardState => ({ viewport: home }),
  live: ["viewport"],
  api: (context) => {
    const viewport = createViewport<BoardState>(context, {
      key: "viewport",
      surface: () => context.element("root"),
    })
    return { viewport }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        tabindex: "0",
        "aria-roledescription": "board",
        style: css({
          position: "relative",
          overflow: "hidden",
          "touch-action": "none",
        }),
      }),
      on: {
        wheel: (event, { api }) => api.viewport.wheel(event),
        pointerdown: (event, { el, api }) =>
          event.target === el && api.viewport.pointerdown(event),
        keydown: (event, { api }) => api.viewport.keydown(event),
      },
    }),
    world: slot({
      attrs: () => ({
        style: css({ position: "absolute", left: "0", top: "0" }),
      }),
      live: live<BoardState, any, {}>((state, { api }) => ({
        style: api.viewport.style(state),
      })),
    }),
    note: slot<BoardNoteProps>({
      props: { value: "string", x: "number", y: "number" },
      attrs: (_state, { props }) => ({
        style: css({
          position: "absolute",
          left: `${props.x}px`,
          top: `${props.y}px`,
        }),
      }),
    }),
    reset: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }, event) =>
        api.viewport.setViewport(home, { reason: "imperative", event }),
    }),
  }),
})

const parts = createParts(board, {
  root: "div",
  world: "div",
  note: "div",
  reset: "button",
})

const Board = parts.Root

const BoardWorld = parts.World

const BoardNote = parts.Note

const BoardReset = parts.Reset

const notes = [
  { value: "plan", text: "Plan the launch", x: 24, y: 24 },
  { value: "copy", text: "Write the copy", x: 144, y: 80 },
  { value: "ship", text: "Ship it", x: 40, y: 136 },
  { value: "far", text: "Found me", x: 520, y: 320 },
]

function Example() {
  return (
    <Board aria-label="Board">
      <BoardWorld>
        {notes.map((note) => (
          <BoardNote key={note.value} value={note.value} x={note.x} y={note.y}>
            {note.text}
          </BoardNote>
        ))}
      </BoardWorld>
      <BoardReset data-size="sm">Reset View</BoardReset>
    </Board>
  )
}

export {
  Board,
  board,
  BoardNote,
  BoardReset,
  BoardWorld,
  Example,
  type BoardNoteProps,
  type BoardState,
}
Drag the empty board or scroll to pan. Pinch, or hold Control or Command while scrolling, to zoom. Focus the board and try +, โˆ’, and 0; Reset View restores its starting position.

0 resets the scale to 100%. To reset the position too, call viewport.setViewport({ x: 0, y: 0, zoom: 1 }).

Fit, Reveal and Zoom from Code

Use fit to show a rectangle in full. Its coordinates are in canvas units:

viewport.fit(
  { left: 0, top: 0, right: 800, bottom: 600 },
  { padding: 16, maxZoom: 1 }
)
viewport.zoomBy(1.25)
viewport.panBy(80, 0)

zoomBy and zoomTo use the center of the frame unless you pass a point. panBy takes screen pixels. Set minZoom and maxZoom in the behavior's options to limit the scale; the defaults are 0.1 and 4.

When a node receives focus, reveal brings it into view. rectOf reads an element's rectangle in canvas units:

sight: slot({
  on: {
    focus: (_event, { el, api }) => {
      if (el) api.viewport.reveal(api.viewport.rectOf(el), { padding: 16 })
    },
  },
}),

Use overflow: clip on the frame so native focus scrolling doesn't compete with the viewport, or let the viewport take those scrolls with bindFindable. The message option customizes what a zoom key announces:

message: (zoom) => `Map at ${Math.round(zoom * 100)} percent`,
import * as React from "react"
import {
  ArrowLeftIcon,
  ArrowRightIcon,
  MapPinIcon,
  ZoomInIcon,
  ZoomOutIcon,
} from "lucide-react"
import {
  createViewport,
  css,
  definePrimitive,
  live,
  type Viewport,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Sight = { value: string; label: string; x: number; y: number }

type MapState = { viewport: Viewport; sights: Sight[] }

const pin = { width: 96, height: 28 }

const sights: Sight[] = [
  { value: "museum", label: "Museum", x: 24, y: 24 },
  { value: "park", label: "Park", x: 260, y: 120 },
  { value: "market", label: "Market", x: 80, y: 260 },
  { value: "tower", label: "Tower", x: 520, y: 60 },
  { value: "harbor", label: "Harbor", x: 640, y: 380 },
]

const map = definePrimitive({
  name: "map",
  state: (): MapState => ({ viewport: { x: 0, y: 0, zoom: 1 }, sights }),
  live: ["viewport"],
  api: (context) => {
    const { store } = context
    const viewport = createViewport<MapState>(context, {
      key: "viewport",
      surface: () => context.element("frame"),
      minZoom: () => 0.25,
      maxZoom: () => 2,
      message: (zoom) => `Map at ${Math.round(zoom * 100)} percent`,
    })
    const bounds = () => {
      const all = store.get().sights
      return {
        left: Math.min(...all.map((sight) => sight.x)),
        top: Math.min(...all.map((sight) => sight.y)),
        right: Math.max(...all.map((sight) => sight.x + pin.width)),
        bottom: Math.max(...all.map((sight) => sight.y + pin.height)),
      }
    }
    return {
      viewport,
      act(action: string) {
        if (action === "fit") {
          viewport.fit(bounds(), { padding: 16, maxZoom: 1 })
        } else if (action === "in") {
          viewport.zoomBy(1.25)
        } else if (action === "out") {
          viewport.zoomBy(1 / 1.25)
        } else if (action === "reset") {
          viewport.zoomTo(1)
        } else if (action === "left") {
          viewport.panBy(80, 0)
        } else if (action === "right") {
          viewport.panBy(-80, 0)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    frame: slot({
      attrs: () => ({
        tabindex: "0",
        "aria-roledescription": "map",
        style: css({
          position: "relative",
          overflow: "clip",
          "touch-action": "none",
        }),
      }),
      on: {
        wheel: (event, { api }) => api.viewport.wheel(event),
        pointerdown: (event, { el, api }) =>
          event.target === el && api.viewport.pointerdown(event),
        keydown: (event, { api }) => api.viewport.keydown(event),
      },
    }),
    world: slot({
      attrs: () => ({
        style: css({ position: "absolute", left: "0", top: "0" }),
      }),
      live: live<MapState, any, {}>((state, { api }) => ({
        style: api.viewport.style(state),
      })),
    }),
    sight: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => {
        const sight = state.sights.find((other) => other.value === key)!
        return {
          type: "button",
          style: css({
            position: "absolute",
            left: `${sight.x}px`,
            top: `${sight.y}px`,
            width: `${pin.width}px`,
            height: `${pin.height}px`,
          }),
        }
      },
      on: {
        focus: (_event, { el, api }) =>
          api.viewport.reveal(api.viewport.rectOf(el!), { padding: 16 }),
      },
    }),
    zoom: slot({
      live: live<MapState, any, {}>((state) => ({
        "data-zoom": `${Math.round(state.viewport.zoom * 100)}%`,
      })),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(map, {
  root: "div",
  frame: "div",
  world: "div",
  sight: "button",
  zoom: "span",
  action: "button",
})

const SightsMap = parts.Root

const SightsMapFrame = parts.Frame

const SightsMapWorld = parts.World

const SightsMapSight = parts.Sight

const SightsMapZoom = parts.Zoom

const SightsMapAction = parts.Action

function Example() {
  return (
    <SightsMap>
      <SightsMapFrame aria-label="Sights">
        <SightsMapWorld>
          {sights.map((sight) => (
            <SightsMapSight key={sight.value} value={sight.value}>
              <MapPinIcon aria-hidden="true" />
              {sight.label}
            </SightsMapSight>
          ))}
        </SightsMapWorld>
      </SightsMapFrame>
      <div data-slot="row">
        <SightsMapAction data-size="sm" value="fit">
          Fit
        </SightsMapAction>
        <SightsMapAction data-size="sm" value="out" aria-label="Zoom out">
          <ZoomOutIcon aria-hidden="true" />
        </SightsMapAction>
        <SightsMapAction data-size="sm" value="in" aria-label="Zoom in">
          <ZoomInIcon aria-hidden="true" />
        </SightsMapAction>
        <SightsMapAction data-size="sm" value="reset">
          100%
        </SightsMapAction>
        <SightsMapAction data-size="sm" value="left" aria-label="Pan left">
          <ArrowLeftIcon aria-hidden="true" />
        </SightsMapAction>
        <SightsMapAction data-size="sm" value="right" aria-label="Pan right">
          <ArrowRightIcon aria-hidden="true" />
        </SightsMapAction>
        <SightsMapZoom />
      </div>
    </SightsMap>
  )
}

export {
  Example,
  map,
  type MapState,
  type Sight,
  SightsMap,
  SightsMapAction,
  SightsMapFrame,
  SightsMapSight,
  SightsMapWorld,
  SightsMapZoom,
}
Press Fit to see every sight. Try the zoom and pan buttons, then Tab through the sights: the map brings each focused sight into view.

Find in Page

Find in page (โŒ˜F, or Ctrl+F) finds text anywhere on the canvas, but it reveals a match by scrolling, and the viewport pans instead. Put the world in a pane that fills the frame, and bind the pane with bindFindable:

pane: slot({
  attrs: () => ({
    style: css({
      position: "absolute",
      inset: "0",
      overflow: "scroll",
      "scrollbar-width": "none",
      "touch-action": "none",
      "pointer-events": "none",
    }),
  }),
  bind: (el, { api }) => api.viewport.bindFindable(el),
}),
world: slot({
  attrs: () => ({ style: css({ position: "absolute", left: "0", top: "0", "pointer-events": "auto" }) }),
  live: live((state, { api }) => ({ style: api.viewport.style(state) })),
}),

The pane must be one a user could scroll, with overflow: scroll, because find in page doesn't scroll overflow: hidden; scrollbar-width: none hides its scrollbars. Keep the frame's overflow: clip, and keep controls like a reset button in the frame, outside the pane. The pane passes presses and the wheel on empty space through to the frame, so its handlers work as before.

Every scroll the browser makes in the pane becomes a pan with the reason find-in-page, so the match comes into view the way the browser would show it, with its own highlight and count. The same goes for anything else that scrolls to an element, like scrollIntoView or a link to its id; Tab to a node out of view pans with focus-in.

The pane rests scrolled far from its origin, with an empty element to make room, so the browser can scroll toward a node whichever side of the view it's on. The world's style moves to match while it's bound; anything else in the pane would scroll away, which is why it holds only the world. A smooth scrollIntoView pans by its first step only; to animate a pan, call reveal.

import * as React from "react"
import {
  createViewport,
  css,
  definePrimitive,
  live,
  type Viewport,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type MapState = { viewport: Viewport }

type MapNoteProps = { value: string; x: number; y: number }

const home: Viewport = { x: 0, y: 0, zoom: 1 }

const map = definePrimitive({
  name: "map",
  state: (): MapState => ({ viewport: home }),
  live: ["viewport"],
  api: (context) => {
    const viewport = createViewport<MapState>(context, {
      key: "viewport",
      surface: () => context.element("root"),
    })
    return { viewport }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        tabindex: "0",
        "aria-roledescription": "map",
        style: css({
          position: "relative",
          overflow: "clip",
          "touch-action": "none",
        }),
      }),
      on: {
        wheel: (event, { api }) => api.viewport.wheel(event),
        pointerdown: (event, { el, api }) =>
          event.target === el && api.viewport.pointerdown(event),
        keydown: (event, { api }) => api.viewport.keydown(event),
      },
    }),
    pane: slot({
      attrs: () => ({
        style: css({
          position: "absolute",
          inset: "0",
          overflow: "scroll",
          "scrollbar-width": "none",
          "touch-action": "none",
          "pointer-events": "none",
        }),
      }),
      bind: (el, { api }) => api.viewport.bindFindable(el),
    }),
    world: slot({
      attrs: () => ({
        style: css({
          position: "absolute",
          left: "0",
          top: "0",
          "pointer-events": "auto",
        }),
      }),
      live: live<MapState, any, {}>((state, { api }) => ({
        style: api.viewport.style(state),
      })),
    }),
    note: slot<MapNoteProps>({
      props: { value: "string", x: "number", y: "number" },
      attrs: (_state, { props }) => ({
        style: css({
          position: "absolute",
          left: `${props.x}px`,
          top: `${props.y}px`,
        }),
      }),
    }),
    reset: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }, event) =>
        api.viewport.setViewport(home, { reason: "imperative", event }),
    }),
  }),
})

const parts = createParts(map, {
  root: "div",
  pane: "div",
  world: "div",
  note: "div",
  reset: "button",
})

const notes = [
  { value: "harbor", text: "Harbor", x: 112, y: 88 },
  { value: "east", text: "East lighthouse", x: 760, y: 96 },
  { value: "south", text: "South lighthouse", x: 120, y: 640 },
  { value: "west", text: "West lighthouse", x: -560, y: 80 },
  { value: "north", text: "North lighthouse", x: 104, y: -480 },
]

function Example() {
  return (
    <div data-slot="stack">
      <p data-slot="hint">
        Search <span data-slot="viewport-term">lighthouse</span> with โŒ˜F or
        Ctrl+F
      </p>
      <parts.Root aria-label="Map">
        <parts.Pane>
          <parts.World>
            {notes.map((note) => (
              <parts.Note
                key={note.value}
                value={note.value}
                x={note.x}
                y={note.y}
              >
                {note.text}
              </parts.Note>
            ))}
          </parts.World>
        </parts.Pane>
        <parts.Reset data-size="sm">Reset View</parts.Reset>
      </parts.Root>
    </div>
  )
}

export { Example }
Press โŒ˜F or Ctrl+F and search for lighthouse. Each one is off the map in a different direction; press Enter to go from one to the next, and the map pans to each.

Screen and Canvas Points

A pointer event gives you screen coordinates. Convert them before placing something on the canvas:

const point = viewport.toCanvas(event.clientX, event.clientY)

toScreen(point) converts back to client coordinates, useful for a label outside the scaled world. visible() gives the rectangle currently in view, and get() returns the viewport.

By default, the behavior writes its state key directly. To let a component control the viewport, use the change pattern with set:

set: (value, details) => change("viewport", value, details),

Changes report a reason: wheel, drag, pinch, zoom-key, focus-in, find-in-page, or imperative.

Console
import * as React from "react"
import {
  createChange,
  createViewport,
  css,
  definePrimitive,
  live,
  type ChangeDetails,
  type Viewport,
  type ViewportReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type Point = { x: number; y: number }

type DotsState = { viewport: Viewport; dots: Point[] }

type DotsOptions = {
  viewport?: Viewport
  onViewportChange?: (
    viewport: Viewport,
    details: ChangeDetails<ViewportReason>
  ) => void
}

const round = (point: Point) => `${Math.round(point.x)}, ${Math.round(point.y)}`

const dots = definePrimitive({
  name: "dots",
  options: { viewport: "value", onViewportChange: "callback" },
  state: (options: DotsOptions): DotsState => ({
    viewport: options.viewport ?? { x: 0, y: 0, zoom: 1 },
    dots: [],
  }),
  sync: (options) => (options.viewport ? { viewport: options.viewport } : {}),
  live: ["viewport"],
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const viewport = createViewport<DotsState>(context, {
      key: "viewport",
      surface: () => context.element("root"),
      set: (value, details) => change("viewport", value, details),
    })
    return {
      viewport,
      drop(event: MouseEvent) {
        store.set({
          dots: [
            ...store.get().dots,
            viewport.toCanvas(event.clientX, event.clientY),
          ],
        })
      },
      labelAt(state: DotsState) {
        const surface = context.element("root")
        const dot = state.dots.at(-1)
        if (!surface || !dot) {
          return undefined
        }
        const box = surface.getBoundingClientRect()
        const at = viewport.toScreen(dot)
        return { x: at.x - box.left, y: at.y - box.top }
      },
      inView() {
        const { left, top, right, bottom } = viewport.visible()
        return `${round({ x: left, y: top })} to ${round({ x: right, y: bottom })}`
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { dots: Point[] }>({
      state: (state) => ({ dots: state.dots }),
      attrs: () => ({
        tabindex: "0",
        "aria-roledescription": "board",
        style: css({
          position: "relative",
          overflow: "clip",
          "touch-action": "none",
        }),
      }),
      on: {
        wheel: (event, { api }) => api.viewport.wheel(event),
        pointerdown: (event, { el, api }) =>
          event.target === el && api.viewport.pointerdown(event),
        keydown: (event, { api }) => api.viewport.keydown(event),
        dblclick: (event, { el, api }) =>
          event.target === el && api.drop(event),
      },
    }),
    world: slot({
      attrs: () => ({
        style: css({
          position: "absolute",
          left: "0",
          top: "0",
          "pointer-events": "none",
        }),
      }),
      live: live<DotsState, any, {}>((state, { api }) => ({
        style: api.viewport.style(state),
      })),
    }),
    label: slot({
      live: live<DotsState, any, {}>((state, { api }) => {
        const at = api.labelAt(state)
        const dot = state.dots.at(-1)
        return at && dot
          ? {
              "data-at": round(dot),
              style: css({
                position: "absolute",
                left: `${at.x}px`,
                top: `${at.y}px`,
                "pointer-events": "none",
              }),
            }
          : { hidden: "" }
      }),
    }),
    view: slot({
      live: live<DotsState, any, {}>((state, { api }) => ({
        "data-view": state.viewport && api.inView(),
      })),
    }),
  }),
})

const parts = createParts(dots, {
  root: "div",
  world: "div",
  label: "span",
  view: "span",
})

const Dots = parts.Root

const DotsWorld = parts.World

const DotsLabel = parts.Label

const DotsView = parts.View

function Example() {
  const [viewport, setViewport] = React.useState<Viewport>({
    x: 0,
    y: 0,
    zoom: 1,
  })
  const log = useLog()
  const last = React.useRef("")
  return (
    <Dots
      aria-label="Dots"
      viewport={viewport}
      onViewportChange={(next, details) => {
        setViewport(next)
        const message = `Moved by ${details.reason}, zoom ${Math.round(next.zoom * 100)}%`
        if (message !== last.current) {
          log(message)
        }
        last.current = message
      }}
    >
      {({ dots }) => (
        <>
          <DotsWorld>
            {dots.map((dot, index) => (
              <span
                key={index}
                data-slot="viewport-dot"
                style={{ left: dot.x, top: dot.y }}
              />
            ))}
          </DotsWorld>
          <DotsView />
          <DotsLabel />
        </>
      )}
    </Dots>
  )
}

export {
  Dots,
  dots,
  DotsLabel,
  type DotsOptions,
  type DotsState,
  DotsView,
  DotsWorld,
  Example,
  type Point,
}
Double-click to add a dot. Pan or zoom, then add another: it lands where you click. The newest dot's label keeps its size while the canvas scales.

Connections

createConnect follows a line from one port to another. You decide which pairs can connect and what to save when they do.

Draw a Connection

Add a connecting field to state, initially null as ConnectDraft | null, and list it in live. Then create the behavior in api:

const connect = createConnect(context, {
  slot: "port",
  draft: "connecting",
  canConnect: (from, to) => from.endsWith(":out") && to.endsWith(":in"),
  point: (x, y) => viewport.toCanvas(x, y),
  connect: (from, to, details) =>
    change("edges", [...context.store.get().edges, { from, to }], details),
})

Here, port keys end in :out or :in. canConnect accepts only output-to-input pairs. Add your own checks there, such as refusing two ports on the same node. point puts the loose end of the line in canvas coordinates.

Give each port a value, such as "prompt:out" or "answer:in", and connect its pointer handler. role gives you source, target, or candidate for styling:

port: slot<{ value: string }>({
  props: { value: "string" },
  live: live((state, { api, key }) => ({ "data-connect": api.connect.role(state, key) })),
  on: {
    pointerdown: (event, { el, api, key }) => {
      if (el) api.connect.pointerdown(event, key, el)
    },
  },
}),

While drawing, connecting holds { from, to, x, y, keyboard }. Render that draft alongside the saved edges, using x and y for its loose end when to is null.

For keyboard access, call connect.start(portKey) from an action on the node, then pass its key events to connect.keydown(event). Arrows choose a valid port in that direction; Enter or Space connects; Escape or Tab cancels.

import * as React from "react"
import {
  createChange,
  createConnect,
  css,
  definePrimitive,
  live,
  type ChangeDetails,
  type ConnectDraft,
  type ConnectReason,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"

type FlowNode = { value: string; label: string; x: number; y: number }

type FlowEdge = { from: string; to: string }

type FlowState = {
  nodes: FlowNode[]
  edges: FlowEdge[]
  connecting: ConnectDraft | null
}

type FlowOptions = {
  nodes: FlowNode[]
  onEdgesChange?: (
    edges: FlowEdge[],
    details: ChangeDetails<ConnectReason>
  ) => void
}

const size = { width: 112, height: 40 }

const nodeOf = (port: string) => port.slice(0, port.lastIndexOf(":"))

function portPoint(nodes: FlowNode[], port: string) {
  const node = nodes.find((other) => other.value === nodeOf(port))!
  return {
    x: node.x + (port.endsWith(":out") ? size.width : 0),
    y: node.y + size.height / 2,
  }
}

function curve(from: { x: number; y: number }, to: { x: number; y: number }) {
  const reach = Math.max(30, Math.hypot(to.x - from.x, to.y - from.y) / 2)
  return `M${from.x},${from.y} C${from.x + reach},${from.y} ${to.x - reach},${to.y} ${to.x},${to.y}`
}

const flow = definePrimitive({
  name: "flow",
  options: { nodes: "value", onEdgesChange: "callback" },
  state: (options: FlowOptions): FlowState => ({
    nodes: options.nodes,
    edges: [],
    connecting: null,
  }),
  live: ["connecting"],
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const connect = createConnect<FlowState>(context, {
      slot: "port",
      draft: "connecting",
      canConnect: (from, to) =>
        from.endsWith(":out") &&
        to.endsWith(":in") &&
        nodeOf(from) !== nodeOf(to) &&
        !store.get().edges.some((edge) => edge.from === from && edge.to === to),
      point: (clientX, clientY) => {
        const box = context.element("root")!.getBoundingClientRect()
        return { x: clientX - box.left, y: clientY - box.top }
      },
      label: (port) =>
        store.get().nodes.find((node) => node.value === nodeOf(port))!.label,
      connect: (from, to, details) =>
        change("edges", [...store.get().edges, { from, to }], details),
    })
    return {
      connect,
      nodeKeydown(event: KeyboardEvent, node: string) {
        if (connect.keydown(event)) {
          return
        }
        if (event.key.toLowerCase() !== "c" || event.ctrlKey || event.metaKey) {
          return
        }
        event.preventDefault()
        connect.start(`${node}:out`)
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { edges: FlowEdge[] }>({
      state: (state) => ({ edges: state.edges }),
      attrs: () => ({ style: css({ position: "relative" }) }),
    }),
    node: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => {
        const node = state.nodes.find((other) => other.value === key)!
        const to = state.edges
          .filter((edge) => nodeOf(edge.from) === key)
          .map(
            (edge) =>
              state.nodes.find((other) => other.value === nodeOf(edge.to))!
                .label
          )
        return {
          role: "button",
          tabindex: "0",
          "aria-description": to.length
            ? `Connected to ${to.join(", ")}.`
            : undefined,
          style: css({
            position: "absolute",
            left: `${node.x}px`,
            top: `${node.y}px`,
            width: `${size.width}px`,
            height: `${size.height}px`,
          }),
        }
      },
      on: { keydown: (event, { api, key }) => api.nodeKeydown(event, key) },
    }),
    port: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, { key }) => ({
        "aria-hidden": "true",
        "data-side": key.endsWith(":out") ? "right" : "left",
        style: "touch-action:none",
      }),
      live: live<FlowState, any, { value: string }>((state, { api, key }) => ({
        "data-connect": api.connect.role(state, key),
      })),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.connect.pointerdown(event, key, el!),
      },
    }),
    edge: slot<{ value: string; from: string; to: string }>({
      props: { value: "string", from: "string", to: "string" },
      attrs: (state, { props }) => ({
        d: curve(
          portPoint(state.nodes, props.from),
          portPoint(state.nodes, props.to)
        ),
      }),
    }),
    draft: slot({
      live: live<FlowState, any, {}>((state) => {
        const draft = state.connecting
        if (!draft) {
          return { d: "" }
        }
        const end = draft.to ? portPoint(state.nodes, draft.to) : draft
        return { d: curve(portPoint(state.nodes, draft.from), end) }
      }),
    }),
  }),
})

const parts = createParts(flow, {
  root: "div",
  node: "div",
  port: "span",
  draft: "path",
})

const Flow = parts.Root

const FlowNode = parts.Node

const FlowPort = parts.Port

function FlowEdgePath({
  edge,
  ...props
}: { edge: FlowEdge } & PartProps<"path">) {
  return usePart(
    flow,
    "edge",
    { value: `${edge.from}>${edge.to}`, from: edge.from, to: edge.to },
    props,
    "path"
  )
}

const FlowDraft = parts.Draft

const nodes: FlowNode[] = [
  { value: "prompt", label: "Prompt", x: 8, y: 70 },
  { value: "answer", label: "Answer", x: 176, y: 16 },
  { value: "summary", label: "Summary", x: 176, y: 124 },
]

function Example() {
  return (
    <Flow nodes={nodes}>
      {({ edges }) => (
        <>
          <svg data-slot="connect-edges">
            {edges.map((edge) => (
              <FlowEdgePath key={`${edge.from}>${edge.to}`} edge={edge} />
            ))}
            <FlowDraft />
          </svg>
          {nodes.map((node) => (
            <FlowNode
              key={node.value}
              value={node.value}
              aria-label={node.label}
            >
              {node.label}
              {node.value === "prompt" ? (
                <FlowPort value="prompt:out" />
              ) : (
                <FlowPort value={`${node.value}:in`} />
              )}
            </FlowNode>
          ))}
        </>
      )}
    </Flow>
  )
}

export {
  Example,
  flow,
  Flow,
  FlowDraft,
  FlowEdgePath,
  FlowNode,
  FlowPort,
  type FlowEdge,
  type FlowOptions,
  type FlowState,
}
Drag from Prompt's output to an input on Answer or Summary. Or focus Prompt, press C, choose an input with the arrow keys, and press Enter. Escape cancels.

The behavior announces each step. Give it a label function for readable port names. If the ports are hidden from assistive technology, keep the node's keyboard action accessible and describe its connections in text, such as aria-description="Connected to Answer.".

Snapping, Words and Code

radius controls how close the pointer must get before a port becomes the target. It uses screen pixels, so it stays consistent at different zoom levels. The default is 28.

Override message when the default announcements don't fit. connectMessage supplies the defaults for the other steps:

radius: () => 48,
message: (message, label) =>
  message.type === "connect"
    ? `${label(message.from)} into ${label(message.to)}.`
    : connectMessage(message, label),

You can also connect from code:

if (connect.start("synth")) {
  connect.target("ch3")
  connect.commit({ reason: "connect-key" })
}

active() tells you whether a connection is underway; cancel() clears its draft. The connect callback decides what to save. In this patch bay, connecting an existing pair removes its cable.

import * as React from "react"
import {
  connectMessage,
  createChange,
  createConnect,
  css,
  definePrimitive,
  live,
  type ConnectDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Cable = { from: string; to: string }

type BayState = { cables: Cable[]; drawing: ConnectDraft | null }

const sources = ["mic", "guitar", "synth"]
const channels = ["ch1", "ch2", "ch3"]
const names: Record<string, string> = {
  mic: "Mic",
  guitar: "Guitar",
  synth: "Synth",
  ch1: "Channel 1",
  ch2: "Channel 2",
  ch3: "Channel 3",
}

function jack(port: string) {
  const source = sources.includes(port)
  const row = (source ? sources : channels).indexOf(port)
  return { x: source ? 104 : 184, y: 20 + row * 48 }
}

function curve(from: { x: number; y: number }, to: { x: number; y: number }) {
  const reach = Math.max(30, Math.hypot(to.x - from.x, to.y - from.y) / 2)
  return `M${from.x},${from.y} C${from.x + reach},${from.y} ${to.x - reach},${to.y} ${to.x},${to.y}`
}

const bay = definePrimitive({
  name: "bay",
  state: (): BayState => ({ cables: [], drawing: null }),
  live: ["drawing"],
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const connect = createConnect<BayState>(context, {
      slot: "jack",
      draft: "drawing",
      canConnect: (from, to) => sources.includes(from) && channels.includes(to),
      point: (clientX, clientY) => {
        const box = context.element("root")!.getBoundingClientRect()
        return { x: clientX - box.left, y: clientY - box.top }
      },
      radius: () => 48,
      label: (port) => names[port],
      message: (message, label) =>
        message.type === "connect"
          ? `${label(message.from)} into ${label(message.to)}.`
          : connectMessage(message, label),
      connect: (from, to, details) => {
        const { cables } = store.get()
        const patched = cables.some(
          (cable) => cable.from === from && cable.to === to
        )
        const next = patched
          ? cables.filter((cable) => cable.from !== from || cable.to !== to)
          : [...cables, { from, to }]
        return change("cables", next, details)
      },
    })
    return {
      connect,
      sourceKeydown(event: KeyboardEvent, source: string) {
        if (connect.keydown(event)) {
          return
        }
        if (event.key !== "Enter") {
          return
        }
        event.preventDefault()
        connect.start(source)
      },
      act(action: string) {
        if (action === "cancel") {
          return connect.active() && connect.cancel()
        }
        const [from, to] = action.split("-")
        if (connect.start(from)) {
          connect.target(to)
          connect.commit({ reason: "connect-key" })
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { cables: Cable[] }>({
      state: (state) => ({ cables: state.cables }),
      attrs: () => ({ style: css({ position: "relative" }) }),
    }),
    source: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => ({
        type: "button",
        "aria-description": state.cables.some((cable) => cable.from === key)
          ? `Into ${state.cables
              .filter((cable) => cable.from === key)
              .map((cable) => names[cable.to])
              .join(", ")}.`
          : undefined,
      }),
      on: { keydown: (event, { api, key }) => api.sourceKeydown(event, key) },
    }),
    jack: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, { key }) => {
        const { x, y } = jack(key)
        return {
          "aria-hidden": "true",
          style: css({
            position: "absolute",
            left: `${x}px`,
            top: `${y}px`,
            "touch-action": "none",
          }),
        }
      },
      live: live<BayState, any, { value: string }>((state, { api, key }) => ({
        "data-connect": api.connect.role(state, key),
      })),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.connect.pointerdown(event, key, el!),
      },
    }),
    cable: slot<{ value: string; from: string; to: string }>({
      props: { value: "string", from: "string", to: "string" },
      attrs: (_state, { props }) => ({
        d: curve(jack(props.from), jack(props.to)),
      }),
    }),
    draft: slot({
      live: live<BayState, any, {}>((state) => {
        const draft = state.drawing
        if (!draft) {
          return { d: "" }
        }
        return { d: curve(jack(draft.from), draft.to ? jack(draft.to) : draft) }
      }),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(bay, {
  root: "div",
  source: "button",
  jack: "span",
  cable: "path",
  draft: "path",
  action: "button",
})

const Bay = parts.Root

const BaySource = parts.Source

const BayJack = parts.Jack

const BayCable = parts.Cable

const BayDraft = parts.Draft

const BayAction = parts.Action

function Example() {
  return (
    <Bay>
      {({ cables }) => (
        <>
          <div data-slot="connect-canvas">
            <svg data-slot="connect-edges">
              {cables.map((cable) => (
                <BayCable
                  key={`${cable.from}-${cable.to}`}
                  value={`${cable.from}-${cable.to}`}
                  from={cable.from}
                  to={cable.to}
                />
              ))}
              <BayDraft />
            </svg>
            {sources.map((source, index) => (
              <BaySource
                key={source}
                value={source}
                style={{ "--row": index } as React.CSSProperties}
              >
                {names[source]}
              </BaySource>
            ))}
            {channels.map((channel, index) => (
              <span
                key={channel}
                data-slot="connect-channel"
                style={{ "--row": index } as React.CSSProperties}
              >
                {names[channel]}
              </span>
            ))}
            {[...sources, ...channels].map((port) => (
              <BayJack key={port} value={port} />
            ))}
          </div>
          <div data-slot="row">
            <BayAction data-size="sm" value="synth-ch3">
              Synth into 3
            </BayAction>
            <BayAction data-size="sm" value="cancel">
              Cancel
            </BayAction>
          </div>
        </>
      )}
    </Bay>
  )
}

export {
  Bay,
  bay,
  BayAction,
  BayCable,
  BayDraft,
  BayJack,
  BaySource,
  type BayState,
  type Cable,
  Example,
}
Drag a source jack toward a channel, then connect the same pair again to unplug it. Try Synth into 3, or focus a source and press Enter to start; use arrows to choose, Enter to connect, and Escape to cancel.

Accessibility

Give every pointer action a keyboard path and explain it beside the canvas. Bring focused nodes into view, and describe connections in words as well as lines.

For a full editor, combine these helpers with moving in two dimensions, selection and spatial focus. Testing covers keyboard and accessibility checks for the result.

API Reference

keykeyof State & string
The state key that holds the Viewport. List it in live, so panning renders nothing.
Example: Pan and Zoom
surface() => HTMLElement | null
The element that clips the canvas and takes the gestures.
Example: Pan and Zoom
set?((value: Viewport, details: { reason: ViewportReason; event?: Event; }) => boolean)
Asks for a new viewport. Defaults to setting the state key; pass the primitive's change to make it controllable.
Example: Screen and Canvas Points
minZoom?(() => number)
Example: Fit, Reveal and Zoom from Code
maxZoom?(() => number)
Example: Fit, Reveal and Zoom from Code
message?((zoom: number) => string)
What screen readers hear after a zoom key. Defaults to English, like "Zoom 150%".
Example: Fit, Reveal and Zoom from Code
slotstring
The slot whose parts are ports.
Example: Draw a Connection
draftkeyof State & string
The state key that holds the ConnectDraft while one is drawn. Start it at null, and list it in live.
Example: Draw a Connection
connect(from: string, to: string, details: { reason: ConnectReason; event?: Event; }) => boolean
Makes the connection and returns whether it went through.
Example: Draw a Connection
canConnect?((from: string, to: string) => boolean)
Whether from can connect to to. Defaults to any other port.
Example: Draw a Connection
point?((clientX: number, clientY: number) => { x: number; y: number; })
Turns a point on screen into your units, like a canvas viewport's toCanvas. Defaults to client pixels.
Example: Draw a Connection
radius?(() => number)
How near, in screen pixels, a pointer must come to a port to snap to it.
Example: Snapping, Words and Code
label?((key: string) => string)
A port's name for screen readers. Defaults to its key.
Example: Draw a Connection
message?((message: ConnectMessage, label: (key: string) => string) => string)
What screen readers hear. Defaults to English.
Example: Snapping, Words and Code