Dot Grid Card That Ripples Away from the Pointer

A light card filled with a grid of small dots that twinkle at slightly different rhythms. Move a pointer across it and the dots nearby are pushed away, grow a little and darken, then ease back when the pointer moves on. The grid thins out towards the middle to leave room for a case study title, a sentence and two buttons. When nobody is pointing at the card, an invisible finger wanders around it so the pattern keeps moving. Everything is drawn on one canvas and the card scales down to a 360 pixel screen.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/mouse-effect-card.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { motion, useReducedMotion } from "framer-motion";

// Tune the pattern here.
const DOT_SIZE = 2.4; // diameter in pixels
const DOT_SPACING = 16; // distance between dots
const REPEL_RADIUS = 90; // how close the pointer must be to push a dot
const REPEL_STRENGTH = 22; // how far a dot is pushed, in pixels
const DOT_COLOUR = "63, 63, 70"; // zinc-700 as RGB

type Dot = { x: number; y: number; ox: number; oy: number; base: number; phase: number; glow: number };

/** Repeatable pseudo-random value between 0 and 1 for a grid cell. */
function cellNoise(col: number, row: number): number {
  const n = Math.sin(col * 127.1 + row * 311.7) * 43758.5453;
  return n - Math.floor(n);
}

export default function DotGridCardThatRipplesAwayFromThePointer() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const pointer = useRef({ x: 0, y: 0, active: false });
  const reduce = useReducedMotion();

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = canvas?.parentElement;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !wrap || !ctx) return;

    let w = 0;
    let h = 0;
    let frame = 0;
    let onScreen = true;
    let dots: Dot[] = [];

    const build = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = wrap.clientWidth;
      h = wrap.clientHeight;
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      const cx = w / 2;
      const cy = h / 2;
      const reach = Math.hypot(cx, cy) * 0.7;
      dots = [];
      for (let row = 0; row <= Math.ceil(h / DOT_SPACING); row++) {
        for (let col = 0; col <= Math.ceil(w / DOT_SPACING); col++) {
          const x = col * DOT_SPACING;
          const y = row * DOT_SPACING;
          // Dots thin out towards the middle, which leaves a clear area for the text.
          const edge = Math.min(Math.hypot(x - cx, y - cy) / reach, 1);
          if (cellNoise(col, row) > edge) continue;
          dots.push({ x, y, ox: 0, oy: 0, base: [0.3, 0.5, 0.7][(row + col) % 3] * edge, phase: (row * 7 + col * 3) * 0.35, glow: 0 });
        }
      }
    };

    const paint = (time: number) => {
      const t = time / 1000;
      // With nobody pointing at the card, an invisible finger wanders around it so the pattern stays alive.
      let px = pointer.current.x;
      let py = pointer.current.y;
      let live = pointer.current.active;
      if (!live && !reduce) {
        px = w / 2 + Math.cos(t * 0.7) * w * 0.34;
        py = h / 2 + Math.sin(t * 1.15) * h * 0.32;
        live = true;
      }
      ctx.clearRect(0, 0, w, h);
      for (const d of dots) {
        let tx = 0;
        let ty = 0;
        let near = 0;
        if (live) {
          const dx = d.x - px;
          const dy = d.y - py;
          const dist = Math.hypot(dx, dy);
          if (dist < REPEL_RADIUS) {
            const force = (1 - dist / REPEL_RADIUS) * REPEL_STRENGTH;
            const angle = Math.atan2(dy, dx);
            tx = Math.cos(angle) * force;
            ty = Math.sin(angle) * force;
          }
          if (dist < REPEL_RADIUS * 1.2) near = (1 - dist / (REPEL_RADIUS * 1.2)) * 0.8;
        }
        // Ease towards the target, which reads as a soft spring.
        d.ox += (tx - d.ox) * 0.16;
        d.oy += (ty - d.oy) * 0.16;
        d.glow += (near - d.glow) * 0.12;
        const twinkle = reduce ? 0.5 : 0.5 + 0.5 * Math.sin(t * 5 + d.phase);
        const low = Math.max(d.base * 0.5, 0.25);
        const high = Math.min(d.base * 1.5, 1);
        const alpha = Math.min(low + (high - low) * twinkle + d.glow, 1);
        ctx.fillStyle = `rgba(${DOT_COLOUR}, ${alpha.toFixed(2)})`;
        ctx.beginPath();
        ctx.arc(d.x + d.ox, d.y + d.oy, DOT_SIZE / 2 + d.glow * 0.8, 0, Math.PI * 2);
        ctx.fill();
      }
    };

    const loop = (time: number) => {
      paint(time);
      frame = requestAnimationFrame(loop);
    };
    const start = () => {
      cancelAnimationFrame(frame);
      if (!onScreen || document.hidden) return;
      frame = requestAnimationFrame(loop);
    };

    const sizeWatcher = new ResizeObserver(() => {
      build();
      paint(performance.now());
    });
    sizeWatcher.observe(wrap);
    const viewWatcher = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      start();
    });
    viewWatcher.observe(wrap);
    document.addEventListener("visibilitychange", start);
    build();
    start();

    return () => {
      cancelAnimationFrame(frame);
      sizeWatcher.disconnect();
      viewWatcher.disconnect();
      document.removeEventListener("visibilitychange", start);
    };
  }, [reduce]);

  const track = (event: PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    pointer.current = { x: event.clientX - rect.left, y: event.clientY - rect.top, active: true };
  };
  const leave = () => {
    pointer.current.active = false;
  };
  // Arrow keys move the ripple, so the effect is not limited to a mouse.
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const moves: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
    const move = moves[event.key];
    if (!move || event.target !== event.currentTarget) return;
    event.preventDefault();
    const rect = event.currentTarget.getBoundingClientRect();
    const p = pointer.current;
    const x = p.active ? p.x : rect.width / 2;
    const y = p.active ? p.y : rect.height / 2;
    pointer.current = { x: Math.max(0, Math.min(rect.width, x + move[0] * 40)), y: Math.max(0, Math.min(rect.height, y + move[1] * 40)), active: true };
  };

  const rise = (delay: number) => ({
    initial: reduce ? false : ({ opacity: 0, y: 12 } as const),
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.6, delay, ease: [0.22, 1, 0.36, 1] as const },
  });

  return (
    <div className="flex w-full items-center justify-center bg-zinc-200 px-4 py-14">
      <div
        className="relative h-[440px] w-full max-w-xl overflow-hidden rounded-3xl border border-white bg-zinc-50 shadow-xl shadow-zinc-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-200"
        onPointerMove={track}
        onPointerDown={track}
        onPointerLeave={leave}
        onBlur={leave}
        onKeyDown={onKeyDown}
        tabIndex={0}
        role="group"
        aria-label="Case study card with an interactive dot pattern. Arrow keys move the ripple."
      >
        <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />

        <div className="absolute left-6 top-6 z-10">
          <div className="absolute -inset-3 rounded-xl bg-zinc-50/80 blur-lg" aria-hidden="true" />
          <p className="relative text-sm font-bold text-zinc-900">Case study</p>
          <p className="relative text-xs font-medium text-zinc-600">Northline Cycles, 2025</p>
        </div>
        <div className="absolute right-6 top-6 z-10">
          <div className="absolute -inset-3 rounded-xl bg-zinc-50/80 blur-lg" aria-hidden="true" />
          <p className="relative font-mono text-xs font-medium text-zinc-600">04 / 12</p>
        </div>

        <div className="relative z-10 flex h-full flex-col items-center justify-center px-6 text-center">
          <div className="relative z-10">
            <motion.h2 {...rise(0.1)} className="relative text-4xl font-bold tracking-tight text-zinc-900 sm:text-5xl">
              Quiet until you
              <br />
              get close
            </motion.h2>
          </div>
          <div className="relative mt-5">
            <motion.p {...rise(0.2)} className="relative max-w-sm text-base font-medium leading-relaxed text-zinc-700">
              How a bike shop rebuilt its booking page around one idea: show less, respond more.
            </motion.p>
          </div>
          <motion.div {...rise(0.3)} className="mt-7 flex flex-wrap items-center justify-center gap-3">
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-full bg-zinc-900 px-7 text-sm font-semibold text-white shadow-lg transition hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2">
              Read the story
            </a>
            <a href="#" className="inline-flex h-12 items-center justify-center rounded-full border border-zinc-300 bg-white px-7 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2">
              See the numbers
            </a>
          </motion.div>
        </div>

        <div className="absolute inset-x-0 bottom-6 z-10 flex justify-center">
          <div className="relative">
            <div className="absolute -inset-2 rounded-full bg-zinc-50/80 blur-lg" aria-hidden="true" />
            <p className="relative px-4 text-xs font-medium text-zinc-600">Six minute read</p>
          </div>
        </div>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/dot-grid-card-that-ripples-away-from-the-pointer.json

When to use it

How to customize it

Accessibility

Questions

Why draw the dots on a canvas?

A grid this size has several hundred dots. As separate elements each would need its own transform and opacity on every pointer move. One canvas paints them all in a single pass and stays smooth on phones.

How does the push work?

For each dot the distance to the pointer is measured. Inside the radius, the dot gets a target offset pointing directly away, larger the closer it is. Each frame the dot moves a fraction of the way to its target, which gives the soft, springy return.

Why are there fewer dots in the middle?

Each grid cell has a fixed pseudo-random number. A dot is kept only if that number is below its distance from the centre, so the chance of a dot rises towards the edges and the text area stays clean.

Does it work on touch screens?

Yes. It listens to pointer events, so dragging a finger across the card moves the ripple. Lifting the finger hands control back to the idle wander.

Can I use it as a full section background?

Yes. Remove the fixed height and width classes from the card and give the wrapper the size you need. The canvas follows its parent through a ResizeObserver.

Adapted from Mouse Effect Card in Kokonut UI, used under its open-source licence. Licences and credits

Related: Spotlight Cards with Pointer Light, 3D Tilt Card with Parallax Layers and Glare, Dot Matrix Background with Ripples and Pointer Spotlight.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap