{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle-text",
  "type": "registry:ui",
  "title": "Particle Text",
  "description": "Type sampled into a swarm of particles that spring home, scatter from the pointer and re-form.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/particle-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type ParticleTextColorMode = \"ink\" | \"accent\" | \"gradient\" | \"velocity\";\n\nexport interface ParticleTextProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The text to draw. It is exposed once to assistive tech; the particles are decorative. */\n  text?: string;\n  /** Element used for the screen-reader copy of the text. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\" | \"span\";\n  /** Font weight (100–900). The size is fitted to the container automatically. */\n  fontWeight?: number;\n  /** Particle diameter, in px. */\n  particleSize?: number;\n  /** Sampling density, 0.2–1. Higher means more, closer particles. */\n  density?: number;\n  /** Theme ink, the accent, an accent → secondary gradient, or ink that heats up with speed. */\n  colorMode?: ParticleTextColorMode;\n  /** Pointer repel radius, in px. */\n  radius?: number;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Second colour of the gradient mode (any CSS colour). */\n  secondary?: string;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.45)) as RGB;\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Particles -------------------------------- */\n\n/** Particle state kept across prop changes, so new settings re-form the text instead of restarting it. */\ninterface Swarm {\n  count: number;\n  x: Float32Array;\n  y: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  hx: Float32Array;\n  hy: Float32Array;\n}\n\nconst STEP_MS = 1000 / 60;\nconst SPRING = 0.055; // pull toward home per step\nconst FRICTION = 0.86; // velocity kept per step\nconst MAX_PARTICLES = 7000;\nconst HEAT_BUCKETS = 6;\n\n/** Grows (or shrinks) the swarm to `count`, keeping existing particles where they are. */\nfunction resizeSwarm(prev: Swarm | null, count: number, spawn: (i: number) => [number, number]): Swarm {\n  const next: Swarm = {\n    count,\n    x: new Float32Array(count),\n    y: new Float32Array(count),\n    vx: new Float32Array(count),\n    vy: new Float32Array(count),\n    hx: new Float32Array(count),\n    hy: new Float32Array(count),\n  };\n  const keep = prev ? Math.min(prev.count, count) : 0;\n  if (prev) {\n    next.x.set(prev.x.subarray(0, keep));\n    next.y.set(prev.y.subarray(0, keep));\n    next.vx.set(prev.vx.subarray(0, keep));\n    next.vy.set(prev.vy.subarray(0, keep));\n  }\n  for (let i = keep; i < count; i++) [next.x[i], next.y[i]] = spawn(i);\n  return next;\n}\n\n/**\n * Text drawn as a swarm of particles. The text is rendered once to an offscreen canvas (weight as\n * given, size fitted to the container), its pixels are sampled on a grid, and every sample becomes\n * a particle's home. Particles spring home, are pushed away by the pointer and drift back; a click\n * or tap scatters them. The text itself is exposed once as screen-reader text.\n */\nexport function ParticleText({\n  text = \"Wancraft\",\n  as: Tag = \"p\",\n  fontWeight = 700,\n  particleSize = 2,\n  density = 0.6,\n  colorMode = \"velocity\",\n  radius = 100,\n  accent = \"#FF7448\",\n  secondary = \"#B6F3D1\",\n  className = \"\",\n  ...props\n}: ParticleTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const swarmRef = React.useRef<Swarm | null>(null);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    const sampler = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n    if (!root || !canvas || !ctx || !sampler) return;\n\n    const dot = Math.max(0.5, particleSize);\n    const reach = Math.max(10, radius);\n    const fill = Math.min(1, Math.max(0.2, density));\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let moving = true;\n    let inView = false;\n    let ink: RGB = [24, 24, 27];\n    let accentRGB: RGB = [255, 116, 72];\n    let secondaryRGB: RGB = [182, 243, 209];\n    let bounds = { left: 0, right: 0 };\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, touch: false };\n\n    const readTheme = () => {\n      ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);\n      secondaryRGB = legible(resolveColor(secondary, [182, 243, 209]), ink);\n    };\n\n    /** Renders the text offscreen and turns its opaque pixels into particle homes. */\n    const sample = () => {\n      const label = text.trim() || \" \";\n      const family = getComputedStyle(root).fontFamily || \"sans-serif\";\n      const weight = Math.round(Math.min(900, Math.max(100, fontWeight)) / 100) * 100;\n      sampler.canvas.width = Math.max(1, Math.ceil(width));\n      sampler.canvas.height = Math.max(1, Math.ceil(height));\n      sampler.font = `${weight} 100px ${family}`;\n      const probeWidth = sampler.measureText(label).width || 1;\n      // Fit: as large as possible within 88% of the width and 62% of the height.\n      const size = Math.max(10, Math.min(height * 0.62, ((width * 0.88) / probeWidth) * 100));\n      sampler.font = `${weight} ${size}px ${family}`;\n      sampler.textAlign = \"center\";\n      sampler.textBaseline = \"alphabetic\";\n      const m = sampler.measureText(label);\n      const ascent = m.actualBoundingBoxAscent || size * 0.72;\n      const descent = m.actualBoundingBoxDescent || 0;\n      sampler.clearRect(0, 0, width, height);\n      sampler.fillStyle = \"#000\";\n      sampler.fillText(label, width / 2, height / 2 + (ascent - descent) / 2);\n      bounds = { left: width / 2 - m.width / 2, right: width / 2 + m.width / 2 };\n\n      const w = sampler.canvas.width;\n      const h = sampler.canvas.height;\n      const alpha = sampler.getImageData(0, 0, w, h).data;\n      let gap = 9 - fill * 6.5; // px between samples\n      let homes: number[] = [];\n      for (let attempt = 0; attempt < 4; attempt++) {\n        homes = [];\n        for (let y = gap / 2; y < h; y += gap) {\n          for (let x = gap / 2; x < w; x += gap) {\n            if (alpha[(Math.floor(y) * w + Math.floor(x)) * 4 + 3] > 128) homes.push(x, y);\n          }\n        }\n        if (homes.length / 2 <= MAX_PARTICLES) break;\n        gap *= Math.sqrt(homes.length / 2 / MAX_PARTICLES);\n      }\n\n      const count = homes.length / 2;\n      const prev = swarmRef.current;\n      const fresh = !prev || prev.count === 0;\n      const swarm = resizeSwarm(prev, count, (i) =>\n        reduceMotion\n          ? [homes[i * 2], homes[i * 2 + 1]]\n          : fresh\n            ? [Math.random() * width, Math.random() * height] // first build: assemble from a scatter\n            : [width / 2 + (Math.random() - 0.5) * 40, height / 2 + (Math.random() - 0.5) * 40],\n      );\n      for (let i = 0; i < count; i++) {\n        swarm.hx[i] = homes[i * 2];\n        swarm.hy[i] = homes[i * 2 + 1];\n        if (reduceMotion) {\n          swarm.x[i] = swarm.hx[i];\n          swarm.y[i] = swarm.hy[i];\n          swarm.vx[i] = 0;\n          swarm.vy[i] = 0;\n        }\n      }\n      swarmRef.current = swarm;\n    };\n\n    /** One fixed 60Hz physics step. Returns true while anything is still moving. */\n    const step = () => {\n      const s = swarmRef.current;\n      if (!s) return false;\n      const r2 = reach * reach;\n      let active = false;\n      for (let i = 0; i < s.count; i++) {\n        let vx = s.vx[i];\n        let vy = s.vy[i];\n        if (ptr.inside) {\n          const dx = s.x[i] - ptr.x;\n          const dy = s.y[i] - ptr.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < r2 && d2 > 0.01) {\n            const d = Math.sqrt(d2);\n            const f = (1 - d / reach) ** 2 * 3.2;\n            vx += (dx / d) * f;\n            vy += (dy / d) * f;\n          }\n        }\n        vx = (vx + (s.hx[i] - s.x[i]) * SPRING) * FRICTION;\n        vy = (vy + (s.hy[i] - s.y[i]) * SPRING) * FRICTION;\n        s.x[i] += vx;\n        s.y[i] += vy;\n        s.vx[i] = vx;\n        s.vy[i] = vy;\n        if (!active && (Math.abs(vx) > 0.01 || Math.abs(vy) > 0.01 || Math.abs(s.hx[i] - s.x[i]) > 0.05 || Math.abs(s.hy[i] - s.y[i]) > 0.05)) {\n          active = true;\n        }\n      }\n      return active;\n    };\n\n    const rgb = ([r, g, b]: RGB) => `rgb(${r}, ${g}, ${b})`;\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      const s = swarmRef.current;\n      if (!s) return;\n      const round = dot >= 2;\n      const half = dot / 2;\n      const add = (path: Path2D, i: number) => {\n        if (round) {\n          path.moveTo(s.x[i] + half, s.y[i]);\n          path.arc(s.x[i], s.y[i], half, 0, Math.PI * 2);\n        } else path.rect(s.x[i] - half, s.y[i] - half, dot, dot);\n      };\n\n      if (colorMode === \"velocity\") {\n        // Bucket by speed: ink at rest, heating toward the accent when disturbed.\n        const paths = Array.from({ length: HEAT_BUCKETS }, () => new Path2D());\n        for (let i = 0; i < s.count; i++) {\n          const speed = Math.abs(s.vx[i]) + Math.abs(s.vy[i]);\n          add(paths[Math.min(HEAT_BUCKETS - 1, Math.floor((speed / 3) * HEAT_BUCKETS))], i);\n        }\n        paths.forEach((path, b) => {\n          const t = b / (HEAT_BUCKETS - 1);\n          ctx.fillStyle = rgb(ink.map((v, k) => Math.round(v + (accentRGB[k] - v) * t)) as RGB);\n          ctx.fill(path);\n        });\n        return;\n      }\n      const path = new Path2D();\n      for (let i = 0; i < s.count; i++) add(path, i);\n      if (colorMode === \"gradient\") {\n        const grad = ctx.createLinearGradient(bounds.left, 0, bounds.right, 0);\n        grad.addColorStop(0, rgb(accentRGB));\n        grad.addColorStop(1, rgb(secondaryRGB));\n        ctx.fillStyle = grad;\n      } else ctx.fillStyle = rgb(colorMode === \"accent\" ? accentRGB : ink);\n      ctx.fill(path);\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n      if (ptr.inside) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.x = ptr.clientX - rect.left;\n        ptr.y = ptr.clientY - rect.top;\n      }\n      // Fixed 60Hz steps keep the spring identical on 60, 120 or 144Hz displays.\n      // A short frame may run no step at all; it then keeps the previous \"still moving\" verdict.\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      if (pending >= STEP_MS) moving = false;\n      while (pending >= STEP_MS) {\n        moving = step() || moving;\n        pending -= STEP_MS;\n      }\n      draw();\n      if (moving || ptr.inside) schedule();\n      else last = 0;\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const rebuild = () => {\n      sample();\n      moving = true;\n      draw();\n      schedule();\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      if (width > 0 && height > 0) rebuild();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      if (!raf) draw();\n    };\n\n    const locate = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      ptr.x = e.clientX - rect.left;\n      ptr.y = e.clientY - rect.top;\n      return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\" && !ptr.touch) return;\n      ptr.inside = locate(e);\n      if (ptr.inside) schedule();\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      const inside = locate(e);\n      if (!inside) return;\n      if (e.pointerType === \"touch\") {\n        ptr.touch = true;\n        ptr.inside = true;\n      }\n      // Scatter: an outward kick from the press point, stronger close to it.\n      const s = swarmRef.current;\n      if (s) {\n        const blast = reach * 2.2;\n        for (let i = 0; i < s.count; i++) {\n          const dx = s.x[i] - ptr.x;\n          const dy = s.y[i] - ptr.y;\n          const d = Math.sqrt(dx * dx + dy * dy) || 1;\n          if (d < blast) {\n            const f = (1 - d / blast) * 16;\n            s.vx[i] += (dx / d) * f;\n            s.vy[i] += (dy / d) * f;\n          }\n        }\n        moving = true;\n      }\n      schedule();\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = false;\n      ptr.inside = false;\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const listen = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n        window.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n        window.addEventListener(\"pointerup\", onPointerUp, { passive: true });\n        window.addEventListener(\"pointercancel\", onPointerUp, { passive: true });\n        document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        window.removeEventListener(\"pointerup\", onPointerUp);\n        window.removeEventListener(\"pointercancel\", onPointerUp);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n      }\n    };\n\n    // Pointer tracking lives on the window (only while visible), so the canvas never blocks\n    // anything layered around it.\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (!reduceMotion) listen(true);\n        schedule();\n      } else {\n        listen(false);\n        ptr.inside = false;\n        stop();\n      }\n    });\n\n    let cancelled = false;\n    readTheme();\n    resize();\n    // Web fonts may still be loading: re-sample once they are ready so the shapes match.\n    document.fonts?.ready.then(() => {\n      if (!cancelled && width > 0) rebuild();\n    });\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      cancelled = true;\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      listen(false);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [text, fontWeight, particleSize, density, colorMode, radius, accent, secondary, reduceMotion]);\n\n  return (\n    <div ref={rootRef} className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <Tag className=\"sr-only\">{text}</Tag>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/particle-text"
}
