Skip to content

Instantly share code, notes, and snippets.

@krabhi1
Last active October 5, 2025 06:15
Show Gist options
  • Save krabhi1/af717bca34b18487753c54f6e7f572f0 to your computer and use it in GitHub Desktop.
Save krabhi1/af717bca34b18487753c54f6e7f572f0 to your computer and use it in GitHub Desktop.
pan+zoom logic in canvas react

pan+zoom

very smooth pan+zoom logic in react+canvas

Functions

  • worldToScreen()
  • screenToWorld()
import { useEffect, useRef } from "react";
class Point {
constructor(public x: number = 0, public y: number = 0) {}
}
export default function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current!!;
const ctx = canvas.getContext("2d")!!;
let isDragging = false;
let startDrag = new Point();
let offset = new Point();
let currentZoom = 1;
const minZoom = 0.1; // Minimum zoom level
const maxZoom = 8; // Maximum zoom level
canvas.addEventListener("pointerdown", (e) => {
const down = getMousePoint(e);
console.log("offset", offset, "down", down);
isDragging = true;
startDrag.x = down.x - offset.x;
startDrag.y = down.y - offset.y;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointerup", (e) => {
isDragging = false;
const up = getMousePoint(e);
canvas.releasePointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", (e) => {
const move = getMousePoint(e);
if (isDragging) {
offset.x = move.x - startDrag.x;
offset.y = move.y - startDrag.y;
onDraw();
} else {
const world = screenToWorld(move);
const screen=worldToScreen(world)
console.log("world", world,"screen",screen);
}
});
canvas.addEventListener("wheel", (e) => {
const point = getMousePoint(e as any);
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = currentZoom * zoomFactor;
if (newZoom >= minZoom && newZoom <= maxZoom) {
// Calculate the translation needed to keep the mouse cursor fixed
const scale = newZoom / currentZoom;
const dx = (point.x - offset.x) * (1 - scale);
const dy = (point.y - offset.y) * (1 - scale);
offset.x += dx;
offset.y += dy;
currentZoom = newZoom;
onDraw();
}
});
function onDraw() {
ctx.clearRect(0, 0, 600, 400);
ctx.save();
ctx.translate(offset.x, offset.y);
ctx.scale(currentZoom, currentZoom);
drawLines([new Point(0, 1000), new Point(0, 0), new Point(1000, 0)]);
ctx.fillRect(70, 70, 100, 100);
ctx.fillRect(200, 70, 100, 50);
ctx.restore();
}
onDraw();
//utils
function getMousePoint(e: PointerEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
} as Point;
}
function drawLines(points: Point[]) {
if (points.length < 2) {
return;
}
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
}
function screenToWorld(screen: Point) {
const worldX = (screen.x - offset.x) / currentZoom;
const worldY = (screen.y - offset.y) / currentZoom;
return new Point(worldX, worldY);
}
function worldToScreen(world: Point) {
const screenX = world.x * currentZoom + offset.x;
const screenY = world.y * currentZoom + offset.y;
return new Point(screenX, screenY);
}
}, []);
return (
<div>
<canvas
ref={canvasRef}
width={"600px"}
height={"400px"}
style={{
border: "black solid 1px",
}}
></canvas>
</div>
);
}
@krabhi1
Copy link
Author

krabhi1 commented Oct 5, 2025

Refactored: Smooth Pan + Zoom in React + Canvas

import { useEffect, useRef } from 'react';

// --- Types ---
type Point = { x: number; y: number };
type CanvasState = {
  offset: Point;
  zoom: number;
  isDragging: boolean;
  dragStart: Point;
};

// --- Constants ---
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
const ZOOM_FACTOR = 0.1; // 10% per scroll tick

// --- Pure Helper Functions (no side effects) ---
const createPoint = (x: number = 0, y: number = 0): Point => ({ x, y });

const screenToWorld = (screen: Point, offset: Point, zoom: number): Point => ({
  x: (screen.x - offset.x) / zoom,
  y: (screen.y - offset.y) / zoom,
});

const worldToScreen = (world: Point, offset: Point, zoom: number): Point => ({
  x: world.x * zoom + offset.x,
  y: world.y * zoom + offset.y,
});

const getCanvasCoordinates = (
  clientX: number,
  clientY: number,
  canvas: HTMLCanvasElement
): Point => {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  return {
    x: (clientX - rect.left) * scaleX,
    y: (clientY - rect.top) * scaleY,
  };
};

const clamp = (value: number, min: number, max: number): number =>
  Math.min(Math.max(value, min), max);

// --- Canvas Drawing Logic ---
const drawWorldContent = (ctx: CanvasRenderingContext2D, zoom: number) => {
  // Keep stroke width consistent across zoom levels
  ctx.strokeStyle = '#ccc';
  ctx.lineWidth = 1 / zoom;

  // Axes
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(1000, 0);
  ctx.moveTo(0, 0);
  ctx.lineTo(0, 1000);
  ctx.stroke();

  // Sample shapes
  ctx.fillStyle = 'lightblue';
  ctx.fillRect(70, 70, 100, 100);
  ctx.fillRect(200, 70, 100, 50);
};

