Grid wave
React + Tailwind
const N = 5;
const DOTS = Array.from({ length: N * N }, (_, k) => ({ x: k % N, y: Math.floor(k / N) }));
const css = `
@keyframes loader-grid-wave-ripple {
0%, 60%, 100% { transform: scale(0.55); background: light-dark(#cbced3, #393c41); }
20% { transform: scale(1.15); background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
35% { transform: scale(0.9); background: light-dark(#50535b, #c6c8cd); }
}
.loader-grid-wave { display: grid; grid-template-columns: repeat(${N}, 10px); gap: 9px; }
.loader-grid-wave span { width: 10px; height: 10px; border-radius: 50%; background: light-dark(#cbced3, #393c41); transform: scale(0.55); animation: loader-grid-wave-ripple 1.8s cubic-bezier(0.4, 0, 0.3, 1) infinite; animation-delay: calc((var(--x) + var(--y)) * 0.09s); }
@media (prefers-reduced-motion: reduce) {
.loader-grid-wave span { animation-duration: 3.6s; }
}
`;
export function GridWaveLoader() {
return (
<>
<style>
{css}
</style>
<div className="loader-grid-wave" role="status" aria-label="Loading">
{DOTS.map((d) => (
<span key={`${d.x}-${d.y}`} aria-hidden="true" style={{ '--x': d.x, '--y': d.y } as React.CSSProperties} />
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-grid-wave" role="status" aria-label="Loading">
<span aria-hidden="true" style="--x:0;--y:0"></span>
<span aria-hidden="true" style="--x:1;--y:0"></span>
<span aria-hidden="true" style="--x:2;--y:0"></span>
<span aria-hidden="true" style="--x:3;--y:0"></span>
<span aria-hidden="true" style="--x:4;--y:0"></span>
<span aria-hidden="true" style="--x:0;--y:1"></span>
<span aria-hidden="true" style="--x:1;--y:1"></span>
<span aria-hidden="true" style="--x:2;--y:1"></span>
<span aria-hidden="true" style="--x:3;--y:1"></span>
<span aria-hidden="true" style="--x:4;--y:1"></span>
<span aria-hidden="true" style="--x:0;--y:2"></span>
<span aria-hidden="true" style="--x:1;--y:2"></span>
<span aria-hidden="true" style="--x:2;--y:2"></span>
<span aria-hidden="true" style="--x:3;--y:2"></span>
<span aria-hidden="true" style="--x:4;--y:2"></span>
<span aria-hidden="true" style="--x:0;--y:3"></span>
<span aria-hidden="true" style="--x:1;--y:3"></span>
<span aria-hidden="true" style="--x:2;--y:3"></span>
<span aria-hidden="true" style="--x:3;--y:3"></span>
<span aria-hidden="true" style="--x:4;--y:3"></span>
<span aria-hidden="true" style="--x:0;--y:4"></span>
<span aria-hidden="true" style="--x:1;--y:4"></span>
<span aria-hidden="true" style="--x:2;--y:4"></span>
<span aria-hidden="true" style="--x:3;--y:4"></span>
<span aria-hidden="true" style="--x:4;--y:4"></span>
</div>CSS
@keyframes loader-grid-wave-ripple {
0%, 60%, 100% { transform: scale(0.55); background: light-dark(#cbced3, #393c41); }
20% { transform: scale(1.15); background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
35% { transform: scale(0.9); background: light-dark(#50535b, #c6c8cd); }
}
.loader-grid-wave { display: grid; grid-template-columns: repeat(5, 10px); gap: 9px; }
.loader-grid-wave span { width: 10px; height: 10px; border-radius: 50%; background: light-dark(#cbced3, #393c41); transform: scale(0.55); animation: loader-grid-wave-ripple 1.8s cubic-bezier(0.4, 0, 0.3, 1) infinite; animation-delay: calc((var(--x) + var(--y)) * 0.09s); }
@media (prefers-reduced-motion: reduce) {
.loader-grid-wave span { animation-duration: 3.6s; }
}Goal: Create a "Grid wave" loader.
Visual behavior: A five by five grid of dots with a ripple running through it from corner to corner, each dot swelling, brightening and settling as it passes.
Technique:
- Twenty-five dots in a CSS grid, each with --x and --y custom properties for its column and row.
- They share one keyframe that scales and lightens them, with animation-delay calc((var(--x) + var(--y)) * 0.09s), so the wave runs along the diagonal.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the ripple runs at half speed.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Grid wave" loader.
Visual behavior: A five by five grid of dots with a ripple running through it from corner to corner, each dot swelling, brightening and settling as it passes.
Technique:
- Twenty-five dots in a CSS grid, each with --x and --y custom properties for its column and row.
- They share one keyframe that scales and lightens them, with animation-delay calc((var(--x) + var(--y)) * 0.09s), so the wave runs along the diagonal.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the ripple runs at half speed.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A five by five grid of dots with a ripple running through it from corner to corner, each dot swelling, brightening and settling as it passes.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- continuous, loop, delay stagger
- Techniques
- keyframes, css variables, stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the ripple runs at half speed.