Canvas View
Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support. Install Canvas View from the VLLNT UI registry with the shadcn CLI.
Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Canvas View to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-view.jsonSource
"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 };
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.