Canvas View

Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support.

Report a bug

Preview

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

Installation

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

Storybook

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

View in Storybook

Code

"use client"; import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState, } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, } 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") { const style = window.getComputedStyle(element); if (axis === "x") { return ( supportsScrollableOverflow(style.overflowX) && element.scrollWidth > element.clientWidth ); } return ( supportsScrollableOverflow(style.overflowY) && element.scrollHeight > element.clientHeight ); } 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: ReactWheelEvent<HTMLDivElement>) { if ( isHtmlElement(event.target) && hasScrollableAncestor(event.target, event.currentTarget, { 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({ nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep, }: { nudgeViewport: (deltaX: number, deltaY: number) => void; resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; viewportRef: ViewportReference; zoomStep: number; }) { const [isSpacePressed, setIsSpacePressed] = useState(false); const handleWheel = useCallback( (event: ReactWheelEvent<HTMLDivElement>) => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); setViewport({ ...viewportRef.current, zoom: viewportRef.current.zoom + (event.deltaY > 0 ? -zoomStep : zoomStep), }); return; } if (!shouldHandleCanvasWheelEvent(event)) { return; } event.preventDefault(); nudgeViewport(-event.deltaX, -event.deltaY); }, [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); } }, [], ); return { handleKeyDown, handleKeyUp, handleWheel, isSpacePressed }; } 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.ForwardedRef<CanvasViewHandle>, viewportState: { resetViewport: () => void; setViewport: (viewport: CanvasViewport) => void; }, ) { useImperativeHandle( ref, () => ({ resetViewport: viewportState.resetViewport, setViewport: viewportState.setViewport, }), [viewportState.resetViewport, viewportState.setViewport], ); } type CanvasInteractionLayerProps = { children: React.ReactNode; instructionsId: string; isDragging: boolean; isSpacePressed: boolean; 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; onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void; viewport: CanvasViewport; }; function CanvasInteractionLayer({ children, instructionsId, isDragging, isSpacePressed, onKeyDown, onKeyUp, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, onWheel, 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)} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onPointerCancel={onPointerCancel} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onWheel={onWheel} 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, overlay, viewport, }: { children: React.ReactNode; overlay?: React.ReactNode; viewport: CanvasViewport; }) { return ( <> <div className="absolute inset-0 origin-top-left 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 = forwardRef<CanvasViewHandle, CanvasViewProps>( ( { children, className, defaultViewport = DEFAULT_VIEWPORT, maxZoom = 2, minZoom = 0.5, onViewportChange, overlay, zoomStep = 0.1, ...props }, ref, ) => { const instructionsId = useId(); const viewportState = useViewportState({ defaultViewport, maxZoom, minZoom, onViewportChange, }); const keyboard = useCanvasKeyboardInteractions({ 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 instructionsId={instructionsId} isDragging={pointer.isDragging} isSpacePressed={keyboard.isSpacePressed} onKeyDown={keyboard.handleKeyDown} onKeyUp={keyboard.handleKeyUp} onPointerCancel={pointer.handlePointerCancel} onPointerDown={pointer.handlePointerDown} onPointerMove={pointer.handlePointerMove} onPointerUp={pointer.handlePointerUp} onWheel={keyboard.handleWheel} viewport={viewportState.viewport} > <CanvasContentLayer overlay={overlay} viewport={viewportState.viewport} > {children} </CanvasContentLayer> </CanvasInteractionLayer> </div> ); }, ); CanvasView.displayName = "CanvasView"; export { CanvasView };

Dependencies

  • @vllnt/ui@^0.2.1