"use client" /** * DotPattern — dot grid revealed by a soft drifting "cloud" mask. * * Inspiration: Google/Apple AI loading states — a diffuse dot field that * softly fades in, drifts diagonally across the surface, then fades out. * No bright glow cores, no particles — just one or two large soft halos * sliding across the grid so the dots appear as an ambient cloud. */ import * as React from "react" import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react" import { usePageVisible } from "../../hooks/use-page-visible" import { cn } from "../../lib/utils" interface DotPatternProps extends React.SVGProps { width?: number height?: number x?: number y?: number cx?: number cy?: number cr?: number className?: string glow?: boolean /** Number of drifting soft clouds (keep small: 1–2). Ignored when `cursorSpotlight`. */ glowCount?: number /** Cloud radius — large values produce a wide, diffuse reveal. */ glowRadius?: number /** * Reveal dots under a soft radial mask that follows the pointer. * Pass `spotlightX` / `spotlightY` as percentages of the SVG (0–100). * When true, drifting clouds are replaced by this spotlight. */ cursorSpotlight?: boolean /** Horizontal spotlight centre as % of width (default 50). */ spotlightX?: number /** Vertical spotlight centre as % of height (default 50). */ spotlightY?: number /** Fade the spotlight (pointer left the surface). */ spotlightActive?: boolean /** * Increment to send a wave through the **same** spotlight field * (extra mask rings — not a second DotPattern layer). */ spotlightBurstKey?: number /** * Seeded irregular dots across the full SVG (not a repeating tile). * Prefer for cursor / ambience surfaces — tiled scatter looked like * duplicate / ghost pairs. */ scatter?: boolean /** * Relative field density when `scatter` (default `1`). * Count ≈ area / (2800 / density); radius scales with √area. * Use `0.6` sparse → `1` normal → `1.75` dense. Ignored when * `scatterCount` is set explicitly. */ scatterDensity?: number /** Hard override for total dots. Prefer `scatterDensity` + measured size. */ scatterCount?: number } type Cloud = { key: number xs: string[] ys: string[] duration: number delay: number } type ScatterDot = { key: number cx: number cy: number r: number opacity: number } /** * Tiny deterministic PRNG (mulberry32). We use a seeded RNG instead of * `Math.random()` so the SVG attributes emitted on the server match the * client's first paint — otherwise React reports a hydration mismatch and * has to re-paint every drifting `` on mount, which is both * a perf cost and a visible jump. */ function mulberry32(seed: number): () => number { let s = seed >>> 0 return () => { s = (s + 0x6d2b79f5) >>> 0 let t = s t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } function hashString(str: string): number { // Cheap FNV-1a-style hash. Stable across SSR + CSR for the same input. let h = 2166136261 for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i) h = Math.imul(h, 16777619) } return h >>> 0 } /** Dots per px² at `scatterDensity={1}` — ~1 per 32×32 cell (was 45×45). */ const SCATTER_AREA_PER_DOT = 1000 function clamp(n: number, min: number, max: number) { return Math.min(max, Math.max(min, n)) } function scatterPlan( fieldW: number, fieldH: number, density: number, countOverride?: number, ) { const area = Math.max(1, fieldW * fieldH) const densitySafe = Math.max(0.25, density) const count = countOverride != null ? clamp(Math.round(countOverride), 8, 520) : clamp(Math.round(area / (SCATTER_AREA_PER_DOT / densitySafe)), 48, 520) // Pixel radius grows slowly with surface size so panels and windows match. const baseR = clamp(Math.sqrt(area) / 320, 0.75, 1.85) // Min separation in % of the shorter side → denser packing than before. const minDistPct = clamp(100 / Math.sqrt(count * 1.85), 0.85, 5.5) return { count, baseR, minDistPct } } function buildScatterDots(opts: { id: string count: number baseR: number minDistPct: number }): ScatterDot[] { const { id, count, baseR, minDistPct } = opts const rng = mulberry32(hashString(`${id}|field|${count}|${baseR.toFixed(2)}`)) const rand = (min: number, max: number) => min + rng() * (max - min) const dots: ScatterDot[] = [] let attempts = 0 const maxAttempts = count * 80 while (dots.length < count && attempts < maxAttempts) { attempts += 1 const cx = rand(1.5, 98.5) const cy = rand(1.5, 98.5) if (dots.some(d => Math.hypot(d.cx - cx, d.cy - cy) < minDistPct)) continue dots.push({ key: dots.length, cx, cy, r: rand(baseR * 0.7, baseR * 1.25), opacity: rand(0.45, 1), }) } return dots } export function DotPattern({ width = 14, height = 14, x = 0, y = 0, cx = 1, cy = 1, cr = 0.8, className, glow = false, glowCount = 2, glowRadius = 240, cursorSpotlight = false, spotlightX = 50, spotlightY = 50, spotlightActive = true, spotlightBurstKey = 0, scatter = false, scatterDensity = 1, scatterCount, ...props }: DotPatternProps) { const id = React.useId() const svgRef = React.useRef(null) const [fieldSize, setFieldSize] = React.useState({ w: 420, h: 640 }) const maskId = `${id}-mask` const gradId = `${id}-grad` const ringGradId = `${id}-ring` const useGlowMask = glow || cursorSpotlight // Motion drives these SVG attributes from its own frame loop, so the global // `prefers-reduced-motion` guard in globals.css (which clamps CSS animation // and transition durations) cannot reach them. Gate locally instead. const reducedMotion = useReducedMotion() ?? false const pageVisible = usePageVisible() const motionPaused = reducedMotion || !pageVisible const waveActive = cursorSpotlight && spotlightBurstKey > 0 && !motionPaused React.useEffect(() => { if (!scatter) return const el = svgRef.current if (!el) return const update = () => { const rect = el.getBoundingClientRect() if (rect.width < 1 || rect.height < 1) return setFieldSize(prev => Math.abs(prev.w - rect.width) < 1 && Math.abs(prev.h - rect.height) < 1 ? prev : { w: rect.width, h: rect.height }, ) } update() const ro = new ResizeObserver(update) ro.observe(el) return () => ro.disconnect() }, [scatter]) const clouds = React.useMemo(() => { if (cursorSpotlight) return [] const rng = mulberry32(hashString(`${id}|${glowCount}`)) const rand = (min: number, max: number) => min + rng() * (max - min) return Array.from({ length: glowCount }).map((_, i) => { // Drift diagonally: bottom-right → top-left. Start/end partly off-canvas // so the cloud enters and exits softly without a visible edge. const startX = rand(85, 120) const endX = rand(-20, 15) const midX = (startX + endX) / 2 + rand(-6, 6) const startY = rand(85, 115) const endY = rand(-15, 10) const midY = (startY + endY) / 2 + rand(-4, 4) const duration = rand(8, 12) // Offset clouds by half a cycle so one is arriving as the other leaves. const delay = -(i / glowCount) * duration return { key: i, xs: [`${startX}%`, `${midX}%`, `${endX}%`], ys: [`${startY}%`, `${midY}%`, `${endY}%`], duration, delay, } }) }, [cursorSpotlight, glowCount, id]) const scatterDots = React.useMemo(() => { if (!scatter) return [] const plan = scatterPlan( fieldSize.w, fieldSize.h, scatterDensity, scatterCount, ) return buildScatterDots({ id, count: plan.count, baseR: plan.baseR, minDistPct: plan.minDistPct, }) }, [fieldSize.h, fieldSize.w, id, scatter, scatterCount, scatterDensity]) const spotX = `${spotlightX}%` const spotY = `${spotlightY}%` return ( ) }