// src/components/RippleGridLoader/RippleGridLoader.tsx
import { motion, useReducedMotion } from "framer-motion";
import { useId, useMemo } from "react";
type RippleGridLoaderProps = {
className?: string;
/** Width and height in px */
size?: number;
/** Seconds for one full ripple cycle */
speed?: number;
/** Dots per row and column — odd numbers keep a true center dot */
grid?: number;
/** Where the ripple starts: from the center, or sweeping from the top-left corner */
origin?: "center" | "corner";
label?: string;
};
// viewBox is 100 x 100
const PADDING = 10;
// Colors blend from the ripple's origin (first) to the farthest dots (last)
const COLORS: [string, string, string] = ["#00E5DE", "#3D4DFF", "#FF3DF5"];
// How much of each cycle the wave takes to cross the grid (0–1)
const SPREAD = 0.4;
function mixHex(from: string, to: string, t: number) {
const a = parseInt(from.slice(1), 16);
const b = parseInt(to.slice(1), 16);
const channel = (shift: number) => {
const x = (a >> shift) & 255;
const y = (b >> shift) & 255;
return Math.round(x + (y - x) * t);
};
const hex = (channel(16) << 16) | (channel(8) << 8) | channel(0);
return `#${hex.toString(16).padStart(6, "0")}`;
}
function gradientAt(t: number) {
return t < 0.5
? mixHex(COLORS[0], COLORS[1], t * 2)
: mixHex(COLORS[1], COLORS[2], (t - 0.5) * 2);
}
export function RippleGridLoader({
className = "",
size = 140,
speed = 1.8,
grid = 7,
origin = "center",
label = "Loading",
}: RippleGridLoaderProps) {
const uid = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const { dots, rMin, rMax } = useMemo(() => {
const count = Math.max(2, Math.round(grid));
const spacing = (100 - PADDING * 2) / (count - 1);
const mid = (count - 1) / 2;
const [ox, oy] = origin === "corner" ? [0, 0] : [mid, mid];
const maxDist = Math.hypot(count - 1 - ox, count - 1 - oy);
const list = [];
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
const t = Math.hypot(col - ox, row - oy) / maxDist; // 0 at origin, 1 farthest
list.push({
key: `${row}-${col}`,
cx: PADDING + col * spacing,
cy: PADDING + row * spacing,
t,
color: gradientAt(t),
});
}
}
return {
dots: list,
rMin: spacing * 0.14,
rMax: spacing * 0.32,
};
}, [grid, origin]);
return (
<div
className={`flex flex-col items-center gap-4 ${className}`}
role="status"
aria-label={label || "Loading"}
>
<div
className="relative aspect-square"
style={{ width: size, maxWidth: "100%" }}
>
<svg
viewBox="0 0 100 100"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<filter
id={`ripple-glow-${uid}`}
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<feGaussianBlur stdDeviation="1.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={`url(#ripple-glow-${uid})`}>
{dots.map((dot) =>
reduce ? (
// Reduced motion: a still grid, brightest at the origin
<circle
key={dot.key}
cx={dot.cx}
cy={dot.cy}
r={(rMin + rMax) / 2}
fill={dot.color}
opacity={1 - dot.t * 0.6}
/>
) : (
<motion.circle
key={dot.key}
cx={dot.cx}
cy={dot.cy}
fill={dot.color}
initial={{ r: rMin, opacity: 0.25 }}
// Quick swell, slow settle, then rest until the next wave
animate={{
r: [rMin, rMax, rMin, rMin],
opacity: [0.25, 1, 0.25, 0.25],
}}
transition={{
duration: speed,
times: [0, 0.18, 0.45, 1],
ease: "easeInOut",
repeat: Infinity,
// Farther dots start later, so the wave travels outward
delay: dot.t * speed * SPREAD,
}}
/>
),
)}
</g>
</svg>
</div>
{label && (
<motion.span
aria-hidden="true"
className="text-sm font-medium tracking-[0.2em] text-neutral-400 uppercase"
animate={reduce ? {} : { opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
>
{label}
</motion.span>
)}
</div>
);
}
// Component Source: https://codekk.devComponent details
Overview, install command, dependencies, and project links.
A ripple grid loader for React built with TypeScript, Framer Motion, SVG, and Tailwind CSS. A square grid of glowing dots, 7 × 7 by default, pulses with a wave that keeps rippling outward, like a stone dropped into still water. Each dot's start time is based on its distance from the ripple's origin, so the wave spreads in a smooth circle rather than row by row. In each cycle, a dot swells and brightens quickly, settles back, then rests until the next wave arrives, which gives the motion a clear rhythm instead of a constant shimmer. The wave takes 40% of the cycle to cross the grid, so a new ripple always starts cleanly after the last one fades. Dot colors follow the ripple too, blending from cyan at the origin through blue to magenta at the farthest dots. The origin prop switches the effect: "center" ripples outward from the middle, and "corner" sweeps a diagonal wave from the top-left, so one component gives you two distinct loaders. Grid size is adjustable with the grid prop, and dot sizes scale with the spacing, so a 3 × 3 grid and an 11 × 11 grid both keep the same proportions. Dot positions, colors, and timing are calculated once with useMemo, and each dot animates its radius and opacity on a native SVG circle. An SVG blur filter adds a soft neon glow, and the filter ID comes from React's useId, so multiple loaders can share a page without conflicts. The wrapper uses role="status" with an accessible label, and the visuals are hidden from screen readers. When prefers-reduced-motion is enabled, the grid stays still, brightest at the origin and fading toward the edges. It works in Vite, Create React App, and Next.js.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
Similar Components
More from the same category




