DesignCanvas
design-canvasInfinite pan-and-zoom canvas with a selection box, drag to move, and eight-way resize.
Usage
Basic usage
Controlled items: drag to move, drag any of the eight handles to resize, click empty space to deselect; Tab walks between elements, arrow keys nudge, Delete removes.
const [items, setItems] = useState(initial);
const [selected, setSelected] = useState<string | null>("hero");
<div className="h-[360px] w-full overflow-hidden rounded border border-border">
<DesignCanvas
items={items}
onItemsChange={setItems}
selectedElement={selected}
onSelect={setSelected}
onItemDelete={(id) => setItems((p) => p.filter((i) => i.id !== id))}
renderItem={(item) => <div className="h-full w-full …">{item.label}</div>}
/>
</div>Grid snapping
grid draws the backdrop and snap sets the step; they are independent, so you can draw without snapping, or snap to a step finer than the backdrop.
<DesignCanvas items={items} onItemsChange={setItems} grid={20} snap={20} … />Locked element
A locked element can still be selected and reached with Tab, but it cannot be dragged and shows no resize handles.
<DesignCanvas
items={[{ id: "hero", x: 40, y: 32, width: 260, height: 120, locked: true }, …]}
onItemsChange={setItems}
/>Controlled viewport + custom layer
Controlling zoom / pan lets an external toolbar drive the canvas; children mount straight into the world layer, following pan and zoom while owning their own geometry.
const [zoom, setZoom] = useState(0.8);
const [pan, setPan] = useState({ x: 0, y: 0 });
<DesignCanvas
items={items}
onItemsChange={setItems}
zoom={zoom}
onZoomChange={setZoom}
pan={pan}
onPanChange={setPan}
>
<div className="pointer-events-none absolute left-[40px] top-[8px] text-[10px] text-muted">
Board A · 320×280
</div>
</DesignCanvas>When to use
Use DesignCanvas to arrange rectangles freely on an infinite surface: page drafts, boards, poster or slide layouts, and the canvas area of a low-code visual editor. Position and size are the data; there is no "what connects to what" topology.
- To orchestrate nodes and edges (AI workflows, DAGs, flowcharts) use Flow. Flow owns connection handles, Bezier edges, and topological auto layout; DesignCanvas draws no edges at all.
- To scale one fixed design size to fill a container (big-screen dashboards) use FitScreen. It has no panning, no selection, and no editing.
- For cards moving between columns use Kanban; for one reorderable list use Sortable.
DesignCanvas reuses Flow's viewport math (screenToCanvas, zoomAtPoint, clampZoom), so both canvases share the same wheel feel and coordinate convention and can be mixed on one page without a behavioural mismatch.
Import
import { DesignCanvas, canvasToScreen, itemsBounds, moveRect, normalizeRect, resizeRect, snapTo } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| items | DesignCanvasItem[] | [] | Controlled { id, x, y, width, height, locked?, label? } elements whose geometry the canvas owns. |
| zoom | number | — | Controlled zoom. When present the canvas only reports onZoomChange. |
| defaultZoom | number | 1 | Initial zoom when uncontrolled. |
| pan | { x, y } | — | Controlled pan: the screen-pixel offset of the canvas origin inside the container. |
| defaultPan | { x, y } | { x: 0, y: 0 } | Initial pan when uncontrolled. |
| selectedElement | string | null | — | Controlled selection (element id or path). |
| defaultSelectedElement | string | null | null | Initial selection when uncontrolled. |
| minZoom | number | 0.1 | Lower zoom bound. |
| maxZoom | number | 4 | Upper zoom bound. |
| grid | boolean | number | true | Grid backdrop: true means 40 canvas units, a number sets the cell size, false disables it. |
| snap | number | 0 | Snap step in canvas units for dragging, resizing, and arrow nudges. 0 disables snapping. |
| minItemSize | number | 8 | Minimum element width and height in canvas units. |
| wheelBehavior | "zoom" | "pan" | "zoom" | Default wheel action; holding Ctrl or Cmd inverts it. |
| controls | boolean | true | Show the zoom toolbar in the bottom-right corner. |
| readOnly | boolean | false | Disable dragging, resizing, and deletion while keeping selection, panning, and zooming. |
| className | string | — | Outer class name. It must have a definite height; the canvas fills it. |
| labels | Partial\<DesignCanvasLabels\> | — | Overrides the copy taken from the locale (canvas, item, zoomIn, zoomOut, fitView, resetView). Omit it and the canvas follows ConfigProvider. |
| apiRef | MutableRefObject\<DesignCanvasApi | null\> | — | Imperative handle (zoomIn, zoomOut, reset, fitView, screenToCanvas). |
Events
| Event | Type | Description |
|---|---|---|
| onItemsChange | (items: DesignCanvasItem[]) => void | Emits the whole new array after a geometry change. Drag and resize commit once on pointer up; arrow keys commit on every press. |
| onItemDelete | (id: string) => void | Delete or Backspace on the selected element. Omit it to ignore the delete keys. |
| onSelect | (elementPath: string | null) => void | Selection change: an element id, or null when the empty canvas is clicked. |
| onZoomChange | (zoom: number) => void | Zoom change. |
| onPanChange | (pan: { x, y }) => void | Pan change. |
Slots
| Slot | Type | Description |
|---|---|---|
| renderItem | (item, state: { selected, dragging, resizing }) => ReactNode | Render the element body; positioning, the selection box, and the handles belong to the canvas. Defaults to an empty placeholder frame. |
| children | ReactNode | Free layer mounted straight into the world-coordinate layer. It follows pan and zoom, but you place it yourself. |
Example
const [items, setItems] = useState<DesignCanvasItem[]>([
{ id: "hero", x: 40, y: 32, width: 260, height: 120, label: "Hero banner" },
{ id: "cta", x: 40, y: 176, width: 120, height: 96, label: "Call to action" },
]);
const [selected, setSelected] = useState<string | null>(null);
<div className="h-[420px] w-full overflow-hidden rounded border border-border">
<DesignCanvas
items={items}
onItemsChange={setItems}
selectedElement={selected}
onSelect={setSelected}
onItemDelete={(id) => setItems((p) => p.filter((i) => i.id !== id))}
grid={20}
snap={20}
renderItem={(item, s) => (
<div className={cn("grid h-full w-full place-items-center rounded-[var(--radius)] border bg-surface",
s.selected ? "border-primary" : "border-hairline")}>
{item.label}
</div>
)}
/>
</div>items and children are not two competing APIs. The only difference is whether the canvas knows the element's rectangle:
<DesignCanvas items={items} onItemsChange={setItems}>
{/* Free layer: selectable (onSelect reports "ruler") but the canvas has no rectangle for it,
so it cannot be dragged and shows no handles. */}
<div data-canvas-item="ruler" className="absolute left-0 top-0 h-px w-[600px] bg-primary" />
</DesignCanvas>The geometry helpers stand alone, which makes external toolbars (align, equalise, bulk offset) easy:
const bounds = itemsBounds(items); // multi-element bounding box for guides
const next = moveRect(item, 0, 8, 8); // move down one 8px grid cell
const bigger = resizeRect(item, "se", 20, 20, { snap: 8 }); // grow from the bottom-right, snapped
const screen = canvasToScreen({ x: item.x, y: item.y }, { x: pan.x, y: pan.y, zoom });Accessibility
- The canvas is
role="application"withtabIndex=0and a visible focus ring; its accessible name comes fromlabels.canvas. Copy resolves aslabelsprop first, then the ConfigProvider locale, then the built-in Chinese fallback, so switching the app language moves the canvas with it and you never have to passlabelsper call site. - Every
itemselement is focusable and reached in order by Tab. Focusing an element selects it (and reportsonSelect); the selected one carriesaria-current="true"plusdata-selectedand afocus-visiblering. - An element's accessible name is its
label, falling back toid, so prefer readable ids such as"hero"over"a1f3". - The keyboard path is complete: arrow keys move by one canvas unit (or by the snap step),
Shift+arrowmoves ten times as far,Alt+arrowresizes, and Delete or Backspace removes the element. The eight resize handles are thereforearia-hiddenpointer-only decoration and stay out of the tab order. - When focus sits in an
input,textarea, orcontenteditableinside an element, arrow keys and Delete are handed back to that control instead of being hijacked by the canvas.
Usage notes
- The outer
classNamemust have a definite height or the canvas is invisible. - The canvas is fully controlled:
onItemsChangeemits the whole new array, and skipping the state write snaps elements back. Drag and resize only touch an internal draft and do not emit per frame; readdragging/resizinginrenderItemto drive live external panels. - The canvas does not own the geometry of anything in
children. Addingdata-canvas-itemthere buys selection only, never dragging, resizing, or a selection box. Move it intoitemsto have the canvas manage it. - Buttons and inputs inside an element work normally: pressing one selects without starting a drag, matching Kanban and Sortable. Add
data-no-dragto exempt any other custom element. - Right-drag is part of the pan gesture, so the native context menu is suppressed. Listen for
contextmenuinsiderenderItemand callstopPropagationto provide your own. - Wheel events are registered with
{ passive: false }and prevented, so the canvas never scrolls an ancestor. That is deliberate: do not place the canvas inside a narrow column that relies on wheel scrolling. - Resizing past the anchored edge flips the rectangle, as in Figma, rather than clamping at
minItemSize. Reject the change insideonItemsChangeif your domain forbids flipping. - Pointer Events make the component client-only.
Related
Flow · FitScreen · Kanban · Sortable · GridPattern · ImageViewer