A cobalt blue hero standing on a floor of isometric tiles. Each tile is a small blue slab drawn on a canvas with a top and two shaded sides. A wave crosses the floor diagonally and raises a ridge of tiles as it passes, and the tiles under the pointer rise into a soft mound that trails behind fast movement. The higher a tile stands, the further its faces shift from cobalt to white, so crests read as bright blocks standing out of the floor. The far edge of the floor fades into the page so the headline, copy and two buttons stay clean, and a stat strip floats over the near edge. With no pointer, an unseen hand sweeps the floor. On phones the tiles are smaller and the buttons stack.
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
/** Width of one tile in pixels on wide screens and on phones. A tile is half as tall as it is wide. */
const TILE = 64;
const TILE_PHONE = 46;
/** Share of each grid cell the tile covers. 1 closes the gaps. */
const FILL = 0.88;
/** Thickness of a resting tile and the most a tile can rise, in pixels. */
const SLAB = 7;
const MAX_LIFT = 92;
/** The wave: height in pixels, distance between crests in tiles and speed in tiles per second. */
const WAVE_HEIGHT = 50;
const WAVE_LENGTH = 15;
const WAVE_SPEED = 3.2;
/** The pointer: how high it lifts tiles and how far the lift reaches, in tiles. */
const POINTER_LIFT = 84;
const POINTER_REACH = 2;
// Colours as red, green, blue: a resting tile, and the same three faces at full height.
const REST = { top: [52, 86, 238], left: [27, 52, 178], right: [19, 37, 138] };
const PEAK = { top: [255, 255, 255], left: [196, 208, 255], right: [150, 168, 250] };
/** Where the floor starts, as a share of the hero height, on wide screens and on phones. */
const FLOOR_TOP = 0.45;
const FLOOR_TOP_PHONE = 0.52;
const SHADES = 28;
const stats = [
{ value: "11 s", label: "to add a region" },
{ value: "99.995%", label: "measured uptime" },
{ value: "0", label: "idle servers billed" },
];
export default function IsometricTileGridBackgroundWithWaveAndPointerLift() {
const reduce = !!useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
// Every face colour for every height step is worked out once, so the loop only picks from a list.
const blend = (a: number[], b: number[], f: number) => `rgb(${a.map((v, i) => Math.round(v + (b[i] - v) * f)).join(",")})`;
const tops: string[] = [];
const lefts: string[] = [];
const rights: string[] = [];
for (let i = 0; i <= SHADES; i++) {
const f = Math.pow(i / SHADES, 0.7);
tops.push(blend(REST.top, PEAK.top, f));
lefts.push(blend(REST.left, PEAK.left, f));
rights.push(blend(REST.right, PEAK.right, f));
}
let w = 0;
let h = 0;
let tw = TILE;
let th = TILE / 2;
let cols = 0;
let rows = 0;
let top = 0;
let lift = new Float32Array(0);
let frame = 0;
let onScreen = true;
const began = performance.now();
let pointer: { x: number; y: number } | null = null;
const hand = { x: 0, y: 0, set: false };
const draw = (now: number, settle: number) => {
const t = (now - began) / 1000;
// With no visitor, an unseen hand sweeps the floor in a slow figure of eight.
const tx = pointer ? pointer.x : w * (0.5 + 0.36 * Math.sin(t * 0.55 + 0.4));
const ty = pointer ? pointer.y : top + (h - top) * (0.52 + 0.24 * Math.sin(t * 1.1 + 1.3));
if (!hand.set) {
hand.x = tx;
hand.y = ty;
hand.set = true;
}
hand.x += (tx - hand.x) * 0.12;
hand.y += (ty - hand.y) * 0.12;
ctx.clearRect(0, 0, w, h);
const hw = (tw / 2) * FILL;
const hh = (th / 2) * FILL;
const reach = 1 / (POINTER_REACH * POINTER_REACH);
// Rows are drawn from the back to the front, so nearer tiles overlap the ones behind them.
for (let r = 0; r < rows; r++) {
const y = top + (r * th) / 2;
for (let c = r % 2; c < cols; c += 2) {
const x = (c * tw) / 2;
// Distance from the hand measured in tiles on the floor, not in screen pixels.
const gx = (x - hand.x) / tw;
const gy = (y - hand.y) / th;
const near = Math.exp(-(gx * gx + gy * gy) * reach);
// The wave travels along one of the two floor axes, so its crests run diagonally across the screen.
const along = (c + r) / 2;
const crest = Math.max(0, Math.sin(((along - t * WAVE_SPEED) / WAVE_LENGTH) * Math.PI * 2));
const want = Math.min(MAX_LIFT, WAVE_HEIGHT * crest * crest + POINTER_LIFT * near);
const n = r * cols + c;
// Each tile eases towards its target, which leaves a soft wake behind the pointer.
lift[n] += (want - lift[n]) * settle;
const z = lift[n];
const shade = Math.min(SHADES, Math.round((z / MAX_LIFT) * SHADES));
const ty0 = y - z;
ctx.fillStyle = lefts[shade];
ctx.beginPath();
ctx.moveTo(x - hw, ty0);
ctx.lineTo(x, ty0 + hh);
ctx.lineTo(x, y + hh + SLAB);
ctx.lineTo(x - hw, y + SLAB);
ctx.fill();
ctx.fillStyle = rights[shade];
ctx.beginPath();
ctx.moveTo(x + hw, ty0);
ctx.lineTo(x, ty0 + hh);
ctx.lineTo(x, y + hh + SLAB);
ctx.lineTo(x + hw, y + SLAB);
ctx.fill();
ctx.fillStyle = tops[shade];
ctx.beginPath();
ctx.moveTo(x, ty0 - hh);
ctx.lineTo(x + hw, ty0);
ctx.lineTo(x, ty0 + hh);
ctx.lineTo(x - hw, ty0);
ctx.fill();
}
}
};
const loop = (now: number) => {
draw(now, 0.14);
frame = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(frame);
if (reduce || !onScreen || document.hidden) return;
frame = requestAnimationFrame(loop);
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = root.clientWidth;
h = root.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tw = w < 640 ? TILE_PHONE : TILE;
th = tw / 2;
// The floor starts part of the way down; the canvas mask fades its far edge into the page.
top = h * (w < 640 ? FLOOR_TOP_PHONE : FLOOR_TOP);
cols = Math.ceil(w / (tw / 2)) + 2;
rows = Math.ceil((h - top) / (th / 2)) + 3;
lift = new Float32Array(cols * rows);
hand.set = false;
draw(performance.now(), 1);
};
const move = (e: PointerEvent) => {
const box = root.getBoundingClientRect();
pointer = { x: e.clientX - box.left, y: e.clientY - box.top };
if (reduce) {
hand.set = false;
draw(performance.now(), 1);
}
};
const leave = () => (pointer = null);
let lastW = 0;
let lastH = 0;
const sizes = new ResizeObserver(() => {
if (root.clientWidth === lastW && root.clientHeight === lastH) return;
lastW = root.clientWidth;
lastH = root.clientHeight;
resize();
});
sizes.observe(root);
const view = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
start();
});
view.observe(root);
root.addEventListener("pointermove", move);
root.addEventListener("pointerleave", leave);
root.addEventListener("pointercancel", leave);
document.addEventListener("visibilitychange", start);
start();
return () => {
cancelAnimationFrame(frame);
sizes.disconnect();
view.disconnect();
root.removeEventListener("pointermove", move);
root.removeEventListener("pointerleave", leave);
root.removeEventListener("pointercancel", leave);
document.removeEventListener("visibilitychange", start);
};
}, [reduce]);
const rise = (delay: number) => ({
initial: reduce ? false : ({ opacity: 0, y: 18 } as const),
animate: { opacity: 1, y: 0 },
transition: { duration: 0.8, delay, ease: [0.22, 0.61, 0.36, 1] as const },
});
const fadeIn = "linear-gradient(180deg, transparent 41%, #000 68%)";
return (
<section ref={rootRef} className="relative min-h-[800px] w-full overflow-hidden bg-[#2243dd] text-white">
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" style={{ maskImage: fadeIn, WebkitMaskImage: fadeIn }} aria-hidden="true" />
<div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-5 pt-16 text-center sm:pt-20">
<motion.p {...rise(0.05)} className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3.5 py-1.5 text-xs font-semibold text-white ring-1 ring-white/30">
<span className="h-2 w-2 rotate-45 bg-white" aria-hidden="true" />
Gridwell Compute · now in 14 regions
</motion.p>
<motion.h1 {...rise(0.15)} className="mt-6 text-5xl font-semibold leading-[1.03] tracking-tight sm:text-7xl">
Capacity that rises when you do.
</motion.h1>
<motion.p {...rise(0.3)} className="mt-6 max-w-xl text-base leading-relaxed text-blue-50 sm:text-lg">
Gridwell adds compute one block at a time, the moment traffic climbs, and folds it away when the rush has passed. You pay for the blocks that are standing.
</motion.p>
<motion.div {...rise(0.42)} className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-white px-7 text-sm font-semibold text-[#1a2f9e] shadow-lg shadow-blue-950/30 transition hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#2243dd] sm:w-auto">
Deploy your first block
</a>
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-lg px-7 text-sm font-semibold text-white ring-1 ring-inset ring-white/50 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#2243dd] sm:w-auto">
Read the scaling guide
</a>
</motion.div>
</div>
<motion.dl {...rise(0.6)} className="absolute inset-x-0 bottom-6 z-10 mx-auto flex w-fit max-w-[calc(100%-2rem)] divide-x divide-slate-900/10 rounded-xl bg-white shadow-2xl shadow-blue-950/40">
{stats.map((s) => (
<div key={s.label} className="px-4 py-3 text-center sm:px-7 sm:py-4">
<dt className="order-2 text-[11px] font-medium leading-tight text-slate-600 sm:text-xs">{s.label}</dt>
<dd className="text-lg font-semibold tracking-tight text-slate-900 sm:text-2xl">{s.value}</dd>
</div>
))}
</motion.dl>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/isometric-tile-grid-background-with-wave-and-pointer-lift.json
Tiles sit on a diamond grid: each row is offset by half a tile, and a tile's screen position is its column times half the tile width and its row times half the tile height. Every tile is three filled shapes, a diamond for the top and two parallelograms for the sides, and raising a tile just moves the top diamond up and stretches the sides.
They are drawn row by row from the back of the floor to the front. A tall tile at the back is painted first, and the tiles in front of it are painted over its base, which is all the depth sorting an isometric view needs.
The pointer position is treated as a point on the floor. For each tile the distance to that point is measured in tile units, with the vertical distance doubled to undo the isometric squash, and a bell curve of that distance gives the lift. That makes the mound round on the floor instead of round on the screen.
A laptop hero draws around seven hundred tiles, so about two thousand small fills per frame with colours picked from a prepared list. That stays within a couple of milliseconds. Larger `TILE` values mean fewer tiles.
Yes. Set `REST` to dark greys, `PEAK` to a bright accent and the section background to the same dark colour. The text is already white. For a light version do the reverse and switch the text classes to dark.
Related: Interactive Grid Background That Lights Under the Pointer, Dot Matrix Background with Ripples and Pointer Spotlight, Grid Background with Fading Random Squares.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.