Canvas View

Add a pan-and-zoom canvas to your React app for diagrams, boards, and node editors. Drag to pan, use keys or the wheel to zoom. Install via the shadcn CLI.

Report a bug

What it is & when to use

Canvas View is an interactive viewport for spatial surfaces — infinite boards, diagrams, mind maps, and node editors. It manages pan and zoom state so your content moves as a single transformed layer. Users pan by holding space and dragging or using the middle mouse button, and zoom with the plus and minus keys or a modifier-held wheel. It exposes viewport callbacks, an imperative reset handle, min and max zoom bounds, and an overlay slot. Reach for it whenever content outgrows its container.

Browse all Utility

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/canvas-view.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState, } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from "react"; import { cn } from "../../lib/utils"; export type CanvasViewport = { x: number; y: number; zoom: number; }; export type CanvasViewHandle = { resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; }; export type CanvasViewProps = Omit< React.ComponentPropsWithoutRef<"div">, "onScroll" > & { defaultViewport?: CanvasViewport; maxZoom?: number; minZoom?: number; onViewportChange?: (viewport: CanvasViewport) => void; overlay?: React.ReactNode; zoomStep?: number; }; type DragOrigin = { pointerX: number; pointerY: number; viewport: CanvasViewport; }; type ViewportReference = { current: CanvasViewport; }; const DEFAULT_VIEWPORT: CanvasViewport = { x: 0, y: 0, zoom: 1 }; const INTERACTIVE_ELEMENT_SELECTOR = [ "a[href]", "button", 'input:not([type="hidden"])', "select", "textarea", "summary", '[contenteditable=""]', '[contenteditable="true"]', '[role="button"]', '[role="checkbox"]', '[role="link"]', '[role="menuitem"]', '[role="option"]', '[role="radio"]', '[role="slider"]', '[role="spinbutton"]', '[role="switch"]', '[role="tab"]', '[role="textbox"]', ].join(", "); function clampZoom(value: number, minZoom: number, maxZoom: number) { return Math.min(maxZoom, Math.max(minZoom, Number(value.toFixed(2)))); } function isHtmlElement(target: EventTarget | null): target is HTMLElement { return target instanceof HTMLElement; } function isInteractiveDescendant( element: HTMLElement, container: HTMLDivElement, ) { const interactiveAncestor = element.closest(INTERACTIVE_ELEMENT_SELECTOR); return ( interactiveAncestor !== null && container.contains(interactiveAncestor) ); } function supportsScrollableOverflow(value: string) { return value === "auto" || value === "overlay" || value === "scroll"; } function hasScrollableAxis(element: HTMLElement, axis: "x" | "y") { if (axis === "x") { if (element.scrollWidth <= element.clientWidth) { return false; } return supportsScrollableOverflow( window.getComputedStyle(element).overflowX, ); } if (element.scrollHeight <= element.clientHeight) { return false; } return supportsScrollableOverflow(window.getComputedStyle(element).overflowY); } function hasScrollableAncestor( element: HTMLElement, container: HTMLDivElement, delta: { x: number; y: number }, ): boolean { if (!container.contains(element) || element === container) { return false; } if ( (delta.x !== 0 && hasScrollableAxis(element, "x")) || (delta.y !== 0 && hasScrollableAxis(element, "y")) ) { return true; } return element.parentElement === null ? false : hasScrollableAncestor(element.parentElement, container, delta); } function shouldHandleCanvasKeyboardEvent( event: ReactKeyboardEvent<HTMLDivElement>, ) { if ( isHtmlElement(event.target) && event.target !== event.currentTarget && isInteractiveDescendant(event.target, event.currentTarget) ) { return false; } return true; } function shouldHandleCanvasWheelEvent( event: WheelEvent, container: HTMLDivElement, ) { if ( isHtmlElement(event.target) && hasScrollableAncestor(event.target, container, { x: event.deltaX, y: event.deltaY, }) ) { return false; } return true; } function isPanGesture( event: ReactPointerEvent<HTMLDivElement>, isSpacePressed: boolean, ) { return event.button === 1 || (event.button === 0 && isSpacePressed); } function createViewportKeyHandler({ nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, }: { nudgeViewport: (deltaX: number, deltaY: number) => void; resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { return (event: ReactKeyboardEvent<HTMLDivElement>) => { if (event.key === "+" || event.key === "=") { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom + zoomStep, }); return; } if (event.key === "-") { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom - zoomStep, }); return; } if (event.key === "0") { event.preventDefault(); resetViewport(); return; } if (event.key === "ArrowLeft") { event.preventDefault(); nudgeViewport(40, 0); return; } if (event.key === "ArrowRight") { event.preventDefault(); nudgeViewport(-40, 0); return; } if (event.key === "ArrowUp") { event.preventDefault(); nudgeViewport(0, 40); return; } if (event.key === "ArrowDown") { event.preventDefault(); nudgeViewport(0, -40); } }; } function useViewportState({ defaultViewport, maxZoom, minZoom, onViewportChange, }: { defaultViewport: CanvasViewport; maxZoom: number; minZoom: number; onViewportChange?: (viewport: CanvasViewport) => void; }) { const defaultViewportRef = useRef(defaultViewport); const viewportRef = useRef(defaultViewport); const [viewport, setViewport] = useState(defaultViewport); useEffect(() => { defaultViewportRef.current = defaultViewport; }, [defaultViewport]); const applyViewport = useCallback( (nextViewport: CanvasViewport) => { const resolvedViewport = { x: Math.round(nextViewport.x), y: Math.round(nextViewport.y), zoom: clampZoom(nextViewport.zoom, minZoom, maxZoom), }; viewportRef.current = resolvedViewport; setViewport(resolvedViewport); onViewportChange?.(resolvedViewport); }, [maxZoom, minZoom, onViewportChange], ); const resetViewport = useCallback(() => { applyViewport(defaultViewportRef.current); }, [applyViewport]); const nudgeViewport = useCallback( (deltaX: number, deltaY: number) => { const currentViewport = viewportRef.current; applyViewport({ x: currentViewport.x + deltaX, y: currentViewport.y + deltaY, zoom: currentViewport.zoom, }); }, [applyViewport], ); return { nudgeViewport, resetViewport, setViewport: applyViewport, viewport, viewportRef, }; } function useCanvasKeyboardInteractions({ containerRef, nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, }: { containerRef: React.RefObject<HTMLDivElement | null>; nudgeViewport: (deltaX: number, deltaY: number) => void; resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { const [isSpacePressed, setIsSpacePressed] = useState(false); // React 19 marks the synthetic onWheel handler passive, which drops // preventDefault(). useCanvasWheel attaches a native non-passive listener. useCanvasWheel({ containerRef, nudgeViewport, setViewport, viewportRef, zoomStep, }); const handleKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (!shouldHandleCanvasKeyboardEvent(event)) { return; } if (event.key === " ") { event.preventDefault(); setIsSpacePressed(true); return; } createViewportKeyHandler({ nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, })(event); }, [nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep], ); const handleKeyUp = useCallback( (event: ReactKeyboardEvent<HTMLDivElement>) => { if (!shouldHandleCanvasKeyboardEvent(event)) { return; } if (event.key === " ") { setIsSpacePressed(false); } }, [], ); const handleBlur = useCallback(() => { setIsSpacePressed(false); }, []); return { handleBlur, handleKeyDown, handleKeyUp, isSpacePressed }; } function useCanvasWheel({ containerRef, nudgeViewport, setViewport, viewportRef, zoomStep, }: { containerRef: React.RefObject<HTMLDivElement | null>; nudgeViewport: (deltaX: number, deltaY: number) => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { useEffect(() => { const container = containerRef.current; if (!container) { return; } const handleWheel = (event: WheelEvent) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom + (event.deltaY > 0 ? -zoomStep : zoomStep), }); return; } if (!shouldHandleCanvasWheelEvent(event, container)) { return; } event.preventDefault(); nudgeViewport(-event.deltaX, -event.deltaY); }; container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; }, [containerRef, nudgeViewport, setViewport, viewportRef, zoomStep]); } function endCanvasDrag( event: ReactPointerEvent<HTMLDivElement>, dragOriginRef: React.RefObject<DragOrigin | null>, setIsDragging: React.Dispatch<React.SetStateAction<boolean>>, ) { dragOriginRef.current = null; setIsDragging(false); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } } function useCanvasPointerInteractions({ isSpacePressed, setViewport, viewportRef, }: { isSpacePressed: boolean; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; }) { const dragOriginRef = useRef<DragOrigin | null>(null); const [isDragging, setIsDragging] = useState(false); const handlePointerDown = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { if (!isPanGesture(event, isSpacePressed)) { return; } if (event.button === 1) { event.preventDefault(); } dragOriginRef.current = { pointerX: event.clientX, pointerY: event.clientY, viewport: viewportRef.current, }; event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); }, [isSpacePressed, viewportRef], ); const handlePointerMove = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { const dragOrigin = dragOriginRef.current; if (!dragOrigin) { return; } setViewport({ x: dragOrigin.viewport.x + (event.clientX - dragOrigin.pointerX), y: dragOrigin.viewport.y + (event.clientY - dragOrigin.pointerY), zoom: dragOrigin.viewport.zoom, }); }, [setViewport], ); const handlePointerCancel = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { endCanvasDrag(event, dragOriginRef, setIsDragging); }, [], ); const handlePointerUp = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { endCanvasDrag(event, dragOriginRef, setIsDragging); }, [], ); return { handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp, isDragging, }; } function usePreventBodySelection(disabled: boolean) { useEffect(() => { if (typeof document === "undefined") { return; } const { body } = document; const previousUserSelect = body.style.userSelect; if (disabled) { body.style.userSelect = "none"; } return () => { body.style.userSelect = previousUserSelect; }; }, [disabled]); } function useCanvasViewHandle( ref: React.Ref<CanvasViewHandle> | undefined, viewportState: { resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; }, ) { useImperativeHandle( ref, () => ({ resetViewport: viewportState.resetViewport, setViewport: viewportState.setViewport, }), [viewportState.resetViewport, viewportState.setViewport], ); } type CanvasInteractionLayerProps = { children: React.ReactNode; containerRef: React.RefObject<HTMLDivElement | null>; instructionsId: string; isDragging: boolean; isSpacePressed: boolean; onBlur: () => void; onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void; onKeyUp: (event: ReactKeyboardEvent<HTMLDivElement>) => void; onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void; onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void; viewport: CanvasViewport; }; function CanvasInteractionLayer({ children, containerRef, instructionsId, isDragging, isSpacePressed, onBlur, onKeyDown, onKeyUp, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, viewport, }: CanvasInteractionLayerProps) { return ( <div aria-describedby={instructionsId} aria-label="Canvas workspace" aria-roledescription="canvas" className={cn( "relative h-full w-full select-none touch-none outline-none", isDragging || isSpacePressed ? "cursor-grab active:cursor-grabbing" : "cursor-default", )} data-viewport={JSON.stringify(viewport)} onBlur={onBlur} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerCancel={onPointerCancel} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} ref={containerRef} role="button" tabIndex={0} > <div className="sr-only" id={instructionsId}> Hold space and drag or use the middle mouse button to pan. Use plus, minus, or control wheel to zoom. Press zero to reset the viewport. </div> {children} </div> ); } function CanvasContentLayer({ children, isDragging, overlay, viewport, }: { children: React.ReactNode; isDragging: boolean; overlay?: React.ReactNode; viewport: CanvasViewport; }) { return ( <> <div className={cn( "absolute inset-0 origin-top-left", isDragging ? "" : "transition-transform duration-150 ease-out", )} style={{ transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.zoom})`, }} > {children} </div> {overlay ? ( <div className="pointer-events-none absolute inset-0 z-20"> {overlay} </div> ) : null} </> ); } const CanvasView = ({ children, className, defaultViewport = DEFAULT_VIEWPORT, maxZoom = 2, minZoom = 0.5, onViewportChange, overlay, ref, zoomStep = 0.1, ...props }: CanvasViewProps & { ref?: React.Ref<CanvasViewHandle> }) => { const instructionsId = useId(); const interactionRef = useRef<HTMLDivElement>(null); const viewportState = useViewportState({ defaultViewport, maxZoom, minZoom, onViewportChange, }); const keyboard = useCanvasKeyboardInteractions({ containerRef: interactionRef, nudgeViewport: viewportState.nudgeViewport, resetViewport: viewportState.resetViewport, setViewport: viewportState.setViewport, viewportRef: viewportState.viewportRef, zoomStep, }); const pointer = useCanvasPointerInteractions({ isSpacePressed: keyboard.isSpacePressed, setViewport: viewportState.setViewport, viewportRef: viewportState.viewportRef, }); usePreventBodySelection(pointer.isDragging); useCanvasViewHandle(ref, viewportState); return ( <div className={cn( "relative h-full min-h-[32rem] overflow-hidden rounded-sm border border-border bg-background", className, )} {...props} > <CanvasInteractionLayer containerRef={interactionRef} instructionsId={instructionsId} isDragging={pointer.isDragging} isSpacePressed={keyboard.isSpacePressed} onBlur={keyboard.handleBlur} onKeyDown={keyboard.handleKeyDown} onKeyUp={keyboard.handleKeyUp} onPointerCancel={pointer.handlePointerCancel} onPointerDown={pointer.handlePointerDown} onPointerMove={pointer.handlePointerMove} onPointerUp={pointer.handlePointerUp} viewport={viewportState.viewport} > <CanvasContentLayer isDragging={pointer.isDragging} overlay={overlay} viewport={viewportState.viewport} > {children} </CanvasContentLayer> </CanvasInteractionLayer> </div> ); }; CanvasView.displayName = "CanvasView"; export { CanvasView };

Dependencies

  • @vllnt/ui@^0.3.0

Frequently asked questions

What is Canvas View?
Canvas View is a React component that provides an interactive pan-and-zoom viewport for spatial surfaces like diagrams, boards, and node editors. Content moves as one transformed layer, and it supports pointer, wheel, and keyboard navigation plus an optional overlay slot.
How do I add Canvas View?
Run `pnpm dlx shadcn@latest add https://ui.vllnt.com/r/canvas-view.json` to install it into your React project via the shadcn CLI, then import and render CanvasView around your content.
How do users pan and zoom Canvas View?
Users pan by holding the space bar and dragging, by using the middle mouse button, or by scrolling the wheel. They zoom with the plus and minus keys or a control/command-held wheel, and press 0 to reset the viewport. The interaction region is labeled for screen readers.