InfiniteCanvasLayout
The Infinite Canvas Layout template provides the shell behind whiteboard and canvas-style tools (Figma, Miro, tldraw): an optional top toolbar above a full-bleed, pannable and zoomable dot-grid surface. Content is placed with the CanvasNode helper at fixed world coordinates, and the whole layer pans/zooms together via drag, wheel/trackpad, keyboard shortcuts, or the floating zoom controls.
- Preview
- Code
import { InfiniteCanvasLayout, CanvasNode } from '@ignix-ui/infinite-canvas-layout';
import { Github } from 'lucide-react';
<InfiniteCanvasLayout
header={<span className="font-bold">Board</span>}
actions={<a href="https://github.com" aria-label="GitHub"><Github className="h-4 w-4" /></a>}
minZoom={0.25}
maxZoom={2.5}
gridSize={32}
>
<CanvasNode x={0} y={0}>
<div className="rounded-lg border p-4 shadow-md">Idea</div>
</CanvasNode>
<CanvasNode x={320} y={80}>
<div className="rounded-lg border p-4 shadow-md">Sketch</div>
</CanvasNode>
<CanvasNode x={80} y={280}>
<div className="rounded-lg border p-4 shadow-md">Draft</div>
</CanvasNode>
</InfiniteCanvasLayout>
Installation
- CLI
- Manual
ignix add component InfiniteCanvasLayout
cn is a small clsx + tailwind-merge helper (installed automatically by the CLI, at src/utils/cn.ts). Placing this file at src/templates/InfiniteCanvasLayout/index.tsx resolves the import below as-is; adjust the relative path if you place it elsewhere.
import * as React from "react";
import { Button } from "@ignix-ui/button";
import { Plus, Minus, RotateCcw } from "lucide-react";
import { cn } from "../../utils/cn";
/* -------------------------------------------------------------------------- */
/* TYPES & INTERFACES */
/* -------------------------------------------------------------------------- */
/** Pan/zoom state of the canvas, in the canvas's own (untransformed) coordinate space. */
export interface CanvasViewport {
x: number;
y: number;
zoom: number;
}
/** Restricts `value` to the inclusive `[min, max]` range. */
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/**
* Props for the {@link CanvasNode} helper - a single item absolutely positioned on the
* infinite canvas at world coordinates `(x, y)`.
*/
export interface CanvasNodeProps {
x: number;
y: number;
width?: number | string;
height?: number | string;
className?: string;
children: React.ReactNode;
}
/**
* Positions its children at a fixed `(x, y)` point in the canvas's world space. Use one per
* item placed on an {@link InfiniteCanvasLayout} - panning/zooming the canvas moves and scales
* every `CanvasNode` together, since they're rendered inside the same transformed layer.
*/
export const CanvasNode: React.FC<CanvasNodeProps> = ({ x, y, width, height, className, children }) => (
<div className={cn("absolute", className)} style={{ left: x, top: y, width, height }}>
{children}
</div>
);
CanvasNode.displayName = "CanvasNode";
/**
* Props for the {@link InfiniteCanvasLayout} template.
*
* @property header - Custom node rendered at the start of the top toolbar (e.g. a logo/title).
* @property actions - Optional node rendered at the end of the top toolbar.
* @property children - Canvas content, typically one or more {@link CanvasNode} elements.
* @property viewport - Controlled pan/zoom state. Provide alongside `onViewportChange` to
* drive the canvas externally; omit to let the layout manage its own state.
* @property defaultViewport - Initial pan/zoom state when uncontrolled, and the state the
* "Reset view" control returns to. Defaults to `{ x: 0, y: 0, zoom: 1 }`.
* @property onViewportChange - Called whenever the viewport changes, whether controlled or not.
* @property minZoom - Minimum zoom factor. Default `0.25`.
* @property maxZoom - Maximum zoom factor. Default `2.5`.
* @property showGrid - Whether to render the background dot grid. Default `true`.
* @property gridSize - Spacing between grid dots, in canvas world units. Default `32`.
* @property showControls - Whether to render the floating zoom in/out/reset control panel.
* Default `true`.
* @property className - Class name for the root container.
*/
export interface InfiniteCanvasLayoutProps {
header?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
viewport?: CanvasViewport;
defaultViewport?: CanvasViewport;
onViewportChange?: (viewport: CanvasViewport) => void;
minZoom?: number;
maxZoom?: number;
showGrid?: boolean;
gridSize?: number;
showControls?: boolean;
className?: string;
}
const DEFAULT_VIEWPORT: CanvasViewport = { x: 0, y: 0, zoom: 1 };
// Floors zoom regardless of consumer-configured minZoom - the wheel handler's zoom-to-cursor
// math divides by zoom, so 0 (or less) would produce Infinity/NaN and corrupt the viewport.
const MIN_SAFE_ZOOM = 0.01;
/**
* Replaces non-finite x/y/zoom with safe fallbacks, then clamps zoom to `[minZoom, maxZoom]`.
* NaN otherwise propagates into everything derived from the viewport (CSS transform, refs,
* `onViewportChange`), since NaN pollutes every calculation that touches it.
*/
function normalizeViewport(viewport: CanvasViewport, minZoom: number, maxZoom: number): CanvasViewport {
const x = Number.isFinite(viewport.x) ? viewport.x : DEFAULT_VIEWPORT.x;
const y = Number.isFinite(viewport.y) ? viewport.y : DEFAULT_VIEWPORT.y;
const zoom = Number.isFinite(viewport.zoom) ? viewport.zoom : DEFAULT_VIEWPORT.zoom;
return { x, y, zoom: clamp(zoom, minZoom, maxZoom) };
}
/* -------------------------------------------------------------------------- */
/* MAIN COMPONENT */
/* -------------------------------------------------------------------------- */
/**
* InfiniteCanvasLayout is a pannable, zoomable workspace shell - the shape behind
* whiteboard/canvas tools (Figma, Miro, tldraw). An optional top toolbar sits above a
* full-bleed canvas; content is placed via {@link CanvasNode} at fixed world coordinates,
* and the whole layer pans/zooms together via drag, wheel/trackpad, keyboard, or the
* floating zoom controls.
*/
export const InfiniteCanvasLayout: React.FC<InfiniteCanvasLayoutProps> = ({
header,
actions,
children,
viewport,
defaultViewport = DEFAULT_VIEWPORT,
onViewportChange,
minZoom = 0.25,
maxZoom = 2.5,
showGrid = true,
gridSize = 32,
showControls = true,
className,
}) => {
const isControlled = viewport !== undefined;
const [internalViewport, setInternalViewport] = React.useState<CanvasViewport>(defaultViewport);
const rawViewport = isControlled ? viewport : internalViewport;
// Guards against non-finite bounds (propagate NaN through clamp) and an inverted range
// (maxZoom < minZoom would freeze zoom at a fixed value).
const safeMinZoom = Number.isFinite(minZoom) ? Math.max(minZoom, MIN_SAFE_ZOOM) : MIN_SAFE_ZOOM;
const safeMaxZoom = Number.isFinite(maxZoom)
? Math.max(maxZoom, safeMinZoom)
: Math.max(safeMinZoom, DEFAULT_VIEWPORT.zoom);
// A controlled `viewport` prop bypasses updateViewport's own clamping, so this normalizes it
// before it reaches rendering or event handlers.
const currentViewport: CanvasViewport = normalizeViewport(rawViewport, safeMinZoom, safeMaxZoom);
// Lets event handlers read the latest viewport/callback without depending on them, so an
// inline onViewportChange doesn't tear down and reattach the wheel listener every render.
const viewportRef = React.useRef(currentViewport);
viewportRef.current = currentViewport;
const onViewportChangeRef = React.useRef(onViewportChange);
onViewportChangeRef.current = onViewportChange;
const updateViewport = React.useCallback(
(updater: (prev: CanvasViewport) => CanvasViewport) => {
const next = updater(viewportRef.current);
const clamped: CanvasViewport = normalizeViewport(next, safeMinZoom, safeMaxZoom);
if (!isControlled) {
setInternalViewport(clamped);
}
onViewportChangeRef.current?.(clamped);
},
[isControlled, safeMinZoom, safeMaxZoom]
);
const zoomIn = React.useCallback(
() => updateViewport((prev) => ({ ...prev, zoom: prev.zoom * 1.2 })),
[updateViewport]
);
const zoomOut = React.useCallback(
() => updateViewport((prev) => ({ ...prev, zoom: prev.zoom / 1.2 })),
[updateViewport]
);
const resetView = React.useCallback(
() => updateViewport(() => defaultViewport),
[updateViewport, defaultViewport]
);
const surfaceRef = React.useRef<HTMLDivElement>(null);
// React's JSX onWheel is passive by default (can't preventDefault), so a native listener
// with { passive: false } is used instead to stop page scroll/zoom.
React.useEffect(() => {
const surface = surfaceRef.current;
if (!surface) return;
const handleWheel = (event: WheelEvent): void => {
event.preventDefault();
const rect = surface.getBoundingClientRect();
const pointerX = event.clientX - rect.left;
const pointerY = event.clientY - rect.top;
if (event.ctrlKey || event.metaKey) {
updateViewport((prev) => {
const nextZoom = clamp(prev.zoom * (1 - event.deltaY * 0.01), safeMinZoom, safeMaxZoom);
const worldX = (pointerX - prev.x) / prev.zoom;
const worldY = (pointerY - prev.y) / prev.zoom;
return { x: pointerX - worldX * nextZoom, y: pointerY - worldY * nextZoom, zoom: nextZoom };
});
} else {
updateViewport((prev) => ({ ...prev, x: prev.x - event.deltaX, y: prev.y - event.deltaY }));
}
};
surface.addEventListener("wheel", handleWheel, { passive: false });
return () => surface.removeEventListener("wheel", handleWheel);
}, [updateViewport, safeMinZoom, safeMaxZoom]);
const panStateRef = React.useRef<{ pointerId: number; lastX: number; lastY: number } | null>(null);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
// Only pan when the drag starts on empty background, not on a CanvasNode, so content
// stays independently interactive.
if (event.target !== event.currentTarget || event.button !== 0) return;
// Prevents native text selection while panning - otherwise dragging across a CanvasNode's
// text mid-pan would still highlight it.
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
panStateRef.current = { pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY };
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
const state = panStateRef.current;
if (!state || state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.lastX;
const dy = event.clientY - state.lastY;
state.lastX = event.clientX;
state.lastY = event.clientY;
updateViewport((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
if (panStateRef.current?.pointerId === event.pointerId) {
panStateRef.current = null;
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const step = 40;
switch (event.key) {
case "ArrowUp":
updateViewport((prev) => ({ ...prev, y: prev.y + step }));
event.preventDefault();
break;
case "ArrowDown":
updateViewport((prev) => ({ ...prev, y: prev.y - step }));
event.preventDefault();
break;
case "ArrowLeft":
updateViewport((prev) => ({ ...prev, x: prev.x + step }));
event.preventDefault();
break;
case "ArrowRight":
updateViewport((prev) => ({ ...prev, x: prev.x - step }));
event.preventDefault();
break;
case "+":
case "=":
zoomIn();
event.preventDefault();
break;
case "-":
case "_":
zoomOut();
event.preventDefault();
break;
case "0":
resetView();
event.preventDefault();
break;
default:
break;
}
};
return (
<div className={cn("flex h-screen w-full flex-col bg-[var(--background)] text-[var(--foreground)]", className)}>
{(header || actions) && (
<header
className="z-10 flex h-14 w-full shrink-0 items-center gap-3 border-b border-[var(--border)] bg-[var(--background)] px-4"
role="banner"
>
{header && <div className="flex shrink-0 items-center">{header}</div>}
{actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)}
<div
ref={surfaceRef}
className="relative flex-1 touch-none select-none overflow-hidden"
style={
showGrid
? {
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
backgroundSize: `${gridSize * currentViewport.zoom}px ${gridSize * currentViewport.zoom}px`,
backgroundPosition: `${currentViewport.x}px ${currentViewport.y}px`,
}
: undefined
}
role="group"
aria-label="Infinite canvas. Drag to pan, scroll to pan, ctrl or cmd plus scroll to zoom. Focus and use arrow keys to pan, plus and minus to zoom, zero to reset the view."
tabIndex={0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
>
<div
className="absolute left-0 top-0"
style={{
transform: `translate(${currentViewport.x}px, ${currentViewport.y}px) scale(${currentViewport.zoom})`,
transformOrigin: "0 0",
}}
>
{children}
</div>
{showControls && (
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1 rounded-lg border border-[var(--border)] bg-[var(--background)] p-1 shadow-lg">
<Button variant="outline" size="icon" aria-label="Zoom in" onClick={zoomIn}>
<Plus className="h-4 w-4" />
</Button>
<div className="text-center text-xs text-[var(--muted-foreground)]">
{Math.round(currentViewport.zoom * 100)}%
</div>
<Button variant="outline" size="icon" aria-label="Zoom out" onClick={zoomOut}>
<Minus className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" aria-label="Reset view" onClick={resetView}>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
};
InfiniteCanvasLayout.displayName = "InfiniteCanvasLayout";
export default InfiniteCanvasLayout;
Usage
import { InfiniteCanvasLayout, CanvasNode } from '@ignix-ui/infinite-canvas-layout';
Basic Usage
function App() {
return (
<InfiniteCanvasLayout>
<CanvasNode x={0} y={0}>
<div>Idea</div>
</CanvasNode>
<CanvasNode x={320} y={80}>
<div>Sketch</div>
</CanvasNode>
</InfiniteCanvasLayout>
);
}
With a Toolbar
<InfiniteCanvasLayout
header={<span className="font-bold">Board</span>}
actions={<ThemeToggle />}
minZoom={0.25}
maxZoom={2.5}
gridSize={32}
>
<CanvasNode x={0} y={0} width={200} height={120}>
<Card />
</CanvasNode>
</InfiniteCanvasLayout>
Controlled Viewport
import { useState } from 'react';
function App() {
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
return (
<InfiniteCanvasLayout viewport={viewport} onViewportChange={setViewport}>
<CanvasNode x={0} y={0}>
<div>Synced with external state</div>
</CanvasNode>
</InfiniteCanvasLayout>
);
}
Interaction Reference
| Input | Action |
|---|---|
| Drag on empty canvas | Pan the canvas |
| Mouse wheel / trackpad scroll | Pan the canvas |
| Ctrl/Cmd + wheel | Zoom toward the cursor |
| Arrow keys (when focused) | Pan the canvas |
+ / - (when focused) | Zoom in / out |
0 (when focused) | Reset to defaultViewport |
| Zoom control buttons | Zoom in / out / reset |
Props
InfiniteCanvasLayout
| Prop | Type | Default | Description |
|---|---|---|---|
header | React.ReactNode | undefined | Custom node rendered at the start of the top toolbar (e.g. a logo/title) |
actions | React.ReactNode | undefined | Optional node rendered at the end of the top toolbar |
children | React.ReactNode | — | Canvas content, typically one or more CanvasNode elements |
viewport | CanvasViewport | undefined | Controlled pan/zoom state; provide alongside onViewportChange to drive the canvas externally |
defaultViewport | CanvasViewport | x: 0, y: 0, zoom: 1 | Initial pan/zoom state when uncontrolled, and the state "Reset view" returns to |
onViewportChange | (viewport: CanvasViewport) => void | undefined | Called whenever the viewport changes, whether controlled or not |
minZoom | number | 0.25 | Minimum zoom factor |
maxZoom | number | 2.5 | Maximum zoom factor |
showGrid | boolean | true | Whether to render the background dot grid |
gridSize | number | 32 | Spacing between grid dots, in canvas world units |
showControls | boolean | true | Whether to render the floating zoom in/out/reset control panel |
className | string | undefined | Additional CSS classes applied to the root container |
CanvasNode
| Prop | Type | Default | Description |
|---|---|---|---|
x | number | — | Horizontal position in the canvas's world space |
y | number | — | Vertical position in the canvas's world space |
width | number | string | undefined | Optional fixed width |
height | number | string | undefined | Optional fixed height |
className | string | undefined | Additional CSS classes |
children | React.ReactNode | — | Node content |
CanvasViewport
| Prop | Type | Description |
|---|---|---|
x | number | Horizontal pan offset, in pixels |
y | number | Vertical pan offset, in pixels |
zoom | number | Zoom factor (1 = 100%) |