{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vortex",
  "title": "Vortex",
  "description": "Brand-tinted particles spiralling into the centre, leaving soft fading trails.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/vortex.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype Draw = (t: number, dt: number, still: boolean) => void;\ntype Build = (ctx: CanvasRenderingContext2D, w: number, h: number, el: HTMLCanvasElement) => Draw;\n\n// Resolves any CSS colour (or a \"--custom-property\") to RGBA bytes through a\n// 1×1 canvas, so oklch() theme tokens can be blended on every browser.\nfunction probe(el: Element, color: string) {\n  const c = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })!;\n  c.fillStyle = color.startsWith(\"--\") ? getComputedStyle(el).getPropertyValue(color) : color;\n  c.fillRect(0, 0, 1, 1);\n  return c.getImageData(0, 0, 1, 1).data;\n}\n\n// Runs `build` on resize and theme change; loops only while on screen, the tab\n// is visible and motion is allowed — otherwise paints a single still frame.\nfunction useCanvasLoop(ref: React.RefObject<HTMLCanvasElement | null>, build: Build) {\n  useEffect(() => {\n    const el = ref.current;\n    const ctx = el?.getContext(\"2d\");\n    if (!el || !ctx) return;\n    const mq = matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let draw: Draw | undefined;\n    let raf = 0;\n    let t = 0;\n    let last = 0;\n    let seen = false;\n    const frame = (now: number) => {\n      const dt = Math.max(0, Math.min(now - last, 50));\n      last = now;\n      t += dt;\n      draw?.(t, dt, false);\n      raf = requestAnimationFrame(frame);\n    };\n    const sync = () => {\n      cancelAnimationFrame(raf);\n      if (!draw) return;\n      if (mq.matches) draw(t, 0, true);\n      else if (seen && !document.hidden) {\n        last = performance.now();\n        raf = requestAnimationFrame(frame);\n      }\n    };\n    const setup = () => {\n      const dpr = Math.min(devicePixelRatio || 1, 2);\n      const w = el.clientWidth;\n      const h = el.clientHeight;\n      el.width = w * dpr;\n      el.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      draw = w && h ? build(ctx, w, h, el) : undefined;\n      sync();\n    };\n    const ro = new ResizeObserver(setup);\n    const io = new IntersectionObserver(([e]) => {\n      seen = e.isIntersecting;\n      sync();\n    });\n    const mo = new MutationObserver(setup);\n    ro.observe(el);\n    io.observe(el);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    mo.observe(document.head, { childList: true, subtree: true, characterData: true });\n    document.addEventListener(\"visibilitychange\", sync);\n    mq.addEventListener(\"change\", sync);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      mq.removeEventListener(\"change\", sync);\n    };\n  }, [ref, build]);\n}\n\ninterface Particle {\n  a: number;\n  r: number;\n  life: number;\n  ttl: number;\n  c: number;\n  w: number;\n}\n\ninterface VortexProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Maximum particle count; capped by container area so small screens draw fewer */\n  particles?: number;\n  /** Rotation speed multiplier */\n  speed?: number;\n  /** How far the swirl reaches towards the edges, 0–1 */\n  range?: number;\n}\n\n/**\n * A field of brand-tinted particles spiralling into the centre, leaving\n * fading trails. Absolutely positioned — place inside a `relative\n * overflow-hidden` section. Trails glow additively when the inherited text\n * colour is light (i.e. on a dark section).\n */\nexport function Vortex({\n  particles = 520,\n  speed = 1,\n  range = 1,\n  className,\n  ...props\n}: VortexProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n\n  const build = useCallback<Build>(\n    (ctx, w, h, el) => {\n      // Light text means a dark section: let overlapping trails add up to a glow.\n      const fg = probe(el, getComputedStyle(el).color);\n      const glow = fg[0] * 0.3 + fg[1] * 0.59 + fg[2] * 0.11 > 128;\n      // Twelve steps along the brand ramp.\n      const [a, b, c] = [\"--brand-from\", \"--brand-via\", \"--brand-to\"].map((v) => probe(el, v));\n      const ramp = Array.from({ length: 12 }, (_, i) => {\n        const k = i / 11;\n        const [x, y] = k < 0.5 ? [a, b] : [b, c];\n        const m = k < 0.5 ? k * 2 : k * 2 - 1;\n        return `rgb(${[0, 1, 2].map((j) => x[j] + (y[j] - x[j]) * m).join(\", \")})`;\n      });\n      const cx = w / 2;\n      const cy = h / 2;\n      const rx = (w / 2) * range;\n      const ry = (h / 2) * range * 1.15;\n      const spawn = (p: Partial<Particle> = {}): Particle =>\n        Object.assign(p, {\n          a: Math.random() * Math.PI * 2,\n          r: 0.2 + Math.sqrt(Math.random()) * 0.8,\n          life: 0,\n          ttl: 180 + Math.random() * 320,\n          c: (Math.random() * 12) | 0,\n          w: 0.6 + Math.random() * 1.4,\n        });\n      const list = Array.from({ length: Math.min(particles, (w * h) / 450) | 0 }, () => {\n        const p = spawn();\n        p.life = Math.random() * p.ttl;\n        return p;\n      });\n\n      const step = (f: number) => {\n        // Trails: erase a little of every pixel each frame. Erasing (rather\n        // than painting the background colour over) leaves no 8-bit ghosting\n        // and lets trails fade into whatever sits behind, gradients included.\n        ctx.globalCompositeOperation = \"destination-out\";\n        ctx.globalAlpha = 0.18;\n        ctx.fillRect(0, 0, w, h);\n        ctx.globalCompositeOperation = glow ? \"lighter\" : \"source-over\";\n        for (const p of list) {\n          const x = cx + Math.cos(p.a) * p.r * rx;\n          const y = cy + Math.sin(p.a) * p.r * ry;\n          // Faster near the centre, with a slow inward drift.\n          p.a += (f * speed * 0.008) / (0.15 + p.r);\n          p.r -= f * speed * 0.0005 * (0.4 + p.r);\n          p.life += f;\n          ctx.globalAlpha = Math.sin((Math.PI * p.life) / p.ttl) * 0.9;\n          ctx.strokeStyle = ramp[p.c];\n          ctx.lineWidth = p.w;\n          ctx.beginPath();\n          ctx.moveTo(x, y);\n          ctx.lineTo(cx + Math.cos(p.a) * p.r * rx, cy + Math.sin(p.a) * p.r * ry);\n          ctx.stroke();\n          if (p.life > p.ttl || p.r < 0.03) spawn(p);\n        }\n      };\n\n      ctx.lineCap = \"round\";\n      return (_t, dt, still) => {\n        if (!still) return step(dt / 16.7);\n        // Reduced motion: run 90 steps at once and show the frozen swirl.\n        ctx.clearRect(0, 0, w, h);\n        for (let i = 0; i < 90; i++) step(1);\n      };\n    },\n    [particles, speed, range]\n  );\n  useCanvasLoop(ref, build);\n\n  return (\n    <div\n      aria-hidden\n      data-slot=\"vortex\"\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)}\n      {...props}\n    >\n      <canvas ref={ref} className=\"absolute inset-0 size-full\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/vortex.tsx"
    }
  ],
  "type": "registry:ui"
}