useViewport

Pan and zoom state management for canvas surfaces.

Install

npx shadcn@latest add https://substrate.georgedrury.co.uk/r/use-viewport

Usage

import { useViewport } from "@/hooks/substrate/use-viewport"

function Canvas() {
  const {
    viewport,
    handleWheel,
    toWorld,
    toScreen,
    zoomIn,
    zoomOut,
    resetZoom,
    zoomToFit,
  } = useViewport({ minZoom: 0.1, maxZoom: 10 })

  return (
    <div onWheel={handleWheel} style={{ overflow: "hidden" }}>
      <div
        style={{
          transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
          transformOrigin: "0 0",
        }}
      >
        {/* Canvas content */}
      </div>
    </div>
  )
}

Features

This is a surface-agnostic extraction of pan/zoom logic. It manages { x, y, zoom } state and provides coordinate transforms between screen and world space. The handleWheel callback supports two modes – plain scroll for panning and Ctrl/⌘ + scroll for pinch-zoom – keeping the world point under the cursor stationary during zoom.

The hook uses refs internally so that callbacks like toWorld and toScreen remain referentially stable across renders.

API

Options

OptionTypeDefaultDescription
initialPartial<Viewport>{ x: 0, y: 0, zoom: 1 }Initial viewport state
minZoomnumber0.1Minimum zoom level
maxZoomnumber10Maximum zoom level
zoomFactornumber1.2Multiplier for zoomIn / zoomOut

Returns

PropertyTypeDescription
viewportViewportCurrent { x, y, zoom } state
setViewport(v: Viewport) => voidSet viewport directly
toWorld(screenX, screenY) => PointConvert screen coordinates to world
toScreen(worldX, worldY) => PointConvert world coordinates to screen
pan(dx, dy) => voidPan by a screen-space delta
zoomToPoint(screenX, screenY, zoom) => voidZoom keeping a screen point stationary
zoomIn(screenX?, screenY?) => voidZoom in by zoomFactor
zoomOut(screenX?, screenY?) => voidZoom out by zoomFactor
resetZoom() => voidReset to { x: 0, y: 0, zoom: 1 }
zoomToFit(bounds, padding?) => voidFit a bounding box into the viewport
handleWheel(e: WheelEvent) => voidAttach to your container's onWheel