data-display
Infinity Canvas
A flat-plane infinite photo gallery canvas with continuous zooming, physics-based drag inertia, and mouse parallax drift.
canvasgalleryinfinitezoomparallaxdraginertiainteractive
▶Preview
DRAG TO MOVE • WHEEL TO ZOOM
Adjust Infinity Canvas Parameters
Density (Images per cell)5
Image Tile Width150px
Image Tile Height150px
Border Radius6/20
Drag & Zoom Speed20
Parallax Drift Amount12
Inertia Friction10
Canvas Background
* Drag to navigate the infinite 2D plane with momentum. Scroll the mouse wheel to seamlessly zoom in/out of infinite octave layers. Mouse movements trigger subtle layout parallax drift.
🖲Usage
example-usage.tsx
import { InfinityCanvas } from "@/components/kanso/infinity-canvas"
export default function InfinityCanvasDemo() {
return (
<div className="w-full h-[500px] border border-zinc-200 dark:border-zinc-800 rounded-xl overflow-hidden bg-zinc-950/5 dark:bg-zinc-950/40">
<InfinityCanvas />
</div>
)
}↓Installation
1
Create folder & copy source
Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named infinity-canvas.tsx inside it.
2
Install dependencies
pnpm add framer-motion3
Required helper files
Ensure your project has the following helper files configured:
- →
lib/utils
<>Source Code
infinity-canvas.tsx
'use client';
import * as React from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useMotionValue } from 'framer-motion';
import { cn } from '@/lib/utils';
// Deterministic PRNG infrastructure to seed cell scattering
function hash3(cx: number, cy: number, cz: number, salt: number) {
let h = (cx | 0) * 0x8da6b343;
h ^= Math.imul(cy | 0, 0xd8163841);
h ^= Math.imul(cz | 0, 0xcb1ab31f);
h ^= salt | 0;
h ^= h >>> 16;
h = Math.imul(h, 0x7feb352d);
h ^= h >>> 15;
h = Math.imul(h, 0x846ca68b);
h ^= h >>> 16;
return h >>> 0;
}
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export type GalleryImage = {
src: string;
srcSet?: string;
alt?: string;
};
export interface InfinityCanvasProps {
width?: string | number;
height?: string | number;
className?: string;
images?: GalleryImage[];
density?: number;
imageWidth?: number;
imageHeight?: number;
rounded?: number;
dragSpeed?: number;
driftAmount?: number;
friction?: number;
backgroundColor?: string;
style?: React.CSSProperties;
}
export type Tile = {
wx: number;
wy: number;
cx: number;
cy: number;
slot: number;
octave: number;
imgIdx: number;
w: number;
h: number;
rot: number;
bakedScale: number;
};
const PX_PER_UNIT = 6;
const CELL_SIZE = 110;
const MAX_RANGE = 20;
const DEFAULT_IMAGES: GalleryImage[] = [
{
src: 'https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?auto=format&fit=crop&q=80&w=800',
alt: 'Cinematic anime cyberpunk',
},
{
src: 'https://images.unsplash.com/photo-1578632767115-351597cf2477?auto=format&fit=crop&q=80&w=800',
alt: 'Cozy art room scene',
},
{
src: 'https://images.unsplash.com/photo-1541562232579-512a21360020?auto=format&fit=crop&q=80&w=800',
alt: 'Japan street shrine vibes',
},
{
src: 'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&q=80&w=800',
alt: 'Tokyo neon cityscape',
},
{
src: 'https://images.unsplash.com/photo-1528164344705-47542687000d?auto=format&fit=crop&q=80&w=800',
alt: 'Japan Torii gate night',
},
{
src: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=800',
alt: 'Misty castle fantasy landscape',
},
{
src: 'https://images.unsplash.com/photo-1509198397868-475647b2a1e5?auto=format&fit=crop&q=80&w=800',
alt: 'Retro vaporwave game room',
},
{
src: 'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?auto=format&fit=crop&q=80&w=800',
alt: 'Vaporwave art illustration',
},
];
const COMPONENT_DEFAULTS = {
width: '100%',
height: '100%',
className: '',
images: DEFAULT_IMAGES,
density: 5,
imageWidth: 150,
imageHeight: 150,
rounded: 3,
dragSpeed: 20,
driftAmount: 20,
friction: 10,
backgroundColor: 'transparent',
};
const useIsStaticRenderer = () => false;
export function InfinityCanvas(props: InfinityCanvasProps) {
const mergedProps = { ...COMPONENT_DEFAULTS, ...props };
const {
width,
height,
className,
images,
density,
imageWidth,
imageHeight,
rounded,
dragSpeed,
driftAmount,
friction,
backgroundColor,
style,
} = mergedProps;
const containerRef = useRef<HTMLDivElement | null>(null);
const sceneRef = useRef<HTMLDivElement | null>(null);
const isStatic = useIsStaticRenderer();
// Clamp props into safe working ranges
const safeImages =
Array.isArray(images) && images.length > 0 ? images : DEFAULT_IMAGES;
const safeDensity = Math.max(1, Math.min(15, Math.floor(density)));
const safeImageWidth = Math.max(8, Math.min(4000, imageWidth));
const safeImageHeight = Math.max(8, Math.min(4000, imageHeight));
const safeRounded = Math.max(0, Math.min(20, rounded));
const safeDragSpeed = Math.max(0.1, Math.min(5, dragSpeed / 20));
const safeDriftAmount = Math.max(0, Math.min(20, driftAmount));
const safeFriction = 1 - (Math.max(1, Math.min(20, friction)) / 20) * 0.3;
// Camera & input state (motion values prevent React re-renders on every scroll/drag tick)
const targetX = useMotionValue(0);
const targetY = useMotionValue(0);
const camX = useMotionValue(0);
const camY = useMotionValue(0);
const velX = useMotionValue(0);
const velY = useMotionValue(0);
const targetLogZoom = useMotionValue(0);
const logZoom = useMotionValue(0);
const velLogZoom = useMotionValue(0);
const driftTX = useMotionValue(0);
const driftTY = useMotionValue(0);
const driftX = useMotionValue(0);
const driftY = useMotionValue(0);
// Cell generation logic
const subN = Math.max(1, Math.ceil(Math.sqrt(safeDensity)));
const subSize = CELL_SIZE / subN;
const SUBCELL_INNER_PAD = 0.1;
const effectivePerCell = Math.min(safeDensity, subN * subN);
const imagesCount = safeImages.length;
const SCALE_MIN = 0.45;
const SCALE_MAX = 1.6;
const generateCell = useMemo(() => {
return (gx: number, gy: number, octave: number): Tile[] => {
const seed = hash3(gx, gy, octave | 0, 0x9e3779b1);
const rand = mulberry32(seed);
const totalSubs = subN * subN;
const subs = new Array<number>(totalSubs);
for (let i = 0; i < totalSubs; i++) subs[i] = i;
for (let i = totalSubs - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
const tmp = subs[i];
subs[i] = subs[j];
subs[j] = tmp;
}
const tiles: Tile[] = [];
const count = Math.min(effectivePerCell, totalSubs);
const pad = subSize * SUBCELL_INNER_PAD;
const innerRange = Math.max(0, subSize - pad * 2);
const cellX0 = gx * CELL_SIZE;
const cellY0 = gy * CELL_SIZE;
const wWorld = safeImageWidth / PX_PER_UNIT;
const hWorld = safeImageHeight / PX_PER_UNIT;
for (let slot = 0; slot < count; slot++) {
const subIdx = subs[slot];
const sx = subIdx % subN;
const sy = Math.floor(subIdx / subN);
const wx = cellX0 + sx * subSize + pad + rand() * innerRange;
const wy = cellY0 + sy * subSize + pad + rand() * innerRange;
const bakedScale = SCALE_MIN + rand() * (SCALE_MAX - SCALE_MIN);
const imgIdx =
imagesCount > 0 ? Math.floor(rand() * imagesCount) % imagesCount : 0;
tiles.push({
wx,
wy,
cx: gx,
cy: gy,
slot,
octave,
imgIdx,
w: wWorld,
h: hWorld,
rot: 0,
bakedScale,
});
}
return tiles;
};
}, [
imagesCount,
safeImageWidth,
safeImageHeight,
subN,
subSize,
effectivePerCell,
]);
// Main rendering loop (runs once inside requestAnimationFrame)
useEffect(() => {
const scene = sceneRef.current;
const container = containerRef.current;
if (!scene) return;
let cW = container ? container.clientWidth || 900 : 900;
let cH = container ? container.clientHeight || 600 : 600;
const ro = new ResizeObserver(() => {
if (container) {
cW = container.clientWidth || cW;
cH = container.clientHeight || cH;
}
});
if (container) ro.observe(container);
const layerPools = new Map<
number,
{
tileEls: Map<string, HTMLDivElement>;
imgEls: Map<string, HTMLImageElement>;
}
>();
const getPool = (octave: number) => {
let pool = layerPools.get(octave);
if (!pool) {
pool = { tileEls: new Map(), imgEls: new Map() };
layerPools.set(octave, pool);
}
return pool;
};
const disposeLayer = (octave: number) => {
const pool = layerPools.get(octave);
if (!pool) return;
pool.tileEls.forEach((el) => {
if (el.parentNode === scene) scene.removeChild(el);
});
pool.tileEls.clear();
pool.imgEls.clear();
layerPools.delete(octave);
};
const disposeAllLayers = () => {
Array.from(layerPools.keys()).forEach(disposeLayer);
};
const removeTile = (octave: number, key: string) => {
const pool = layerPools.get(octave);
if (!pool) return;
const el = pool.tileEls.get(key);
if (el && el.parentNode === scene) scene.removeChild(el);
pool.tileEls.delete(key);
pool.imgEls.delete(key);
};
const ensureTile = (t: Tile): HTMLDivElement => {
const pool = getPool(t.octave);
const key = `${t.cx},${t.cy},${t.slot}`;
let el = pool.tileEls.get(key);
if (!el) {
el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = '50%';
el.style.top = '50%';
el.style.transformOrigin = '0 0';
el.style.willChange = 'transform, opacity';
el.style.pointerEvents = 'none';
el.dataset.tileKey = key;
// Apply native Kanso UI styling for a polished look and card feel
el.className = cn(
'border border-zinc-200 bg-zinc-100 shadow-sm overflow-hidden transition-shadow duration-300 dark:border-zinc-800/80 dark:bg-zinc-900'
);
const img = document.createElement('img');
const src = safeImages[t.imgIdx];
img.src = src?.src || '';
if (src?.srcSet) img.srcset = src.srcSet;
img.alt = src?.alt || '';
img.draggable = false;
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.style.display = 'block';
img.style.pointerEvents = 'none';
img.style.userSelect = 'none';
el.appendChild(img);
scene.appendChild(el);
pool.tileEls.set(key, el);
pool.imgEls.set(key, img);
}
return el;
};
const projectLayer = (
octave: number,
layerScale: number,
layerAlpha: number,
layerZBase: number,
cx: number,
cy: number
) => {
const pool = getPool(octave);
const camCellX = Math.floor(cx / CELL_SIZE);
const camCellY = Math.floor(cy / CELL_SIZE);
const worldHalfX = cW / 2 / (PX_PER_UNIT * layerScale);
const worldHalfY = cH / 2 / (PX_PER_UNIT * layerScale);
const rangeX = Math.min(MAX_RANGE, Math.ceil(worldHalfX / CELL_SIZE) + 1);
const rangeY = Math.min(MAX_RANGE, Math.ceil(worldHalfY / CELL_SIZE) + 1);
const visibleKeys = new Set<string>();
const tilesThisFrame: Tile[] = [];
for (let dy = -rangeY; dy <= rangeY; dy++) {
for (let dx = -rangeX; dx <= rangeX; dx++) {
const tiles = generateCell(camCellX + dx, camCellY + dy, octave);
for (let i = 0; i < tiles.length; i++) {
tilesThisFrame.push(tiles[i]);
}
}
}
const orderKeys: string[] = new Array(tilesThisFrame.length);
const orderScale: number[] = new Array(tilesThisFrame.length);
for (let i = 0; i < tilesThisFrame.length; i++) {
const t = tilesThisFrame[i];
const key = `${t.cx},${t.cy},${t.slot}`;
visibleKeys.add(key);
const dxPx = (t.wx - cx) * layerScale * PX_PER_UNIT;
const dyPx = (t.wy - cy) * layerScale * PX_PER_UNIT;
const s = t.bakedScale * layerScale;
const el = ensureTile(t);
const img = pool.imgEls.get(key);
const wPx = t.w * PX_PER_UNIT;
const hPx = t.h * PX_PER_UNIT;
el.style.transform = `translate3d(${dxPx}px, ${dyPx}px, 0) scale(${s}) rotate(${t.rot}deg) translate(${-wPx / 2}px, ${-hPx / 2}px)`;
el.style.width = `${wPx}px`;
el.style.height = `${hPx}px`;
el.style.opacity = String(layerAlpha);
// Dynamically compute border radius based on scale and tile dimension
if (img) {
const radiusPx = (safeRounded / 20) * (Math.min(wPx, hPx) / 2);
img.style.borderRadius = `${radiusPx}px`;
el.style.borderRadius = `${radiusPx}px`;
}
orderKeys[i] = key;
orderScale[i] = t.bakedScale;
}
for (const key of Array.from(pool.tileEls.keys())) {
if (!visibleKeys.has(key)) removeTile(octave, key);
}
const idxs = orderKeys.map((_, i) => i);
idxs.sort((a, b) => orderScale[a] - orderScale[b]);
for (let k = 0; k < idxs.length; k++) {
const el = pool.tileEls.get(orderKeys[idxs[k]]);
if (el) el.style.zIndex = String(layerZBase + k);
}
};
let lastOctaves: Set<number> = new Set();
const project = () => {
const cx = camX.get();
const cy = camY.get();
const lz = logZoom.get();
const octave = Math.floor(lz);
const frac = lz - octave;
const scaleCurrent = Math.pow(2, frac);
const scaleNext = Math.pow(2, frac - 1);
const alphaCurrent = 1 - frac;
const alphaNext = frac;
const zBaseCurrent = 0;
const zBaseNext = 100000;
projectLayer(octave, scaleCurrent, alphaCurrent, zBaseCurrent, cx, cy);
projectLayer(octave + 1, scaleNext, alphaNext, zBaseNext, cx, cy);
const nowOctaves = new Set<number>([octave, octave + 1]);
for (const o of Array.from(lastOctaves)) {
if (!nowOctaves.has(o)) disposeLayer(o);
}
for (const o of Array.from(layerPools.keys())) {
if (!nowOctaves.has(o)) disposeLayer(o);
}
lastOctaves = nowOctaves;
};
project();
if (isStatic) {
ro.disconnect();
return;
}
let raf = 0;
const loop = () => {
const tx = targetX.get() + velX.get();
const ty = targetY.get() + velY.get();
targetX.set(tx);
targetY.set(ty);
velX.set(velX.get() * safeFriction);
velY.set(velY.get() * safeFriction);
const vlz = velLogZoom.get();
if (vlz !== 0) {
targetLogZoom.set(targetLogZoom.get() + vlz);
velLogZoom.set(vlz * safeFriction);
}
driftX.set(lerp(driftX.get(), driftTX.get() * safeDriftAmount, 0.08));
driftY.set(lerp(driftY.get(), driftTY.get() * safeDriftAmount, 0.08));
camX.set(lerp(camX.get(), targetX.get() + driftX.get(), 0.18));
camY.set(lerp(camY.get(), targetY.get() + driftY.get(), 0.18));
logZoom.set(lerp(logZoom.get(), targetLogZoom.get(), 0.18));
project();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
disposeAllLayers();
};
}, [
generateCell,
safeFriction,
safeDriftAmount,
safeRounded,
safeImages,
isStatic,
camX,
camY,
logZoom,
targetX,
targetY,
targetLogZoom,
velX,
velY,
velLogZoom,
driftX,
driftY,
driftTX,
driftTY,
]);
// Input listeners: drag, mouse parallax, and wheel scroll zoom
useEffect(() => {
const el = containerRef.current;
if (!el || isStatic) return;
let dragging = false;
let lastPX = 0;
let lastPY = 0;
let lastT = 0;
let pid: number | null = null;
const onDown = (e: PointerEvent) => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
dragging = true;
pid = e.pointerId;
lastPX = e.clientX;
lastPY = e.clientY;
lastT = e.timeStamp;
try {
el.setPointerCapture(e.pointerId);
} catch {}
el.style.cursor = 'grabbing';
};
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
driftTX.set(Math.max(-1, Math.min(1, nx)));
driftTY.set(Math.max(-1, Math.min(1, ny)));
if (!dragging || e.pointerId !== pid) return;
const dpx = e.clientX - lastPX;
const dpy = e.clientY - lastPY;
const lz = logZoom.get();
const frac = lz - Math.floor(lz);
const effScale =
(1 - frac) * Math.pow(2, frac) + frac * Math.pow(2, frac - 1);
const dWorldX = (-dpx / (PX_PER_UNIT * effScale)) * safeDragSpeed;
const dWorldY = (-dpy / (PX_PER_UNIT * effScale)) * safeDragSpeed;
targetX.set(targetX.get() + dWorldX);
targetY.set(targetY.get() + dWorldY);
const dt = Math.max(1, e.timeStamp - lastT);
const k = 16 / dt;
velX.set(dWorldX * k);
velY.set(dWorldY * k);
lastPX = e.clientX;
lastPY = e.clientY;
lastT = e.timeStamp;
};
const onUp = (e: PointerEvent) => {
if (!dragging || e.pointerId !== pid) return;
dragging = false;
pid = null;
try {
el.releasePointerCapture(e.pointerId);
} catch {}
el.style.cursor = 'grab';
};
const onCancel = (e: PointerEvent) => onUp(e);
const onWheel = (e: WheelEvent) => {
e.preventDefault();
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= 16;
else if (e.deltaMode === 2) delta *= 400;
const step = -delta * 0.0015 * safeDragSpeed;
velLogZoom.set(velLogZoom.get() + step);
};
const onLeave = () => {
driftTX.set(0);
driftTY.set(0);
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onCancel);
el.addEventListener('wheel', onWheel, { passive: false });
el.addEventListener('pointerleave', onLeave);
el.style.cursor = 'grab';
return () => {
el.removeEventListener('pointerdown', onDown);
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onCancel);
el.removeEventListener('wheel', onWheel);
el.removeEventListener('pointerleave', onLeave);
};
}, [
isStatic,
safeDragSpeed,
targetX,
targetY,
velX,
velY,
velLogZoom,
logZoom,
driftTX,
driftTY,
]);
const resolveDim = (
v: string | number | undefined,
fallback: string
): string => {
if (v == null) return fallback;
if (typeof v === 'number') return `${v}px`;
return v;
};
const wrapperStyle: React.CSSProperties = {
position: 'relative',
width: resolveDim(width, '100%'),
height: resolveDim(height, '100%'),
overflow: 'hidden',
backgroundColor,
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
...style,
};
const sceneStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
};
return (
<div
ref={containerRef}
className={cn(
'relative w-full h-full overflow-hidden border border-zinc-200/50 bg-zinc-50/10 dark:border-zinc-800/50 dark:bg-zinc-950/20 rounded-xl',
className
)}
style={wrapperStyle}
>
<div ref={sceneRef} style={sceneStyle} />
</div>
);
}