Click anywhere
Customize
Usage
import { RippleGrid } from "@/components/motionkit/ripple-grid";
<RippleGrid className="h-80 rounded-2xl">
<p>Click anywhere</p>
</RippleGrid>Motion
The exact curves this component runs, plotted with Motion's own maths.
Tile flash
Click, plus once on load
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/ripple-grid.tsx.
"use client";
import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type RippleGridProps = {
children?: ReactNode;
/** Tile size in pixels. */
cell?: number;
/** Seconds the wave takes to cross one tile. */
spread?: number;
/** Colour of the lit tiles. */
color?: string;
className?: string;
};
type Ripple = { id: number; col: number; row: number };
export function RippleGrid({ children, cell = 36, spread = 0.035, color = "#0E7C86", className = "" }: RippleGridProps) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const [size, setSize] = useState({ cols: 0, rows: 0 });
const [ripple, setRipple] = useState<Ripple | null>(null);
// the number of tiles follows the container's size
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(() => {
// the full box, padding included: tiles sit behind the padding too
const { offsetWidth: width, offsetHeight: height } = element;
const cols = Math.ceil(width / cell);
const rows = Math.ceil(height / cell);
setSize({ cols, rows });
// one wave from the centre on first layout, so the effect introduces itself
if (!reduceMotion) setRipple((current) => current ?? { id: 1, col: Math.floor(cols / 2), row: Math.floor(rows / 2) });
});
observer.observe(element);
return () => observer.disconnect();
}, [cell, reduceMotion]);
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (reduceMotion) return;
const rect = event.currentTarget.getBoundingClientRect();
setRipple({
id: Date.now(),
col: Math.floor((event.clientX - rect.left) / cell),
row: Math.floor((event.clientY - rect.top) / cell),
});
}
return (
<div ref={ref} onPointerDown={handlePointerDown} className={`relative isolate overflow-hidden bg-[#0C1010] ${className}`}>
<div
aria-hidden
className="absolute inset-0 -z-10 grid"
style={{ gridTemplateColumns: `repeat(${size.cols}, ${cell}px)`, gridAutoRows: `${cell}px` }}
>
{Array.from({ length: size.cols * size.rows }, (_, index) => {
const col = index % size.cols;
const row = Math.floor(index / size.cols);
const distance = ripple ? Math.hypot(col - ripple.col, row - ripple.row) : 0;
return (
<div key={index} className="relative border-b border-r border-white/[0.05]">
{ripple && (
<motion.span
key={ripple.id}
className="absolute inset-0"
style={{ backgroundColor: color }}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.55 / (1 + distance * 0.15), 0] }}
transition={{ duration: 0.9, delay: distance * spread, ease: "easeOut" }}
/>
)}
</div>
);
})}
</div>
{children}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| cell | number | 36 | Tile size in pixels. |
| spread | number | 0.035 | Seconds the wave takes to cross one tile. |
| color | string | "#0E7C86" | Colour of the lit tiles. |
Ambient by default
Aurora Background
Drifting, blurred color fields for hero sections and empty states.
Move your cursor
Dot Glow Background
A quiet dot grid that lights up in teal around the pointer.
Warm, textured, alive
Grain Gradient
Slow, drifting colour with a fine film grain, for warm editorial heroes.