Skip to content
Skip to Content
DocsDotted Grid
Dotted Grid

Dotted Grid

An animated dot field transforms between geometric shapes and responds to your cursor with a glowing trail.

Move across the grid to draw a trail.

Component preview
Dotted Grid

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/dotted-grid.json"

Usage

Code
"use client"; import { DottedGrid } from "@/components/block/dotted-grid"; export default function Demo() { return <DottedGrid className="h-[400px] w-full rounded-lg" />; }

Install Manually

1

Install dependencies

npm install clsx tailwind-merge
2

Copy the source code

Copy into components/block/dotted-grid.jsx

"use client"; import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; // ─── Constants ──────────────────────────────────────────────────────────────── const SPACING = 24; const BASE_RADIUS = 7.2; const MOUSE_RADIUS = 380; // wider head influence const TRAIL_LENGTH = 456; // even more history const TRAIL_RADIUS = 230; // wider trail reach const TRAIL_FADE_MS = 1200; const RANDOM_TIME = 0.6; const COLLECT_TIME = 1.1; const SHAPE_HOLD_TIME = 1.2; const GRAY_DISPERSE_TIME = 0.9; // trail lingers ~2s const TOTAL_CYCLE_TIME = RANDOM_TIME + COLLECT_TIME + SHAPE_HOLD_TIME + GRAY_DISPERSE_TIME; const TOTAL_SHAPES = 5; // ─── Helpers ────────────────────────────────────────────────────────────────── const lerp = (a, b, t) => a + (b - a) * t; const clamp01 = (v) => Math.max(0, Math.min(1, v)); const smoothstep = (e0, e1, v) => { const t = clamp01((v - e0) / (e1 - e0)); return t * t * (3 - 2 * t); }; // ─── Pure Shape Math ────────────────────────────────────────────────────────── const getStarStrength = (x, y, width, height) => { const cx = width / 2; const cy = height / 2; const scale = Math.min(width, height) * 0.28; const nx = (x - cx) / scale; const ny = (y - cy) / scale; const r = Math.sqrt(nx * nx + ny * ny); const angle = Math.atan2(ny, nx); const spikes = 5; const star = Math.cos(spikes * angle); const radius = 0.55 + 0.25 * star; return clamp01(1 - smoothstep(radius - 0.05, radius + 0.05, r)); }; const getSquareStrength = (x, y, width, height) => { const cx = width / 2; const cy = height / 2; const scale = Math.min(width, height) * 0.26; const rx = (x - cx) / scale; const ry = (y - cy) / scale; const d = Math.max(Math.abs(rx), Math.abs(ry)); return clamp01(1 - smoothstep(0.78, 0.82, d)); }; const getCircleRingStrength = (x, y, width, height) => { const cx = width / 2; const cy = height / 2; const scale = Math.min(width, height) * 0.28; const r = Math.sqrt(((x - cx) / scale) ** 2 + ((y - cy) / scale) ** 2); return clamp01(1 - smoothstep(0.13, 0.17, Math.abs(r - 0.72))); }; const getPlusStrength = (x, y, width, height) => { const cx = width / 2; const cy = height / 2; const scale = Math.min(width, height) * 0.27; const rx = (x - cx) / scale; const ry = (y - cy) / scale; const thickness = 0.18; const length = 0.75; const vertical = Math.abs(rx) < thickness && Math.abs(ry) < length; const horizontal = Math.abs(ry) < thickness && Math.abs(rx) < length; const d = Math.min( Math.max(Math.abs(rx) - thickness, Math.abs(ry) - length), Math.max(Math.abs(ry) - thickness, Math.abs(rx) - length) ); return vertical || horizontal ? 1 : clamp01(1 - smoothstep(0, 0.06, d)); }; const getTriangleStrength = (x, y, time, width, height) => { const cx = width / 2; const cy = height / 2; const scale = Math.min(width, height) * 0.32; const rotation = Math.sin(time * 0.3) * 0.12; const cos = Math.cos(rotation); const sin = Math.sin(rotation); const rx = ((x - cx) * cos - (y - cy) * sin) / scale; const ry = ((x - cx) * sin + (y - cy) * cos) / scale; const a = Math.abs(rx) * 0.9 + ry * 0.52; const b = -ry * 0.95; return clamp01(1 - smoothstep(0.38, 0.48, Math.max(a, b))); }; const getRawShapeStrength = (shapeIndex, x, y, time, width, height) => { const i = shapeIndex % TOTAL_SHAPES; if (i === 0) return getStarStrength(x, y, width, height); if (i === 1) return getSquareStrength(x, y, width, height); if (i === 2) return getCircleRingStrength(x, y, width, height); if (i === 3) return getPlusStrength(x, y, width, height); return getTriangleStrength(x, y, time, width, height); }; // ─── Component ──────────────────────────────────────────────────────────────── /** * @param {{ className?: string, style?: import("react").CSSProperties, paused?: boolean }} props */ export function DottedGrid({ className, style, paused = false } = {}) { const canvasRef = useRef(null); const patternRef = useRef({ currentShapeIndex: 0, transitionStartTime: null, }); const mouseRef = useRef({ x: 0, y: 0, targetX: 0, targetY: 0, active: false, trail: [], }); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; const surface = canvas.parentElement; const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); let isStatic = paused || motion.matches; if (isStatic) patternRef.current.transitionStartTime = null; let width = 0; let height = 0; let dpr = window.devicePixelRatio || 1; let animationId = 0; let dots = []; // ─── Dot Setup ───────────────────────────────────────────────────────────── const createDots = () => { dots = []; for (let y = SPACING / 2; y < height; y += SPACING) { for (let x = SPACING / 2; x < width; x += SPACING) { dots.push({ x, y, phase: Math.random() * Math.PI * 2, speed: 0.3 + Math.random() * 1.0, randomOffset: Math.random() * 10, currentShapeStrength: 0, currentRandomStrength: 1, currentMouseStrength: 0, currentTrailStrength: 0, currentGrayDisperseStrength: 0, }); } } }; const resize = () => { const rect = canvas.getBoundingClientRect(); width = Math.max(1, rect.width); height = Math.max(1, rect.height); dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); createDots(); if (isStatic) animate(0); }; // ─── Event Handlers ──────────────────────────────────────────────────────── const handlePointerMove = (e) => { if (isStatic) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; mouseRef.current.targetX = x; mouseRef.current.targetY = y; mouseRef.current.active = true; mouseRef.current.trail.push({ x, y, t: performance.now() }); if (mouseRef.current.trail.length > TRAIL_LENGTH) { mouseRef.current.trail.shift(); } }; const handlePointerLeave = () => { mouseRef.current.active = false; }; const handleClick = () => { patternRef.current.currentShapeIndex = (patternRef.current.currentShapeIndex + 1) % TOTAL_SHAPES; patternRef.current.transitionStartTime = isStatic ? null : performance.now() * 0.001; if (isStatic) animate(0); }; // ─── Transition / Shape Interpolator ─────────────────────────────────────── const getShapeData = (x, y, time) => { const { currentShapeIndex, transitionStartTime } = patternRef.current; if (transitionStartTime === null) { return { shapeStrength: getRawShapeStrength(currentShapeIndex, x, y, time, width, height), randomStrength: 0, grayDisperseStrength: 0, }; } const cyclePosition = time - transitionStartTime; if (cyclePosition >= TOTAL_CYCLE_TIME) { patternRef.current.transitionStartTime = null; return { shapeStrength: getRawShapeStrength(currentShapeIndex, x, y, time, width, height), randomStrength: 0, grayDisperseStrength: 0, }; } const shapeIndex = currentShapeIndex; const shapeStrength = getRawShapeStrength(shapeIndex, x, y, time, width, height); if (cyclePosition < RANDOM_TIME) { return { shapeStrength: 0, randomStrength: 1, grayDisperseStrength: 0.35 }; } if (cyclePosition < RANDOM_TIME + COLLECT_TIME) { const eased = smoothstep(0, 1, (cyclePosition - RANDOM_TIME) / COLLECT_TIME); return { shapeStrength: shapeStrength * eased, randomStrength: 1 - eased, grayDisperseStrength: 0.35 * (1 - eased), }; } if (cyclePosition < RANDOM_TIME + COLLECT_TIME + SHAPE_HOLD_TIME) { return { shapeStrength, randomStrength: 0, grayDisperseStrength: 0 }; } const eased = smoothstep(0, 1, (cyclePosition - RANDOM_TIME - COLLECT_TIME - SHAPE_HOLD_TIME) / GRAY_DISPERSE_TIME); return { shapeStrength: shapeStrength * (1 - eased), randomStrength: eased, grayDisperseStrength: eased, }; }; // ─── Drawing Utilities ───────────────────────────────────────────────────── const drawDot = (x, y, radius, brightness, grayDisperseStrength, trailStrength, mouseStrength) => { const mouseFade = mouseStrength * mouseStrength * 0.72; const trailFade = trailStrength * 0.38; const alpha = clamp01((0.28 + brightness * 0.72) - mouseFade - trailFade); const normalL = 16 + brightness * 78; const disperseL = 12 + brightness * 58 + grayDisperseStrength * 22; const lightness = lerp(normalL, disperseL, grayDisperseStrength); const mouseLift = mouseStrength * (1 - mouseStrength) * 18; const mouseDark = mouseStrength * mouseStrength * 38; const trailLift = trailStrength * 38 * (1 - trailStrength * 0.55); const finalLightness = clamp01((lightness - mouseDark + mouseLift + trailLift) / 100) * 100; const saturation = trailStrength * trailStrength * 16; ctx.beginPath(); ctx.fillStyle = `hsla(210, ${saturation}%, ${finalLightness}%, ${alpha})`; ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); }; // ─── Main Draw Loop ──────────────────────────────────────────────────────── const animate = (ms) => { const time = ms * 0.001; const mouse = mouseRef.current; const now = performance.now(); mouse.trail = mouse.trail.filter((point) => now - point.t < TRAIL_FADE_MS); // Lagging cursor effect mouse.x = lerp(mouse.x, mouse.targetX, 0.12); mouse.y = lerp(mouse.y, mouse.targetY, 0.12); ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, width, height); for (const dot of dots) { const { shapeStrength, randomStrength, grayDisperseStrength } = getShapeData(dot.x, dot.y, time); dot.currentShapeStrength = isStatic ? shapeStrength : lerp(dot.currentShapeStrength, shapeStrength, 0.12); dot.currentRandomStrength = isStatic ? 0 : lerp(dot.currentRandomStrength, randomStrength, 0.14); dot.currentGrayDisperseStrength = lerp(dot.currentGrayDisperseStrength, grayDisperseStrength, 0.14); // Cursor head influence let targetMouseStrength = 0; if (mouse.active) { const dx = dot.x - mouse.x; const dy = dot.y - mouse.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < MOUSE_RADIUS) { const norm = dist / MOUSE_RADIUS; targetMouseStrength = (1 - norm) * (1 - norm) * (1 - norm); } } dot.currentMouseStrength = isStatic ? 0 : lerp(dot.currentMouseStrength, targetMouseStrength, 0.12); // Trail influence let targetTrailStrength = 0; for (let i = 0; i < mouse.trail.length; i++) { const pt = mouse.trail[i]; const age = (now - pt.t) / TRAIL_FADE_MS; if (age >= 1) continue; const ageFade = (1 - age) * (1 - age) * (1 - age); const positionFade = (i + 1) / mouse.trail.length; const fade = ageFade * positionFade; const dx = dot.x - pt.x; const dy = dot.y - pt.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < TRAIL_RADIUS) { const proximity = 1 - smoothstep(0, 1, dist / TRAIL_RADIUS); const softProximity = proximity * proximity * proximity; targetTrailStrength = Math.max(targetTrailStrength, softProximity * fade); } } dot.currentTrailStrength = isStatic ? 0 : lerp(dot.currentTrailStrength, targetTrailStrength, 0.08); const randomBlink = Math.sin( time * (1.2 + dot.speed * 1.2) + dot.phase + dot.randomOffset + dot.x * 0.02 + dot.y * 0.016 ) ** 2; const softPulse = Math.sin(time * 1.4 + dot.phase + dot.x * 0.015) ** 2; const stableBrightness = clamp01(0.16 + dot.currentShapeStrength * 0.84 + softPulse * 0.02); const randomBrightness = clamp01(0.16 + randomBlink * 0.18); const brightness = lerp(stableBrightness, randomBrightness, dot.currentRandomStrength); const grayDisperseBlink = clamp01(dot.currentGrayDisperseStrength * (0.45 + randomBlink * 0.55)); // Scale modification based on mouse proximity and trail history const mouseShrink = 1 - dot.currentMouseStrength * 0.75; const trailShrink = 1 - dot.currentTrailStrength * 0.65; const stableRadius = BASE_RADIUS + dot.currentShapeStrength * 1.25; const randomRadius = BASE_RADIUS + randomBlink * 0.35; const radius = lerp(stableRadius, randomRadius, dot.currentRandomStrength) * mouseShrink * trailShrink; drawDot(dot.x, dot.y, radius, brightness, grayDisperseBlink, dot.currentTrailStrength, dot.currentMouseStrength); } if (!isStatic) animationId = requestAnimationFrame(animate); }; // ─── Listeners & Boot ────────────────────────────────────────────────────── resize(); const observer = new ResizeObserver(resize); observer.observe(surface); const handleMotionChange = () => { isStatic = paused || motion.matches; if (isStatic) patternRef.current.transitionStartTime = null; cancelAnimationFrame(animationId); mouseRef.current.active = false; mouseRef.current.trail = []; animate(isStatic ? 0 : performance.now()); }; motion.addEventListener("change", handleMotionChange); canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerleave", handlePointerLeave); surface.addEventListener("click", handleClick); if (!isStatic) animationId = requestAnimationFrame(animate); return () => { observer.disconnect(); motion.removeEventListener("change", handleMotionChange); canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerleave", handlePointerLeave); surface.removeEventListener("click", handleClick); cancelAnimationFrame(animationId); }; }, [paused]); return ( <button type="button" aria-label="Change dotted grid shape" className={cn("relative block h-[28rem] w-full cursor-pointer overflow-hidden bg-black p-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", className)} style={style} > <canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full bg-black" /> <span className="pointer-events-none absolute inset-0 bg-linear-to-b from-white/4 via-transparent to-black/40" /> <span className="pointer-events-none absolute inset-0 shadow-[inset_0_0_140px_rgba(0,0,0,0.95)]" /> </button> ); }
3

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Preview behavior

Move across the grid to draw a trail. The effect stays inside its container and respects reduced motion preferences.

Last updated on