{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "ParticleText-TS-CSS",
	"title": "ParticleText",
	"description": "Text assembles from drifting particles that scatter and reform on demand.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "ParticleText.css",
			"target": "@components/ParticleText.css",
			"content": ".particle-text {\n  position: relative;\n  display: block;\n  width: 100%;\n  height: 100%;\n  min-height: 240px;\n  overflow: hidden;\n  touch-action: none;\n  isolation: isolate;\n}\n\n.particle-text__canvas {\n  position: absolute;\n  inset: 0;\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n.particle-text__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "ParticleText.tsx",
			"content": "import { useEffect, useRef, type CSSProperties } from 'react';\nimport './ParticleText.css';\n\nexport interface ParticleTextProps {\n  text?: string;\n  particleSize?: number;\n  density?: number;\n  color?: string;\n  highlightColor?: string;\n  scatter?: number;\n  gatherDuration?: number;\n  stagger?: number;\n  pointerRepel?: number;\n  repelRadius?: number;\n  idleDrift?: number;\n  trigger?: 'mount' | 'hover' | 'click';\n  fontSize?: number | string;\n  fontWeight?: number | string;\n  fontFamily?: string;\n  glow?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\ntype Rgb = { r: number; g: number; b: number };\ntype Target = { x: number; y: number; alpha: number };\ntype Particle = {\n  x: number;\n  y: number;\n  startX: number;\n  startY: number;\n  targetX: number;\n  targetY: number;\n  size: number;\n  color: string;\n  seed: number;\n  depth: number;\n  delay: number;\n};\n\nconst hexToRgb = (hex: string): Rgb | null => {\n  const clean = hex.replace('#', '').trim();\n  if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;\n  return {\n    r: parseInt(clean.slice(0, 2), 16),\n    g: parseInt(clean.slice(2, 4), 16),\n    b: parseInt(clean.slice(4, 6), 16)\n  };\n};\n\nconst mixRgb = (from: Rgb, to: Rgb, amount: number): Rgb => ({\n  r: Math.round(from.r + (to.r - from.r) * amount),\n  g: Math.round(from.g + (to.g - from.g) * amount),\n  b: Math.round(from.b + (to.b - from.b) * amount)\n});\n\nconst rgbToCss = (rgb: Rgb): string => `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;\n\nconst clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);\nconst easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);\n\nconst resolveFontSize = (\n  value: number | string,\n  container: HTMLDivElement,\n  fontWeight: number | string,\n  fontFamily: string\n): number => {\n  if (typeof value === 'number') return value;\n\n  const probe = document.createElement('span');\n  probe.textContent = 'M';\n  probe.style.position = 'absolute';\n  probe.style.visibility = 'hidden';\n  probe.style.pointerEvents = 'none';\n  probe.style.fontSize = value;\n  probe.style.fontWeight = String(fontWeight);\n  probe.style.fontFamily = fontFamily;\n  container.appendChild(probe);\n  const size = parseFloat(window.getComputedStyle(probe).fontSize) || 96;\n  probe.remove();\n  return size;\n};\n\nconst waitForFonts = async (font: string): Promise<void> => {\n  if (!('fonts' in document)) return;\n\n  try {\n    await document.fonts.load(font);\n  } catch {}\n\n  await document.fonts.ready;\n};\n\nconst ParticleText = ({\n  text = 'React Bits',\n  particleSize = 2,\n  density = 4,\n  color = '#ffffff',\n  highlightColor = '#8b5cf6',\n  scatter = 180,\n  gatherDuration = 1600,\n  stagger = 420,\n  pointerRepel = 40,\n  repelRadius = 120,\n  idleDrift = 0.7,\n  trigger = 'mount',\n  fontSize = 'clamp(3rem, 12vw, 8rem)',\n  fontWeight = 800,\n  fontFamily = 'inherit',\n  glow = true,\n  className = '',\n  style\n}: ParticleTextProps) => {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return undefined;\n\n    const container = containerRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) return undefined;\n\n    const ctx = canvas.getContext('2d');\n    if (!ctx) return undefined;\n\n    let particles: Particle[] = [];\n    let animationFrame: number | null = null;\n    let resizeFrame: number | null = null;\n    let buildId = 0;\n    let gathering = false;\n    let gatherStart = 0;\n    let reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n\n    const pointer = {\n      active: false,\n      x: 0,\n      y: 0,\n      smoothX: 0,\n      smoothY: 0\n    };\n\n    const startGather = (fromScatter = true): void => {\n      if (!particles.length) return;\n\n      const now = performance.now();\n      const spread = reducedMotion ? 0 : scatter;\n\n      particles.forEach(particle => {\n        if (fromScatter) {\n          const angle = particle.seed * Math.PI * 2;\n          const distance = spread * (0.35 + particle.depth * 0.75);\n          particle.x = particle.targetX + Math.cos(angle) * distance + (particle.depth - 0.5) * spread * 0.55;\n          particle.y = particle.targetY + Math.sin(angle) * distance + (particle.seed - 0.5) * spread * 0.55;\n        }\n\n        particle.startX = particle.x;\n        particle.startY = particle.y;\n        particle.delay = reducedMotion ? 0 : particle.seed * stagger;\n      });\n\n      gatherStart = now;\n      gathering = true;\n    };\n\n    const drawParticle = (particle: Particle): void => {\n      const size = particle.size;\n      ctx.fillStyle = particle.color;\n\n      if (size <= 2.1) {\n        ctx.fillRect(particle.x - size / 2, particle.y - size / 2, size, size);\n        return;\n      }\n\n      ctx.beginPath();\n      ctx.arc(particle.x, particle.y, size / 2, 0, Math.PI * 2);\n      ctx.fill();\n    };\n\n    const render = (now: number): void => {\n      ctx.clearRect(0, 0, width, height);\n\n      if (glow && !reducedMotion) {\n        ctx.shadowBlur = particleSize * 3;\n        ctx.shadowColor = highlightColor;\n      } else {\n        ctx.shadowBlur = 0;\n      }\n\n      pointer.smoothX += (pointer.x - pointer.smoothX) * 0.18;\n      pointer.smoothY += (pointer.y - pointer.smoothY) * 0.18;\n\n      let complete = true;\n\n      particles.forEach(particle => {\n        let baseX = particle.targetX;\n        let baseY = particle.targetY;\n        let progress = 1;\n\n        if (gathering) {\n          const local = (now - gatherStart - particle.delay) / Math.max(1, reducedMotion ? 1 : gatherDuration);\n          progress = clamp(local, 0, 1);\n          const eased = easeOutCubic(progress);\n          baseX = particle.startX + (particle.targetX - particle.startX) * eased;\n          baseY = particle.startY + (particle.targetY - particle.startY) * eased;\n          if (progress < 1) complete = false;\n        } else if (!reducedMotion && idleDrift > 0) {\n          const driftTime = now * 0.001;\n          baseX += Math.sin(driftTime * 0.9 + particle.seed * 10) * idleDrift * particle.depth;\n          baseY += Math.cos(driftTime * 0.75 + particle.depth * 10) * idleDrift * particle.depth;\n        }\n\n        if (pointer.active && !reducedMotion && pointerRepel > 0 && repelRadius > 0) {\n          const dx = baseX - pointer.smoothX;\n          const dy = baseY - pointer.smoothY;\n          const distance = Math.hypot(dx, dy);\n          if (distance > 0 && distance < repelRadius) {\n            const force = Math.pow(1 - distance / repelRadius, 2) * pointerRepel;\n            baseX += (dx / distance) * force;\n            baseY += (dy / distance) * force;\n          }\n        }\n\n        const follow = reducedMotion ? 1 : 0.22;\n        particle.x += (baseX - particle.x) * follow;\n        particle.y += (baseY - particle.y) * follow;\n\n        ctx.globalAlpha = clamp(0.35 + progress * 0.65, 0, 1);\n        drawParticle(particle);\n      });\n\n      ctx.globalAlpha = 1;\n      ctx.shadowBlur = 0;\n\n      if (gathering && complete) {\n        gathering = false;\n      }\n\n      animationFrame = window.requestAnimationFrame(render);\n    };\n\n    const ensureRenderLoop = (): void => {\n      if (animationFrame === null) {\n        animationFrame = window.requestAnimationFrame(render);\n      }\n    };\n\n    const sampleText = async (): Promise<void> => {\n      const currentBuild = ++buildId;\n      const rect = container.getBoundingClientRect();\n      width = Math.floor(rect.width);\n      height = Math.floor(rect.height);\n\n      if (width <= 0 || height <= 0) return;\n\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.floor(width * dpr));\n      canvas.height = Math.max(1, Math.floor(height * dpr));\n      canvas.style.width = '100%';\n      canvas.style.height = '100%';\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const computed = window.getComputedStyle(container);\n      const resolvedFamily = fontFamily === 'inherit' ? computed.fontFamily || 'sans-serif' : fontFamily;\n      let resolvedSize = resolveFontSize(fontSize, container, fontWeight, resolvedFamily);\n      let font = `${fontWeight} ${resolvedSize}px ${resolvedFamily}`;\n\n      await waitForFonts(font);\n      if (currentBuild !== buildId) return;\n\n      const offscreen = document.createElement('canvas');\n      const offCtx = offscreen.getContext('2d', { willReadFrequently: true });\n      if (!offCtx) return;\n\n      const content = String(text || ' ');\n      const maxTextWidth = width * 0.92;\n      offCtx.font = font;\n      let metrics = offCtx.measureText(content);\n      const measuredWidth = Math.max(1, metrics.width);\n      if (measuredWidth > maxTextWidth) {\n        resolvedSize = Math.max(18, resolvedSize * (maxTextWidth / measuredWidth));\n        font = `${fontWeight} ${resolvedSize}px ${resolvedFamily}`;\n        await waitForFonts(font);\n        if (currentBuild !== buildId) return;\n        offCtx.font = font;\n        metrics = offCtx.measureText(content);\n      }\n\n      const left = Math.ceil(metrics.actualBoundingBoxLeft || 0);\n      const right = Math.ceil(metrics.actualBoundingBoxRight || metrics.width);\n      const ascent = Math.ceil(metrics.actualBoundingBoxAscent || resolvedSize * 0.78);\n      const descent = Math.ceil(metrics.actualBoundingBoxDescent || resolvedSize * 0.22);\n      const padding = Math.max(12, Math.ceil(resolvedSize * 0.08));\n      const textWidth = Math.max(1, left + right);\n      const textHeight = Math.max(1, ascent + descent);\n\n      offscreen.width = textWidth + padding * 2;\n      offscreen.height = textHeight + padding * 2;\n      offCtx.clearRect(0, 0, offscreen.width, offscreen.height);\n      offCtx.font = font;\n      offCtx.textAlign = 'left';\n      offCtx.textBaseline = 'alphabetic';\n      offCtx.fillStyle = '#ffffff';\n      offCtx.fillText(content, padding - left, padding + ascent);\n\n      const imageData = offCtx.getImageData(0, 0, offscreen.width, offscreen.height);\n      const targets: Target[] = [];\n      const step = Math.max(2, Math.floor(density));\n\n      for (let y = 0; y < offscreen.height; y += step) {\n        for (let x = 0; x < offscreen.width; x += step) {\n          const alpha = imageData.data[(y * offscreen.width + x) * 4 + 3];\n          if (alpha > 40) {\n            targets.push({\n              x: width / 2 - offscreen.width / 2 + x,\n              y: height / 2 - offscreen.height / 2 + y,\n              alpha: alpha / 255\n            });\n          }\n        }\n      }\n\n      const maxParticles = Math.max(900, Math.min(5200, Math.floor((width * height) / 90)));\n      const stride = Math.max(1, Math.ceil(targets.length / maxParticles));\n      const baseRgb = hexToRgb(color);\n      const highlightRgb = hexToRgb(highlightColor);\n      const selected = targets.filter((_, index) => index % stride === 0);\n\n      particles = selected.map((target, index) => {\n        const seed = ((index * 9301 + 49297) % 233280) / 233280;\n        const depth = 0.45 + (((index * 233 + 97) % 1000) / 1000) * 0.9;\n        const blend = baseRgb && highlightRgb ? clamp(target.x / Math.max(1, width) + (seed - 0.5) * 0.35, 0, 1) : 0;\n        const particleColor = baseRgb && highlightRgb ? rgbToCss(mixRgb(baseRgb, highlightRgb, blend)) : color;\n        const angle = seed * Math.PI * 2;\n        const distance = (reducedMotion ? 0 : scatter) * (0.35 + depth * 0.75);\n        const startX = target.x + Math.cos(angle) * distance + (seed - 0.5) * scatter * 0.45;\n        const startY = target.y + Math.sin(angle) * distance + (depth - 0.9) * scatter * 0.45;\n\n        return {\n          x: reducedMotion ? target.x : startX,\n          y: reducedMotion ? target.y : startY,\n          startX,\n          startY,\n          targetX: target.x,\n          targetY: target.y,\n          size: Math.max(0.6, particleSize * (0.75 + target.alpha * 0.45)),\n          color: particleColor,\n          seed,\n          depth,\n          delay: seed * stagger\n        };\n      });\n\n      pointer.x = width / 2;\n      pointer.y = height / 2;\n      pointer.smoothX = pointer.x;\n      pointer.smoothY = pointer.y;\n\n      if (reducedMotion) {\n        particles.forEach(particle => {\n          particle.x = particle.targetX;\n          particle.y = particle.targetY;\n          particle.startX = particle.targetX;\n          particle.startY = particle.targetY;\n          particle.delay = 0;\n        });\n        gathering = false;\n      } else {\n        startGather(false);\n      }\n\n      ensureRenderLoop();\n    };\n\n    const queueSample = (): void => {\n      if (resizeFrame) window.cancelAnimationFrame(resizeFrame);\n      resizeFrame = window.requestAnimationFrame(sampleText);\n    };\n\n    const handlePointerMove = (event: PointerEvent): void => {\n      const rect = canvas.getBoundingClientRect();\n      pointer.x = event.clientX - rect.left;\n      pointer.y = event.clientY - rect.top;\n      pointer.active = true;\n    };\n\n    const handlePointerLeave = (): void => {\n      pointer.active = false;\n    };\n\n    const handlePointerEnter = (event: PointerEvent): void => {\n      handlePointerMove(event);\n      if (trigger === 'hover') startGather(true);\n    };\n\n    const handleClick = (): void => {\n      if (trigger === 'click') startGather(true);\n    };\n\n    const reduceMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n    const handleReduceMotionChange = (event: MediaQueryListEvent): void => {\n      reducedMotion = event.matches;\n      void sampleText();\n    };\n\n    reduceMotionQuery?.addEventListener('change', handleReduceMotionChange);\n    canvas.addEventListener('pointerenter', handlePointerEnter);\n    canvas.addEventListener('pointermove', handlePointerMove);\n    canvas.addEventListener('pointerleave', handlePointerLeave);\n    canvas.addEventListener('click', handleClick);\n\n    const resizeObserver = new ResizeObserver(queueSample);\n    resizeObserver.observe(container);\n    void sampleText();\n\n    return () => {\n      buildId += 1;\n      resizeObserver.disconnect();\n      reduceMotionQuery?.removeEventListener('change', handleReduceMotionChange);\n      canvas.removeEventListener('pointerenter', handlePointerEnter);\n      canvas.removeEventListener('pointermove', handlePointerMove);\n      canvas.removeEventListener('pointerleave', handlePointerLeave);\n      canvas.removeEventListener('click', handleClick);\n\n      if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);\n      if (resizeFrame !== null) window.cancelAnimationFrame(resizeFrame);\n    };\n  }, [\n    text,\n    particleSize,\n    density,\n    color,\n    highlightColor,\n    scatter,\n    gatherDuration,\n    stagger,\n    pointerRepel,\n    repelRadius,\n    idleDrift,\n    trigger,\n    fontSize,\n    fontWeight,\n    fontFamily,\n    glow\n  ]);\n\n  return (\n    <div ref={containerRef} className={`particle-text ${className}`} style={style} aria-label={text}>\n      <canvas ref={canvasRef} className=\"particle-text__canvas\" aria-hidden=\"true\" />\n      <span className=\"particle-text__sr\">{text}</span>\n    </div>\n  );\n};\n\nexport default ParticleText;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}