const redraw = (
  canvas: HTMLCanvasElement,
  ctx: CanvasRenderingContext2D,
  offset: Point,
  zoom: number
) => {
  const { width, height } = canvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio || 1;
  ctx.clearRect(0, 0, width * dpr, height * dpr);

  ctx.save();
  ctx.translate(offset.x, offset.y);
  ctx.scale(zoom, zoom);
  drawWorldContent(ctx, zoom);
  ctx.restore();
};

// --- Main Component ---
export default function PanZoomCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    // --- Initialize state ---
    let state: CanvasState = {
      offset: createPoint(),
      zoom: 1,
      isDragging: false,
      dragStart: createPoint(),
    };

    // --- Resize handling ---
    const resizeCanvas = () => {
      const container = canvas.parentElement;
      if (!container) return;

      const { width, height } = container.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;

      canvas.width = width * dpr;
      canvas.height = height * dpr;
      ctx.scale(dpr, dpr);
      redraw(canvas, ctx, state.offset, state.zoom);
    };

    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    // --- Event Handlers ---
    const handlePointerDown = (e: PointerEvent) => {
      if (e.button !== 0) return; // left mouse only
      const point = getCanvasCoordinates(e.clientX, e.clientY, canvas);
      state = {
        ...state,
        isDragging: true,
        dragStart: {
          x: point.x - state.offset.x,
          y: point.y - state.offset.y,
        },
      };
      canvas.setPointerCapture(e.pointerId);
    };

    const handlePointerMove = (e: PointerEvent) => {
      const point = getCanvasCoordinates(e.clientX, e.clientY, canvas);
      if (state.isDragging) {
        state.offset = {
          x: point.x - state.dragStart.x,
          y: point.y - state.dragStart.y,
        };
        redraw(canvas, ctx, state.offset, state.zoom);
      }
    };

    const handlePointerUpOrCancel = () => {
      state = { ...state, isDragging: false };
    };

    let wheelTimeout: number | null = null;
    const handleWheel = (e: WheelEvent) => {
      e.preventDefault();
      if (wheelTimeout) clearTimeout(wheelTimeout);

      wheelTimeout = window.setTimeout(() => {
        const point = getCanvasCoordinates(e.clientX, e.clientY, canvas);
        const zoomDirection = e.deltaY > 0 ? -1 : 1;
        const newZoom = clamp(
          state.zoom * (1 + zoomDirection * ZOOM_FACTOR),
          MIN_ZOOM,
          MAX_ZOOM
        );

        if (newZoom === state.zoom) return;

        // Adjust offset to keep cursor over same world point
        const scaleRatio = newZoom / state.zoom;
        state.offset = {
          x: state.offset.x + (point.x - state.offset.x) * (1 - scaleRatio),
          y: state.offset.y + (point.y - state.offset.y) * (1 - scaleRatio),
        };
        state.zoom = newZoom;

        redraw(canvas, ctx, state.offset, state.zoom);
      }, 16);
    };

    // --- Attach listeners ---
    canvas.addEventListener('pointerdown', handlePointerDown);
    canvas.addEventListener('pointermove', handlePointerMove);
    canvas.addEventListener('pointerup', handlePointerUpOrCancel);
    canvas.addEventListener('pointercancel', handlePointerUpOrCancel);
    canvas.addEventListener('wheel', handleWheel, { passive: false });

    // --- Cleanup ---
    return () => {
      window.removeEventListener('resize', resizeCanvas);
      canvas.removeEventListener('pointerdown', handlePointerDown);
      canvas.removeEventListener('pointermove', handlePointerMove);
      canvas.removeEventListener('pointerup', handlePointerUpOrCancel);
      canvas.removeEventListener('pointercancel', handlePointerUpOrCancel);
      canvas.removeEventListener('wheel', handleWheel);
      if (wheelTimeout) clearTimeout(wheelTimeout);
    };
  }, []);

  return (
    <div style={{ width: '100%', height: '100vh', margin: 0 }}>
      <canvas
        ref={canvasRef}
        style={{
          display: 'block',
          width: '100%',
          height: '100%',
          border: '1px solid #ccc',
          touchAction: 'none', // disables browser pan/zoom on touch
        }}
      />
    </div>
  );
}

Key Improvements Summary

Feature Why It Matters
e.preventDefault() in wheel Stops page from scrolling during zoom
High-DPI (Retina) support Mouse aligns perfectly on all screens
Responsive canvas Works in any container size
Throttled wheel events Smoother zoom, less jank
touchAction: "none" Enables pan/zoom on touch devices
Proper cleanup No memory leaks or duplicate listeners
Consistent line width lineWidth = 1 / zoom keeps strokes sharp
Left-mouse only drag Ignores right-click/context menu

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment