Skip to content

Shimmering Grid Dots

Random dots twinkle across a grid.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/shimmering-dots.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/random.ts
    /**
     * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed
     * gives the same sequence on the server and in the first client frame, so nothing shifts or
     * mismatches on hydration. Not for anything security-related.
     */
    
    /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */
    export function hashSeed(seed: string | number): number {
      const text = typeof seed === "number" ? `n${seed}` : seed;
      // FNV-1a, then a final avalanche so short, similar seeds still diverge.
      let h = 0x811c9dc5;
      for (let i = 0; i < text.length; i++) {
        h ^= text.charCodeAt(i);
        h = Math.imul(h, 0x01000193);
      }
      h ^= h >>> 16;
      h = Math.imul(h, 0x7feb352d);
      h ^= h >>> 15;
      return h >>> 0;
    }
    
    /** mulberry32: a small, fast generator of floats in [0, 1). */
    export function mulberry32(state: number): () => number {
      let a = state >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    export interface Random {
      /** A float in [0, 1). */
      next(): number;
      /** A float in [min, max). */
      range(min: number, max: number): number;
      /** An integer from min to max, both included. */
      int(min: number, max: number): number;
      /** True with probability `p`. */
      chance(p: number): boolean;
      /** One item of a non-empty list. */
      pick<T>(list: readonly T[]): T;
      /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */
      fork(salt: string | number): Random;
    }
    
    /** A seeded generator with a few helpers on top of `mulberry32`. */
    export function createRandom(seed: string | number): Random {
      const base = hashSeed(seed);
      const next = mulberry32(base);
      return {
        next,
        range: (min, max) => min + next() * (max - min),
        int: (min, max) => min + Math.floor(next() * (max - min + 1)),
        chance: (p) => next() < p,
        pick: (list) => list[Math.floor(next() * list.length)],
        fork: (salt) => createRandom(`${base}:${salt}`),
      };
    }
  3. Copy the source

    components/ui/shimmering-dots/shimmering-dots.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps } from "react";
    import { observeTheme } from "@/lib/color";
    import { createRandom } from "@/lib/random";
    import { cn } from "@/lib/utils";
    
    export interface ShimmeringDotsProps extends ComponentProps<"div"> {
      /** Distance between dots in px. @default 14 */
      gap?: number;
      /** Dot size in px (square dots). @default 2 */
      size?: number;
      /** Dot color; any CSS color. @default "var(--primary)" */
      color?: string;
      /** Opacity of a resting dot. @default 0.16 */
      baseOpacity?: number;
      /** Opacity at the peak of a twinkle. @default 1 */
      peakOpacity?: number;
      /** Fraction of dots that twinkle, 0–1. @default 0.22 */
      density?: number;
      /** Twinkle speed multiplier. @default 1 */
      speed?: number;
      /** Fade the grid out toward the edges. @default true */
      fade?: boolean;
      /** Seed for which dots twinkle and when; the same seed and size give the same pattern. @default "dots" */
      seed?: string | number;
    }
    
    type RGBA = [number, number, number, number];
    
    function resolveColor(host: HTMLElement, color: string): RGBA {
      const probe = document.createElement("span");
      probe.style.color = color;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0, 1];
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b, a / 255];
    }
    
    const EDGE_FADE = "radial-gradient(ellipse at center, #000 45%, transparent 85%)";
    
    export function ShimmeringDots({
      gap = 14,
      size = 2,
      color = "var(--primary)",
      baseOpacity = 0.16,
      peakOpacity = 1,
      density = 0.22,
      speed = 1,
      fade = true,
      seed = "dots",
      className,
      style,
      children,
      ...props
    }: ShimmeringDotsProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
    
      useEffect(() => {
        const root = rootRef.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!root || !canvas || !ctx) return;
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        let width = 0;
        let height = 0;
        let cols = 0;
        let rows = 0;
        // Per dot: phase and frequency; frequency 0 means the dot never twinkles.
        let phase = new Float32Array(0);
        let freq = new Float32Array(0);
        let frame = 0;
        let visible = true;
    
        const draw = (t: number) => {
          ctx.clearRect(0, 0, width, height);
          const ox = (width - (cols - 1) * gap - size) / 2;
          const oy = (height - (rows - 1) * gap - size) / 2;
          for (let i = 0; i < cols * rows; i++) {
            let o = baseOpacity;
            if (freq[i]) {
              // Sharpened sine: dots spend most of their time dim and flash briefly. With reduced motion
              // the still frame is one moment of it, so a few dots stay lit.
              const s = Math.sin((reduce ? 0 : t * 0.001 * freq[i] * speed) + phase[i]);
              if (s > 0) o += (peakOpacity - baseOpacity) * s ** 6;
            }
            ctx.globalAlpha = o;
            ctx.fillRect(ox + (i % cols) * gap, oy + Math.floor(i / cols) * gap, size, size);
          }
          ctx.globalAlpha = 1;
        };
    
        const tick = (t: number) => {
          draw(t);
          frame = requestAnimationFrame(tick);
        };
        const run = () => {
          cancelAnimationFrame(frame);
          if (!reduce && visible) frame = requestAnimationFrame(tick);
          else draw(0);
        };
    
        const layout = () => {
          width = root.clientWidth;
          height = root.clientHeight;
          const dpr = Math.min(window.devicePixelRatio || 1, 2);
          canvas.width = Math.round(width * dpr);
          canvas.height = Math.round(height * dpr);
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          cols = Math.floor(width / gap) + 1;
          rows = Math.floor(height / gap) + 1;
          phase = new Float32Array(cols * rows);
          freq = new Float32Array(cols * rows);
          const random = createRandom(seed);
          for (let i = 0; i < cols * rows; i++) {
            phase[i] = random.next() * Math.PI * 2;
            freq[i] = random.chance(density) ? random.range(0.6, 2.2) : 0;
          }
          paint();
          draw(performance.now());
        };
        const paint = () => {
          const [r, g, b] = resolveColor(root, color);
          ctx.fillStyle = `rgb(${r},${g},${b})`;
        };
    
        layout();
        run();
        const resizeObserver = new ResizeObserver(() => {
          layout();
          run();
        });
        resizeObserver.observe(root);
        const intersection = new IntersectionObserver(([entry]) => {
          visible = entry.isIntersecting;
          run();
        });
        intersection.observe(root);
        // A theme switch changes what the color resolves to.
        const unobserveTheme = observeTheme(() => {
          paint();
          draw(performance.now());
        });
        return () => {
          cancelAnimationFrame(frame);
          resizeObserver.disconnect();
          intersection.disconnect();
          unobserveTheme();
        };
      }, [gap, size, color, baseOpacity, peakOpacity, density, speed, seed]);
    
      return (
        <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} style={style} {...props}>
          <canvas
            ref={canvasRef}
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 -z-10 size-full"
            style={fade ? { maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE } : undefined}
          />
          {children}
        </div>
      );
    }

