Swish UI
All componentsBackgrounds Free

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.

Found on