{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "particle-text-dots", "type": "registry:ui", "title": "Particle Text Dots", "description": "Text rendered as interactive particle dots that react to cursor movement, similar to Gemini's background effect.", "files": [ { "path": "registry/ruixenui/particle-text-dots.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"dark\" | \"light\";\n\ninterface ParticleTextDotsProps {\n text: string;\n variant?: Variant;\n className?: string;\n}\n\ntype Particle = {\n x: number;\n y: number;\n vx: number;\n vy: number;\n baseX: number;\n baseY: number;\n depth: number; // 0..1\n size: number;\n phase: number;\n};\n\ntype MouseState = {\n x: number;\n y: number;\n active: boolean;\n};\n\nexport function ParticleTextDots({\n text,\n variant = \"dark\",\n className,\n}: ParticleTextDotsProps) {\n const containerRef = React.useRef(null);\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(null);\n const mouseRef = React.useRef({\n x: 0,\n y: 0,\n active: false,\n });\n\n React.useEffect(() => {\n const container = containerRef.current;\n const canvas = canvasRef.current;\n if (!container || !canvas) return;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let disposed = false;\n let width = 0;\n let height = 0;\n let particles: Particle[] = [];\n\n const dpr = window.devicePixelRatio || 1;\n\n const createParticlesFromText = (\n w: number,\n h: number,\n raw: string,\n ): Particle[] => {\n const label = raw.trim() || \"3\";\n\n const off = document.createElement(\"canvas\");\n off.width = w;\n off.height = h;\n const octx = off.getContext(\"2d\");\n if (!octx) return [];\n\n octx.clearRect(0, 0, w, h);\n\n const maxFontHeight = h * 0.65;\n const approxCharWidth = 0.6;\n const targetWidth = w * 0.8;\n const fontSize = Math.min(\n maxFontHeight,\n targetWidth / Math.max(1, label.length * approxCharWidth),\n );\n\n octx.fillStyle = variant === \"dark\" ? \"#ffffff\" : \"#020617\";\n octx.textAlign = \"center\";\n octx.textBaseline = \"middle\";\n octx.font = `800 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, \"SF Pro Display\", sans-serif`;\n octx.fillText(label, w / 2, h / 2);\n\n const img = octx.getImageData(0, 0, w, h);\n const data = img.data;\n\n // spacing: between the tight first version and the more scattered one\n const baseGap = Math.min(w, h) / 65;\n const gap = Math.max(5, Math.round(baseGap)); // ~5px\n const alphaThreshold = 70;\n\n const pts: Particle[] = [];\n\n for (let y = 0; y < h; y += gap) {\n for (let x = 0; x < w; x += gap) {\n const idx = (y * w + x) * 4;\n const alpha = data[idx + 3];\n if (alpha > alphaThreshold) {\n const depth = Math.random(); // 0..1\n\n // small jitter -> still organic, but not scattered\n const jitterX = (Math.random() - 0.5) * gap * 0.2;\n const jitterY = (Math.random() - 0.5) * gap * 0.2;\n const baseX = x + jitterX;\n const baseY = y + jitterY;\n\n // spawn slightly offset so they \"settle\" into place\n const startOffset = 22;\n const sx = baseX + (Math.random() - 0.5) * startOffset;\n const sy = baseY + (Math.random() - 0.5) * startOffset;\n\n pts.push({\n x: sx,\n y: sy,\n vx: 0,\n vy: 0,\n baseX,\n baseY,\n depth,\n size: 1.4 + depth * 1.3, // clean circular dots\n phase: Math.random() * Math.PI * 2,\n });\n }\n }\n }\n\n return pts;\n };\n\n const resize = () => {\n const rect = container.getBoundingClientRect();\n width = Math.max(1, Math.floor(rect.width));\n height = Math.max(1, Math.floor(rect.height));\n\n canvas.width = width * dpr;\n canvas.height = height * dpr;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n particles = createParticlesFromText(width, height, text);\n\n mouseRef.current.x = width / 2;\n mouseRef.current.y = height / 2;\n };\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(container);\n\n let lastTime = performance.now();\n\n const render = (time: number) => {\n if (disposed) return;\n\n const dtMs = time - lastTime;\n lastTime = time;\n const dt = Math.min(2, dtMs / 16.67); // normalized (~1 at 60fps)\n\n const t = time * 0.0012;\n const mouse = mouseRef.current;\n\n ctx.clearRect(0, 0, width, height);\n\n const influenceRadius = Math.min(width, height) * 0.35;\n const influenceRadiusSq = influenceRadius * influenceRadius;\n\n // physics\n for (const p of particles) {\n // mouse push\n if (mouse.active) {\n const dxm = p.x - mouse.x;\n const dym = p.y - mouse.y;\n const distSq = dxm * dxm + dym * dym;\n if (distSq < influenceRadiusSq && distSq > 0.0001) {\n const dist = Math.sqrt(distSq);\n const force = (1 - dist / influenceRadius) * 1.5;\n const nx = dxm / dist;\n const ny = dym / dist;\n const k = force * (0.4 + p.depth * 0.6);\n p.vx += nx * k;\n p.vy += ny * k;\n }\n }\n\n // spring back to glyph\n const spring = 0.06 + p.depth * 0.03;\n const dx = p.baseX - p.x;\n const dy = p.baseY - p.y;\n p.vx += dx * spring * dt;\n p.vy += dy * spring * dt;\n\n // gentle float (reduced so they don't look scattered)\n const wobble = 0.07 + p.depth * 0.09;\n p.vx += Math.cos(t * 1.2 + p.phase) * wobble * 0.16 * dt;\n p.vy += Math.sin(t * 1.3 + p.phase) * wobble * 0.16 * dt;\n\n // friction\n p.vx *= 0.9;\n p.vy *= 0.9;\n\n p.x += p.vx * dt;\n p.y += p.vy * dt;\n }\n\n // draw clean dots (no halo)\n for (const p of particles) {\n const dx = p.x - mouse.x;\n const dy = p.y - mouse.y;\n const dist = Math.sqrt(dx * dx + dy * dy) || 1;\n const near = mouse.active ? Math.max(0, 1 - dist / influenceRadius) : 0;\n\n // subtle, slow twinkle\n const flickerBase = (Math.sin(t * 1.0 + p.phase * 1.7) + 1) * 0.5; // 0..1\n const flicker = 0.85 + 0.15 * flickerBase; // 0.85..1.0\n\n const depthFactor = 0.55 + p.depth * 0.45;\n const alpha = (0.55 + 0.3 * near) * depthFactor * flicker;\n\n const hue =\n variant === \"dark\" ? 210 + p.depth * 40 : 220 + p.depth * 30;\n const lightness =\n variant === \"dark\" ? 63 + p.depth * 18 : 50 + p.depth * 15;\n\n ctx.fillStyle = `hsla(${hue}, 90%, ${lightness}%, ${alpha})`;\n ctx.beginPath();\n ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);\n ctx.fill();\n }\n\n frameRef.current = requestAnimationFrame(render);\n };\n\n frameRef.current = requestAnimationFrame(render);\n\n return () => {\n disposed = true;\n ro.disconnect();\n if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n };\n }, [text, variant]);\n\n const handlePointerMove: React.PointerEventHandler = (e) => {\n const rect = e.currentTarget.getBoundingClientRect();\n mouseRef.current.x = e.clientX - rect.left;\n mouseRef.current.y = e.clientY - rect.top;\n mouseRef.current.active = true;\n };\n\n const handlePointerLeave: React.PointerEventHandler = () => {\n const container = containerRef.current;\n if (!container) return;\n const rect = container.getBoundingClientRect();\n mouseRef.current.x = rect.width / 2;\n mouseRef.current.y = rect.height / 2;\n mouseRef.current.active = false;\n };\n\n return (\n \n \n {/* subtle background glow only, no extra text */}\n {variant === \"dark\" ? (\n
\n ) : (\n
\n )}\n
\n );\n}\n", "type": "registry:ui", "target": "components/ruixen/particle-text-dots.tsx" } ] }