{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meteors",
  "title": "Meteors",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Thin meteors with glowing heads and fading tails streak across a container at a set angle. Randomized after mount (hydration-safe), paused off-screen, hidden under reduced motion.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/meteors.tsx",
      "content": "// Ballmac UI: Meteors. https://ui.ballmac.com/components/meteors\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype MeteorsProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"color\"> & {\n  /** Number of meteors in flight at once. */\n  count?: number\n  /** Direction of travel in degrees: 0 is right, 90 is down, 135 is down-left. */\n  angle?: number\n  /** Shortest and longest seconds for one meteor to cross. */\n  duration?: [number, number]\n  /** Shortest and longest tail length in pixels. */\n  tail?: [number, number]\n  /** Meteor color. Any CSS color or var(). */\n  color?: string\n}\n\ntype Meteor = { id: number; left: number; top: number; delay: number; duration: number; tail: number }\n\nfunction Meteors({\n  count = 14,\n  angle = 135,\n  duration = [2.4, 5.5],\n  tail = [60, 160],\n  color = \"color-mix(in oklch, var(--foreground) 70%, transparent)\",\n  className,\n  style,\n  ...props\n}: MeteorsProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  // Positions are random, so they are created on the client after mount; the server renders an empty layer.\n  const [meteors, setMeteors] = React.useState<Meteor[]>([])\n  const [distance, setDistance] = React.useState(0)\n  const [minDuration, maxDuration] = duration\n  const [minTail, maxTail] = tail\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root || window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return\n    const between = (a: number, b: number) => a + Math.random() * (b - a)\n    const rad = (angle * Math.PI) / 180\n    const goingLeft = Math.cos(rad) < 0\n    setMeteors(\n      Array.from({ length: count }, (_, id) => {\n        const d = between(minDuration, maxDuration)\n        return {\n          id,\n          // Start along the top edge, spread past the side the meteors travel away from.\n          left: goingLeft ? between(10, 130) : between(-30, 90),\n          top: between(-20, 30),\n          delay: between(0, maxDuration * 2),\n          duration: d,\n          tail: between(minTail, maxTail),\n        }\n      })\n    )\n    const measure = () => setDistance(Math.hypot(root.clientWidth, root.clientHeight) * 1.1)\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(root)\n    return () => ro.disconnect()\n  }, [count, angle, minDuration, maxDuration, minTail, maxTail])\n\n  // Web Animations keep this free of global CSS and let us pause off-screen.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root || !distance || !meteors.length) return\n    const nodes = root.querySelectorAll<HTMLElement>(\"[data-slot=meteor]\")\n    const animations = Array.from(nodes).map((node, i) => {\n      const m = meteors[i]!\n      // Each loop is the flight plus a pause, so meteors don't fire back to back.\n      const flight = m.duration * 1000\n      const total = flight * 1.8\n      return node.animate(\n        [\n          { transform: `rotate(${angle}deg) translateX(0px)`, opacity: 0, offset: 0 },\n          { opacity: 1, offset: 0.06 },\n          { opacity: 1, offset: (flight / total) * 0.7 },\n          { transform: `rotate(${angle}deg) translateX(${distance}px)`, opacity: 0, offset: flight / total },\n          { transform: `rotate(${angle}deg) translateX(${distance}px)`, opacity: 0, offset: 1 },\n        ],\n        { duration: total, delay: m.delay * 1000, iterations: Infinity, easing: \"linear\", fill: \"backwards\" }\n      )\n    })\n    let visible = true\n    const sync = () => {\n      for (const a of animations) {\n        if (visible && !document.hidden) a.play()\n        else a.pause()\n      }\n    }\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true\n      sync()\n    })\n    io.observe(root)\n    document.addEventListener(\"visibilitychange\", sync)\n    return () => {\n      io.disconnect()\n      document.removeEventListener(\"visibilitychange\", sync)\n      for (const a of animations) a.cancel()\n    }\n  }, [meteors, distance, angle])\n\n  return (\n    <div\n      ref={rootRef}\n      aria-hidden=\"true\"\n      data-slot=\"meteors\"\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden motion-reduce:hidden\", className)}\n      style={{ ...style, [\"--meteor\" as string]: color }}\n      {...props}\n    >\n      {meteors.map((m) => (\n        <span\n          key={m.id}\n          data-slot=\"meteor\"\n          className=\"absolute size-[3px] rounded-full bg-(--meteor) opacity-0 shadow-[0_0_0_1px_color-mix(in_oklch,var(--meteor)_12%,transparent),0_0_10px_2px_color-mix(in_oklch,var(--meteor)_35%,transparent)]\"\n          style={{ left: `${m.left}%`, top: `${m.top}%`, transform: `rotate(${angle}deg)` }}\n        >\n          <span\n            className=\"absolute top-1/2 right-1/2 h-px -translate-y-1/2 bg-linear-to-r from-transparent to-(--meteor)\"\n            style={{ width: m.tail }}\n          />\n        </span>\n      ))}\n    </div>\n  )\n}\n\nexport { Meteors, type MeteorsProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/meteors.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "meteors",
      "shooting stars",
      "background",
      "card",
      "decorative"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Put <Meteors /> inside a relative, overflow-hidden card or section (it is absolute inset-0, aria-hidden, pointer-events-none) and lift content with relative z-10. Meteor positions are generated on the client after mount, so the server renders an empty layer.",
      "whenToUse": [
        "A launch, release or milestone card that should feel special",
        "Dark hero sections or CTA panels that need a hint of motion"
      ],
      "whenNotToUse": [
        "Dense UI or data tables",
        "Communicating anything; it is decorative and hidden under reduced motion"
      ],
      "composesWith": [
        "spotlight-card",
        "badge",
        "button"
      ],
      "a11y": [],
      "customization": [
        "count (default 14), angle in degrees (default 135, down-left)",
        "duration: [min, max] seconds per crossing; tail: [min, max] px",
        "color: any CSS color, default 70% --foreground; try var(--chart-1)"
      ]
    },
    "examples": [
      "meteors-demo",
      "meteors-colored"
    ],
    "url": "https://ui.ballmac.com/components/meteors"
  },
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}