Website Sections
HeroCanvas
A hero over a sea of dots rolling toward you in perspective. The pointer lifts a swell that travels with it.
Download
- React
- Tailwind CSS v4
components/ui/hero-canvas.tsx
"use client";
import { useEffect, useId, useRef, type ElementType, type ReactNode } from "react";
type HeroCanvasProps = {
title: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Buttons or links under the text. */
children?: ReactNode;
/** Dot colour. */
color?: string;
/** Colour of the dots lifted by the pointer. */
accent?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** h1 by default. Use h2 if the page already has an h1. */
headingAs?: ElementType;
/** Set the height, background and text colour here. */
className?: string;
};
const ROWS = 46;
const SPACING = 0.22;
const CAMERA = 1.4;
/* HeroCanvas: a hero over a sea of dots rolling toward you in perspective. The pointer lifts a swell that travels with it. */
export function HeroCanvas({
title,
eyebrow,
subtitle,
children,
color = "#8b7fd6",
accent = "#22d3ee",
speed = 1,
headingAs: Heading = "h1",
className = "min-h-[36rem] bg-[#06060e] text-white",
}: HeroCanvasProps) {
const id = useId();
const box = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const section = box.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!section || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let time = 0;
let onScreen = true;
const pointer = { x: 0, y: 0, power: 0, on: false };
const draw = () => {
ctx.clearRect(0, 0, w, h);
const horizon = h * 0.42;
const focal = h * 0.42;
const travel = time * 0.35;
const flow = travel % SPACING;
// Far rows first, so near dots land on top.
for (let r = ROWS; r >= 1; r--) {
const z = r * SPACING - flow + 0.5;
const scale = focal / z;
const groundY = horizon + CAMERA * scale;
const reach = Math.ceil(w / 2 / (scale * SPACING)) + 1;
const depth = Math.min(1, (ROWS - r + 1) / 14);
for (let c = -reach; c <= reach; c++) {
const worldX = c * SPACING;
const swell = Math.sin(worldX * 0.9 + time * 0.6) * 0.1 + Math.sin((z + travel) * 1.3 - time * 0.5) * 0.14;
const x = w / 2 + worldX * scale;
const dx = (x - pointer.x) / (w * 0.14 + 60);
const dy = (groundY - pointer.y) / (h * 0.3 + 40);
const lift = pointer.power * Math.exp(-(dx * dx + dy * dy));
const y = groundY - (swell + lift * 0.5) * scale;
const size = Math.max(0.9, Math.min(3, scale * 0.016)) * (1 + lift);
ctx.globalAlpha = Math.min(1, depth * 0.85 + lift * 0.4);
ctx.fillStyle = lift > 0.2 ? accent : color;
ctx.fillRect(x - size / 2, y - size / 2, size, size);
}
}
ctx.globalAlpha = 1;
};
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
time += dt * speed;
pointer.power += ((pointer.on ? 1 : 0) - pointer.power) * Math.min(1, dt * 5);
draw();
raf = requestAnimationFrame(tick);
};
const start = () => {
if (still || raf || !onScreen) return;
last = performance.now();
raf = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
w = rect.width;
h = rect.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
};
const move = (e: PointerEvent) => {
if (still || e.pointerType !== "mouse") return;
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
pointer.on = true;
};
const leave = () => {
pointer.on = false;
};
const sizes = new ResizeObserver(resize);
sizes.observe(canvas);
const sight = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
if (onScreen) start();
else stop();
});
sight.observe(canvas);
section.addEventListener("pointermove", move);
section.addEventListener("pointerleave", leave);
start();
return () => {
stop();
sizes.disconnect();
sight.disconnect();
section.removeEventListener("pointermove", move);
section.removeEventListener("pointerleave", leave);
};
}, [color, accent, speed]);
return (
<section ref={box} aria-labelledby={id} className={`@container relative isolate overflow-hidden ${className}`}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
<div className="flex max-w-2xl flex-col px-5 pt-5 pb-16 @2xl:px-14 @2xl:pt-12 @2xl:pb-24">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70 @2xl:mb-4 @2xl:text-sm">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.6rem,8cqw,4rem)] leading-[1.06] font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 max-w-lg text-sm text-pretty opacity-70 @2xl:mt-5 @2xl:text-lg">{subtitle}</p>}
{children && <div className="mt-4 flex flex-wrap gap-3 @2xl:mt-8">{children}</div>}
</div>
</section>
);
}
1. Paste the Code tab into
components/ui/hero-canvas.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/hero-canvas-demo.tsx
import { HeroCanvas } from "@/components/ui/hero-canvas"; export default function HeroCanvasExample() { return ( <div className="h-full overflow-y-auto"> <HeroCanvas headingAs="h2" eyebrow="Signal" title="Read the data before it breaks" subtitle="Move your pointer across the sea." className="min-h-full bg-[#06060e] text-white" > <a href="#demo" className="rounded-lg bg-[#22d3ee] px-4 py-2 text-sm font-medium text-[#06060e]"> See a live demo </a> </HeroCanvas> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | The big line. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| children | ReactNode | — | Buttons or links under the text. |
| color | string | "#8b7fd6" | Dot colour. |
| accent | string | "#22d3ee" | Colour of the dots lifted by the pointer. |
| speed | number | 1 | 2 is twice as fast. |
| headingAs | ElementType | "h1" | Use h2 if the page already has an h1. |
| className | string | "min-h-[36rem] bg-[#06060e] text-white" | Set the height, background and text colour here. |