Duck Viewport
A pan and zoom container: the point under the pointer stays under the pointer, a drag that leaves the element keeps following, and the transform goes straight to the DOM so a pan costs no renders.
Preview
Scroll over a node and it stays under the pointer. Drag past the edge and the pan keeps following. Focus the graph for arrows, +, - and 0.
Installation
The CLI writes the source into your project and pulls in whatever it depends on.
pnpm dlx shadcn@latest add @duck/duck-viewportPulls in @duck/theme, @duck/quack-button, @duck/duck-button-group. Already installed items are skipped.
Usage
import { DuckViewport, DuckViewportControls } from "@/components/ui/duck-viewport"The file lands in components/ui/duck-viewport.tsx and belongs to you from that point on. Edit it in place rather than wrapping it.
Props
Everything not listed here is forwarded to the underlying element.
| Prop | Type | Default | Description |
|---|---|---|---|
| min / max | number | 0.25 / 4 | Scale limits. Every path clamps to them — wheel, pinch, keys and the ref alike. |
| initial | Partial<{ x: number; y: number; scale: number }> | { x: 0, y: 0, scale: 1 } | Starting transform, read once on mount, and where reset goes back to. Uncontrolled, like defaultValue — changing it later does nothing. |
| zoomStep | number | 1.25 | Multiplier for zoomIn, zoomOut and the + / - keys. The wheel is continuous and does not use it. |
| panStep | number | 48 | Pixels an arrow key moves the view. Shift multiplies it by three. |
| wheelZoom | boolean | true | Turn the wheel handler off where the page needs to scroll through the viewport. Drag, pinch and the keys still work. |
| onTransformChange | (transform: { x: number; y: number; scale: number }) => void | - | Fires at most once per frame, after the DOM has already moved. For a zoom read-out or a persisted view — not for driving the transform. |
| ref | Ref<DuckViewportHandle> | - | getTransform, zoomIn, zoomOut, zoomTo(scale, anchor?), panBy(dx, dy), reset. Programmatic moves ease; under prefers-reduced-motion they snap. |
| viewport | RefObject<DuckViewportHandle | null> | - | DuckViewportControls only. The ref you gave the viewport. The cluster is a sibling, so it is passed rather than found. |
| orientation | "vertical" | "horizontal" | "vertical" | DuckViewportControls only. Stacking direction of the three buttons. |
| zoomInLabel / zoomOutLabel / resetLabel | string | "Zoom in" / "Zoom out" / "Reset view" | DuckViewportControls only. The buttons are icon-only, so these are their accessible names. |
| aria-label | string | "View controls" | DuckViewportControls only. It renders a DuckButtonGroup toolbar, and a toolbar needs a name. |
Rules
What keeps this component from turning into noise.
- It is a viewport, not a graph renderer. It never looks at its children: a knowledge graph, an image lightbox, a zoomable diagram and the canvas of an editor are all the same interaction, and everything that knows about nodes and edges stays in the application.
- The wheel listener is registered through addEventListener with passive: false. React's onWheel is delegated and passive, so preventDefault() inside it is ignored and the page scrolls, or macOS runs its swipe-back, while you zoom. Never replace it with an onWheel prop.
- No React state holds the transform. Read it with getTransform, or take onTransformChange, but do not try to drive the viewport from props — a pan writes element.style sixty times a second on purpose.
- Give the host a height and an aria-label. It is focusable, and arrows, + / - and 0 are the whole keyboard story.
- Chromeless by design: no border, no background. Put the sticker edge on the wrapper, and absolutely position DuckViewportControls inside that wrapper so it does not pan away with the content.
- The cluster is a DuckButtonGroup toolbar: one tab stop, arrows inside it. A canvas already owns the arrow keys, so its controls should not also cost three Tabs.
- The cluster's buttons never disable at the limits. Knowing when to would mean subscribing to the transform, which puts the pan back into React; clamping already makes the press a no-op.
