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,
}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,
}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 }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.
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,
}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,
}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,
}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 inlive, 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
draftkeyof State & string- The state key that holds the
ConnectDraftwhile one is drawn. Start it atnull, and list it inlive. - 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
fromcan connect toto. 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