A halftone screen in motion: dots swell and shrink with slow, crossing waves like a printed gradient breathing, and grow around the cursor.
Fresh off the press
Customize
Usage
import { HalftoneWave } from "@/components/motionkit/halftone-wave";
<HalftoneWave className="h-[32rem] rounded-3xl">
<h1>Your hero</h1>
</HalftoneWave>Motion
The exact curves this component runs, plotted with Motion's own maths.
Waves
Runs on its own
Editorial, print, fashion and design studio heroes, and textured section backgrounds that need movement without colour.
A canvas draws a grid of dots each frame. Every dot's size comes from two slow sine waves crossing at different angles, which is what makes the pattern look like a printed gradient breathing. The level is lowered towards the middle so text sits on smaller dots, and near the pointer a Gaussian swell grows them. Dots below half a pixel are skipped, and the canvas pauses off screen.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://www.motionkitui.com/r/halftone-wave.jsonnpm install motionSave as components/motionkit/halftone-wave.tsx.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type HalftoneWaveProps = {
children?: ReactNode;
/** Colour of the dots. */
color?: string;
/** Distance between dot centres, in pixels. */
spacing?: number;
/** Wave speed. 0 holds it still. */
speed?: number;
/** Swell the dots around the pointer. */
interactive?: boolean;
className?: string;
};
/**
* A halftone screen in motion: a grid of dots whose size follows slow, crossing waves, like a printed
* gradient that breathes, with dots that swell around the pointer.
*/
export function HalftoneWave({ children, color = "#5FAEB4", spacing = 14, speed = 1, interactive = true, className = "" }: HalftoneWaveProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
const area = canvas?.parentElement;
if (!canvas || !context || !area) return;
const still = speed === 0 || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let frame = 0;
let visible = true;
let elapsed = 0;
let last = 0;
const pointer = { x: 0, y: 0, strength: 0, target: 0 };
function resize() {
const rect = canvas!.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas!.width = width * ratio;
canvas!.height = height * ratio;
context!.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function draw(time: number) {
elapsed += last ? Math.min(0.05, (time - last) / 1000) * speed : 0;
last = time;
pointer.strength += (pointer.target - pointer.strength) * 0.08;
const t = elapsed * 0.6;
const max = spacing * 0.48;
context!.clearRect(0, 0, width, height);
context!.fillStyle = color;
for (let y = spacing / 2; y < height; y += spacing) {
for (let x = spacing / 2; x < width; x += spacing) {
const u = x / width;
const v = y / height;
// two waves crossing at different angles, kept fainter in the middle where text sits
let level = 0.5 + 0.25 * Math.sin(u * 6 + v * 2 + t) + 0.25 * Math.sin(v * 7 - u * 3 - t * 0.8);
const cx = u - 0.5;
const cy = v - 0.5;
level *= 0.25 + 0.75 * Math.min(1, (cx * cx + cy * cy) * 5);
if (pointer.strength > 0.01) {
const dx = x - pointer.x;
const dy = y - pointer.y;
level += Math.exp(-(dx * dx + dy * dy) / (2 * 90 * 90)) * pointer.strength * 0.8;
}
const radius = Math.min(1, level) * max;
if (radius < 0.4) continue;
context!.globalAlpha = 0.25 + Math.min(1, level) * 0.45;
context!.beginPath();
context!.arc(x, y, radius, 0, Math.PI * 2);
context!.fill();
}
}
context!.globalAlpha = 1;
}
function loop(time: number) {
draw(time);
frame = visible ? requestAnimationFrame(loop) : 0;
if (!frame) last = 0;
}
resize();
draw(0);
if (!still) frame = requestAnimationFrame(loop);
const resizeObserver = new ResizeObserver(() => {
resize();
draw(performance.now());
});
resizeObserver.observe(canvas);
const visibilityObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !frame && !still) frame = requestAnimationFrame(loop);
});
visibilityObserver.observe(canvas);
function move(event: PointerEvent) {
if (!interactive || still || event.pointerType === "touch") return;
const rect = canvas!.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.target = 1;
}
const leave = () => (pointer.target = 0);
area.addEventListener("pointermove", move);
area.addEventListener("pointerleave", leave);
return () => {
cancelAnimationFrame(frame);
resizeObserver.disconnect();
visibilityObserver.disconnect();
area.removeEventListener("pointermove", move);
area.removeEventListener("pointerleave", leave);
};
}, [color, spacing, speed, interactive]);
return (
<div className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 -z-10 size-full" />
{children}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | "#5FAEB4" | Dot colour. |
| spacing | number | 14 | Distance between dots, in pixels. |
| speed | number | 1 | Wave speed. 0 holds it still. |
| interactive | boolean | true | Swell the dots around the pointer. |
| children | ReactNode | — | Your content, layered above. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Nothing beyond React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://www.motionkitui.com/r/halftone-wave.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, it draws one still frame. It also stops drawing off screen.