Click ripple grid
Clicking a cell sends a wave of colour outward through a grid of squares, ring by ring.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const CELL = 34;
const STEP_MS = 45;
const IDLE_MS = 3800;
const COLOURS = ["var(--accent)", "var(--accent-soft)", "var(--sand)", "var(--sky)"];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const grid = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [size, setSize] = useState({ cols: 0, rows: 0 });
const wave = useRef(0);
const lastClick = useRef(-Infinity);
useEffect(() => {
const el = root.current;
if (!el) return;
const observer = new ResizeObserver(() =>
setSize({ cols: Math.ceil(el.clientWidth / CELL), rows: Math.ceil(el.clientHeight / CELL) }),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Every cell gets a delay from its distance to the origin, then the class restarts the keyframes.
const ripple = (col: number, row: number) => {
const el = grid.current;
if (!el || reduced) return;
el.style.setProperty("--c", COLOURS[wave.current++ % COLOURS.length]);
const cells = el.children as HTMLCollectionOf<HTMLElement>;
for (let i = 0; i < cells.length; i++) {
const d = Math.hypot((i % size.cols) - col, Math.floor(i / size.cols) - row);
cells[i].style.animationDelay = `${Math.round(d * STEP_MS)}ms`;
cells[i].classList.remove(styles.hit);
}
void el.offsetWidth;
for (let i = 0; i < cells.length; i++) cells[i].classList.add(styles.hit);
};
// With no clicks, a ripple starts from a random cell every few seconds while on screen.
useEffect(() => {
if (!inView || reduced || !size.cols) return;
const idle = () => {
if (performance.now() - lastClick.current < IDLE_MS) return;
ripple(Math.floor(Math.random() * size.cols), Math.floor(Math.random() * size.rows));
};
const first = setTimeout(idle, 500);
const timer = setInterval(idle, IDLE_MS);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [inView, reduced, size]);
const fromPointer = (event: React.PointerEvent) => {
const rect = root.current!.getBoundingClientRect();
lastClick.current = performance.now();
ripple(Math.floor((event.clientX - rect.left) / CELL), Math.floor((event.clientY - rect.top) / CELL));
};
const fromKey = (event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
lastClick.current = performance.now();
ripple(Math.floor(size.cols / 2), Math.floor(size.rows / 2));
};
return (
<div
ref={root}
className={styles.root}
role="button"
tabIndex={0}
aria-label="Send a colour ripple across the grid"
onPointerDown={fromPointer}
onKeyDown={fromKey}
>
<div ref={grid} className={styles.grid} style={{ gridTemplateColumns: `repeat(${size.cols}, ${CELL}px)` }} aria-hidden="true">
{Array.from({ length: size.cols * size.rows }, (_, i) => (
<span key={i} onAnimationEnd={(e) => e.currentTarget.classList.remove(styles.hit)} />
))}
</div>
<span className={styles.label} aria-hidden="true">Click any cell</span>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; cursor: pointer; user-select: none; }
.root:focus-visible { outline-offset: -4px; }
.grid { --c: var(--accent); position: absolute; left: 0; top: 0; display: grid; grid-auto-rows: 34px; }
.grid span { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--surface); }
.hit { animation: wash .9s var(--out) both; }
@keyframes wash {
0% { background: var(--surface); scale: 1; }
18% { background: var(--c); scale: .82; border-radius: 6px; }
100% { background: var(--surface); scale: 1; }
}
.label { position: relative; padding: 8px 14px; border-radius: 999px; background: var(--white); border: 1px solid var(--line); font-size: 13px; font-weight: 600; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.hit { animation: none; }
}
Use it for
- Playful backgrounds on landing or empty pages
- Rewarding a click on decorative space
Avoid
- Grids with thousands of cells on low end phones
- Busy product screens, where the wave competes with content
How it works
The grid is sized from the container with a ResizeObserver. A click finds the cell under the pointer, then every cell gets an animation-delay of its distance to that cell times 45ms, and one keyframe class is restarted on all of them. Each wave picks the next token colour through a CSS variable. While idle on screen, a wave starts from a random cell every few seconds.
Accessibility
The cells are aria-hidden. The area is a focusable button with a label, and Enter or Space sends a wave from the centre. Reduced motion turns the waves off and leaves the plain grid.