Usage

import { ShimmeringDots } from "@/components/ui/shimmering-dots/shimmering-dots";

export function Panel() {
  return (
    <ShimmeringDots className="h-72">
      <h2>Quietly alive</h2>
    </ShimmeringDots>
  );
}

API reference

PropTypeDefaultDescription
gapnumber14Distance between dots in px.
sizenumber2Dot size in px (square dots).
colorstring"var(--primary)"Dot color; any CSS color.
baseOpacitynumber0.16Opacity of a resting dot.
peakOpacitynumber1Opacity at the peak of a twinkle.
densitynumber0.22Fraction of dots that twinkle, 0–1.
speednumber1Twinkle speed multiplier.
fadebooleantrueFade the grid out toward the edges.
seedstring | number"dots"Seed for which dots twinkle and when; the same seed and size give the same pattern.

Also accepts every native <div> attribute, including ref and className.

  • Each twinkling dot follows its own sharpened sine wave, so dots spend most of their time dim and flash briefly at different moments.
  • Drawn on a canvas and paused while offscreen; the grid fades out toward the edges unless fade={false}.
  • Which dots twinkle, and when, comes from `seed`, so a grid of the same size looks the same on every load. Pass a different seed to vary it.

Accessibility

  • The canvas is decorative and aria-hidden.
  • Twinkles are brief and spread out rather than strobing. With reduced motion enabled, the grid is a still frame: a scattering of dots stays lit and nothing flashes.