Backgrounds
WaveField
Stacked lines ripple like a contour map, brightest in the middle. Pauses off screen.
Download
- React
- Tailwind CSS v4
components/ui/wave-field.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type WaveFieldProps = {
children?: ReactNode;
className?: string;
color?: string;
background?: string;
/** Number of wave lines. */
lines?: number;
/** Wave height in pixels. */
amplitude?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
/* WaveField: stacked lines that ripple like a contour map. Middle lines are brightest. */
export function WaveField({
children,
className = "",
color = "#a78bfa",
background = "#06050d",
lines = 16,
amplitude = 24,
speed = 1,
}: WaveFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!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;
const draw = () => {
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
const n = Math.max(lines, 2);
for (let i = 0; i < n; i++) {
const k = i / (n - 1);
const base = h * (0.15 + 0.7 * k);
ctx.globalAlpha = 0.12 + 0.6 * (1 - Math.abs(k - 0.5) * 2);
ctx.beginPath();
for (let x = 0; x <= w + 8; x += 8) {
const swell = 0.4 + 0.6 * Math.sin((Math.PI * x) / w);
const wave = Math.sin(x * 0.008 + time * 0.6 + i * 0.35) * 0.6 + Math.sin(x * 0.017 - time * 0.4 + i * 0.2) * 0.4;
const y = base + amplitude * swell * wave;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
time += last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (still || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
};
// Redraw on size change; only animate while on screen.
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
ro.observe(canvas);
io.observe(canvas);
return () => {
stop();
ro.disconnect();
io.disconnect();
};
}, [color, lines, amplitude, speed]);
return (
<div className={`relative overflow-hidden ${className}`} style={{ background }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/wave-field.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/wave-field-demo.tsx
import { WaveField } from "@/components/ui/wave-field"; export default function WaveFieldExample() { return ( <WaveField className="h-full"> <p className="absolute bottom-5 left-6 text-2xl font-medium text-white/90">Quiet signal</p> </WaveField> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the field. |
| color | string | "#a78bfa" | Line colour. |
| background | string | "#06050d" | Base colour. |
| lines | number | 16 | Number of wave lines. |
| amplitude | number | 24 | Wave height in pixels. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |