Shimmering Grid Dots
Random dots twinkle across a grid.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/shimmering-dots.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport 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}`), }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
gap | number | 14 | Distance between dots in px. |
size | number | 2 | Dot size in px (square dots). |
color | string | "var(--primary)" | Dot color; any CSS color. |
baseOpacity | number | 0.16 | Opacity of a resting dot. |
peakOpacity | number | 1 | Opacity at the peak of a twinkle. |
density | number | 0.22 | Fraction of dots that twinkle, 0–1. |
speed | number | 1 | Twinkle speed multiplier. |
fade | boolean | true | Fade the grid out toward the edges. |
seed | string | 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.