{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stars-background",
  "type": "registry:ui",
  "title": "Stars Background",
  "description": "Three night-sky layers you stack behind hero content: `StarsBackground`, a canvas of density-seeded twinkling stars; `ShootingStars`, one SVG streak at a time; and `Meteors`, CSS-animated tails falling at a fixed 215°.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/aceternity/stars-background.tsx",
      "target": "components/ui/aceternity/stars-background.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\n// @interlace/stars-background v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/stars-background\n// What changed since: https://ds.interlace.tools/c/stars-background#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — StarsBackground, ShootingStars, Meteors\n *\n * Three night-sky layers you stack behind hero content: `StarsBackground`, a\n * canvas of density-seeded twinkling stars; `ShootingStars`, one SVG streak at\n * a time; and `Meteors`, CSS-animated tails falling at a fixed 215°.\n *\n * All three are `absolute inset-0` overlays and are meant to be composed —\n * `patterns/hero-cosmic.tsx` renders all three at once.\n *\n * Our reimplementation of the Aceternity UI starfield family. What is ours:\n * an `IntersectionObserver` on each layer so an off-screen hero stops\n * painting, deterministic index-seeded meteor placement (so SSR and the client\n * agree), and the reduced-motion contract below.\n *\n * ## Anatomy\n *\n *   StarsBackground                  (canvas — 2D context, resized by a\n *                                     ResizeObserver, repainted per rAF frame)\n *   ShootingStars                    (svg > rect + linearGradient#gradient)\n *   Meteors                          (div > injected <style> + span ×number)\n *\n * ## Motion — three different mechanisms, three different still states\n *\n * | Layer            | Driven by                    | Under `reduce`                      |\n * | ---------------- | ---------------------------- | ----------------------------------- |\n * | StarsBackground  | `requestAnimationFrame` loop | `drawStatic()` once, then returns   |\n * | ShootingStars    | rAF + `setTimeout` spawner   | `return null` — renders nothing     |\n * | Meteors          | injected CSS keyframe        | `return null` — renders nothing     |\n *\n * None of it is reachable by the CSS reset in `styles/preflight.css` — a\n * canvas repaint and a JS-scheduled SVG transform are invisible to it — so all\n * three read `useReducedMotion()` themselves. The stars survive because a\n * still starfield is still a starfield; a shooting star and a meteor are\n * nothing but their motion, so they are removed rather than frozen.\n *\n * `.animate-meteor-effect` is also listed in the `prefers-reduced-motion`\n * block in `styles/tokens.css`, which is belt-and-braces: the component has\n * already returned `null` by then.\n *\n * ## Accessibility — all three roots are `aria-hidden`\n *\n * Each layer's root (`canvas`, `svg`, the meteor `div`) carries\n * `aria-hidden=\"true\"`. None of them holds content, a label or a role, and\n * `HeroCosmic` stacks all three at once — so a reader working through a hero\n * used to walk an unlabelled canvas, an unlabelled graphics node and an\n * anonymous group before reaching the headline. `aria-hidden` goes on the\n * ROOT of each layer and nowhere else: a subtree hidden at its root is hidden\n * entire, and every child here (the star `rect`, the gradient `defs`, the\n * meteor `span`s, the injected `style`) is inside one of the three.\n *\n * This matches `magicui/border-beam.tsx` and the rest of `aceternity/`;\n * `background-lines.tsx` acquired the same attribute for the same reason.\n *\n * ## Per-instance SVG ids\n *\n * `ShootingStars`' trail gradient is `useScopedId(\"shooting-star-trail\")`, not\n * a literal. SVG ids are document-global and this used to be `id=\"gradient\"`,\n * so two starfields on one page — or one starfield next to any other component\n * that reached for the same obvious name — silently shared whichever gradient\n * mounted first.\n *\n * ## Known edges\n *\n * - `StarsBackground` fills every star with `rgba(255, 255, 255, α)` — white,\n *   hard-coded, with no prop and no token. It only reads on a dark surface.\n * - `ShootingStars` defaults `starColor` / `trailColor` to the hex literals\n *   `#9E00FF` / `#2EB9DF`, and `Meteors` defaults `meteorColor` to `#e9d5ff`.\n *   `HeroCosmic` passes computed `--hero-*` token values in instead.\n * - When `StarsBackground` scrolls out of view it keeps requesting frames and\n *   skips only the paint; `Meteors` switches to `animationPlayState: paused`.\n */\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport React, { useState, useEffect, useId, useRef, useCallback } from \"react\";\n\n/**\n * A DOM-id-safe token derived from `useId()`.\n *\n * SVG ids are document-global, so any literal one collides the moment a page\n * renders two of the same layer. `useId()` is the per-instance source, but its\n * output carries framework punctuation that has no business inside a `url(#…)`\n * reference, so strip it to `[A-Za-z0-9_-]` and give it a readable prefix.\n */\nfunction useScopedId(prefix: string): string {\n  return `${prefix}-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n}\n\ninterface StarProps {\n  x: number;\n  y: number;\n  radius: number;\n  opacity: number;\n  twinkleSpeed: number | null;\n}\n\ninterface StarBackgroundProps {\n  starDensity?: number;\n  allStarsTwinkle?: boolean;\n  twinkleProbability?: number;\n  minTwinkleSpeed?: number;\n  maxTwinkleSpeed?: number;\n  className?: string;\n}\n\nexport const StarsBackground: React.FC<StarBackgroundProps> = ({\n  starDensity = 0.00015,\n  allStarsTwinkle = true,\n  twinkleProbability = 0.7,\n  minTwinkleSpeed = 0.5,\n  maxTwinkleSpeed = 1,\n  className,\n}) => {\n  const [stars, setStars] = useState<StarProps[]>([]);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [isVisible, setIsVisible] = useState(true);\n  const reduceMotion = useReducedMotion();\n\n  // Pause animations when not visible (performance optimization)\n  useEffect(() => {\n    if (!canvasRef.current) return;\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        setIsVisible(entry.isIntersecting);\n      },\n      { threshold: 0.1 }\n    );\n\n    observer.observe(canvasRef.current);\n    return () => observer.disconnect();\n  }, []);\n\n  const generateStars = useCallback(\n    (width: number, height: number): StarProps[] => {\n      const area = width * height;\n      const numStars = Math.floor(area * starDensity);\n      return Array.from({ length: numStars }, () => {\n        const shouldTwinkle =\n          allStarsTwinkle || Math.random() < twinkleProbability;\n        return {\n          x: Math.random() * width,\n          y: Math.random() * height,\n          radius: Math.random() * 0.05 + 0.5,\n          opacity: Math.random() * 0.5 + 0.5,\n          twinkleSpeed: shouldTwinkle\n            ? minTwinkleSpeed +\n              Math.random() * (maxTwinkleSpeed - minTwinkleSpeed)\n            : null,\n        };\n      });\n    },\n    [\n      starDensity,\n      allStarsTwinkle,\n      twinkleProbability,\n      minTwinkleSpeed,\n      maxTwinkleSpeed,\n    ]\n  );\n\n  useEffect(() => {\n    // Copy ref to local variable to avoid stale ref in cleanup\n    const canvas = canvasRef.current;\n    \n    const updateStars = () => {\n      if (canvas) {\n        const ctx = canvas.getContext(\"2d\");\n        if (!ctx) return;\n\n        const { width, height } = canvas.getBoundingClientRect();\n        canvas.width = width;\n        canvas.height = height;\n        setStars(generateStars(width, height));\n      }\n    };\n\n    updateStars();\n\n    const resizeObserver = new ResizeObserver(updateStars);\n    if (canvas) {\n      resizeObserver.observe(canvas);\n    }\n\n    return () => {\n      if (canvas) {\n        resizeObserver.unobserve(canvas);\n      }\n    };\n  }, [\n    starDensity,\n    allStarsTwinkle,\n    twinkleProbability,\n    minTwinkleSpeed,\n    maxTwinkleSpeed,\n    generateStars,\n  ]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let animationFrameId: number;\n\n    const drawStatic = () => {\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      stars.forEach((star) => {\n        ctx.beginPath();\n        ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);\n        ctx.fillStyle = `rgba(255, 255, 255, ${star.opacity})`;\n        ctx.fill();\n      });\n    };\n\n    // Reduced-motion: paint stars once, no twinkle loop.\n    if (reduceMotion) {\n      drawStatic();\n      return;\n    }\n\n    const render = () => {\n      // Only render when visible (performance optimization)\n      if (!isVisible) {\n        animationFrameId = requestAnimationFrame(render);\n        return;\n      }\n\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      // Performance: Calculate time once per frame, not per star\n      const time = performance.now() * 0.001;\n      stars.forEach((star) => {\n        ctx.beginPath();\n        ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);\n        ctx.fillStyle = `rgba(255, 255, 255, ${star.opacity})`;\n        ctx.fill();\n\n        if (star.twinkleSpeed !== null) {\n          star.opacity =\n            0.5 +\n            Math.abs(Math.sin(time / star.twinkleSpeed) * 0.5);\n        }\n      });\n\n      animationFrameId = requestAnimationFrame(render);\n    };\n\n    render();\n\n    return () => {\n      cancelAnimationFrame(animationFrameId);\n    };\n  }, [stars, isVisible, reduceMotion]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className={cn(\"h-full w-full absolute inset-0 will-change-transform\", className)}\n      suppressHydrationWarning\n    />\n  );\n};\n\ninterface ShootingStarProps {\n  minSpeed?: number;\n  maxSpeed?: number;\n  minDelay?: number;\n  maxDelay?: number;\n  starColor?: string;\n  trailColor?: string;\n  starWidth?: number;\n  starHeight?: number;\n  className?: string;\n}\n\nexport const ShootingStars: React.FC<ShootingStarProps> = ({\n  minSpeed = 10,\n  maxSpeed = 30,\n  minDelay = 1200,\n  maxDelay = 4200,\n  starColor = \"#9E00FF\",\n  trailColor = \"#2EB9DF\",\n  starWidth = 10,\n  starHeight = 1,\n  className,\n}) => {\n  const [star, setStar] = useState<{\n    x: number;\n    y: number;\n    angle: number;\n    scale: number;\n    speed: number;\n    distance: number;\n  } | null>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const [isVisible, setIsVisible] = useState(true);\n  const reduceMotion = useReducedMotion();\n  // Was a literal `id=\"gradient\"` — about as collision-prone as an SVG id gets.\n  const gradientId = useScopedId(\"shooting-star-trail\");\n\n  // Performance: Pre-compute trig values for fixed 215° angle\n  const angleRad = (215 * Math.PI) / 180;\n  const cosAngle = Math.cos(angleRad);\n  const sinAngle = Math.sin(angleRad);\n\n  // Pause animations when not visible (performance optimization)\n  useEffect(() => {\n    if (!svgRef.current) return;\n    \n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        setIsVisible(entry.isIntersecting);\n      },\n      { threshold: 0.1 }\n    );\n    \n    observer.observe(svgRef.current);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    // Don't create new stars if not visible\n    if (!isVisible) return;\n\n    // The spawn chain is self-perpetuating, so it needs an owner. Without one,\n    // every dep change below (a re-tuned speed or delay, or scrolling back into\n    // view) started a SECOND chain while the first kept running: the effect's\n    // teardown had nothing to cancel, and the old chain only stopped once\n    // `svgRef.current` went null at unmount. Two chains means two stars a\n    // period and a `setStar` fighting itself.\n    let pending: ReturnType<typeof setTimeout> | null = null;\n    let cancelled = false;\n\n    const createStar = () => {\n      if (cancelled) return;\n      const svg = svgRef.current;\n      if (!svg) return;\n\n      const rect = svg.getBoundingClientRect();\n      const newStar = {\n        x: Math.random() * rect.width,\n        y: 0,\n        angle: 215,\n        scale: 1,\n        speed: Math.random() * (maxSpeed - minSpeed) + minSpeed,\n        distance: 0,\n      };\n      setStar(newStar);\n\n      const randomDelay = Math.random() * (maxDelay - minDelay) + minDelay;\n      pending = setTimeout(createStar, randomDelay);\n    };\n\n    createStar();\n\n    return () => {\n      cancelled = true;\n      if (pending !== null) clearTimeout(pending);\n    };\n  }, [minSpeed, maxSpeed, minDelay, maxDelay, isVisible]);\n\n  useEffect(() => {\n    if (!star) return;\n\n    let animationFrameId: number;\n\n    const moveStar = () => {\n      setStar((prevStar) => {\n        if (!prevStar) return null;\n\n        // Performance: Use pre-computed trig values\n        const newX = prevStar.x + prevStar.speed * cosAngle;\n        const newY = prevStar.y + prevStar.speed * sinAngle;\n        const newDistance = prevStar.distance + prevStar.speed;\n\n        const svg = svgRef.current;\n        if (!svg) return null;\n        const rect = svg.getBoundingClientRect();\n\n        if (\n          newX < -20 ||\n          newX > rect.width + 20 ||\n          newY < -20 ||\n          newY > rect.height + 20\n        ) {\n          return null;\n        }\n\n        return {\n          ...prevStar,\n          x: newX,\n          y: newY,\n          distance: newDistance,\n        };\n      });\n\n      animationFrameId = requestAnimationFrame(moveStar);\n    };\n\n    moveStar();\n\n    return () => {\n      cancelAnimationFrame(animationFrameId);\n    };\n  }, [star, cosAngle, sinAngle]);\n\n  // Reduced-motion: shooting stars are pure motion — emit nothing.\n  if (reduceMotion) return null;\n\n  return (\n    <svg\n      ref={svgRef}\n      aria-hidden=\"true\"\n      className={cn(\"w-full h-full absolute inset-0 pointer-events-none\", className)}\n    >\n      {star && (\n        <rect\n          x={star.x}\n          y={star.y}\n          width={starWidth * star.scale}\n          height={starHeight}\n          fill={`url(#${gradientId})`}\n          transform={`rotate(${star.angle}, ${\n            star.x + (starWidth * star.scale) / 2\n          }, ${star.y + starHeight / 2})`}\n        />\n      )}\n      <defs>\n        <linearGradient id={gradientId} x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n          <stop offset=\"0%\" style={{ stopColor: trailColor, stopOpacity: 0 }} />\n          <stop\n            offset=\"100%\"\n            style={{ stopColor: starColor, stopOpacity: 1 }}\n          />\n        </linearGradient>\n      </defs>\n    </svg>\n  );\n};\n\ninterface MeteorsProps {\n  /** Number of meteors to display */\n  number?: number;\n  /** Minimum animation duration in seconds */\n  minDuration?: number;\n  /** Maximum animation duration in seconds */\n  maxDuration?: number;\n  /** Meteor color — the bright head of the streak. */\n  meteorColor?: string;\n  /**\n   * Colour the tail fades OUT to, at the far end of the 120px streak.\n   * Defaults to `\"transparent\"`, which is the streak's natural falloff; pass a\n   * colour to have the tail land on it instead (e.g. a dim brand tint over a\n   * dark hero).\n   */\n  trailColor?: string;\n  /** Additional CSS classes */\n  className?: string;\n}\n\n/**\n * Meteors component - Aceternity-inspired falling meteor effect\n * Uses pure CSS animations for optimal performance (no Framer Motion)\n * Follows the project's CSS Animation Shift pattern\n * \n * Note: Meteors only render after client-side mount to prevent hydration mismatches\n */\nexport const Meteors: React.FC<MeteorsProps> = ({\n  number = 3,\n  minDuration = 12,\n  maxDuration = 30,\n  meteorColor = \"#e9d5ff\",\n  trailColor = \"transparent\",\n  className,\n}) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [isVisible, setIsVisible] = useState(true);\n  const [isMounted, setIsMounted] = useState(false);\n  const reduceMotion = useReducedMotion();\n\n  // Only render meteors after client-side mount to prevent hydration mismatch\n  useEffect(() => {\n    setIsMounted(true);\n  }, []);\n\n  // Generate meteor positions deterministically using lazy initializer\n  const [meteors] = useState(() =>\n    Array.from({ length: number }, (_, idx) => {\n      // Distribute meteors across a wider viewport area (1600px range centered)\n      const position = idx * (1600 / number) - 800;\n      // Use index-based pseudo-random values for consistent positioning\n      const seed = (idx * 13 + 7) % 100;\n      // Longer delay range (0-20s) for less frequent meteor appearance\n      const delay = (seed / 100) * 20;\n      const duration = minDuration + ((seed / 100) * (maxDuration - minDuration));\n\n      return {\n        id: idx,\n        position,\n        delay,\n        duration,\n        angle: 215, // Fixed angle for clean, consistent look\n      };\n    })\n  );\n\n  // Pause animations when not visible (performance optimization)\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        setIsVisible(entry.isIntersecting);\n      },\n      { threshold: 0.1 }\n    );\n\n    observer.observe(containerRef.current);\n    return () => observer.disconnect();\n  }, []);\n\n  // Component-level CSS for meteor animation (avoiding global.css dependency)\n  const meteorStyles = `\n    @keyframes meteor-effect {\n      0% {\n        transform: rotate(var(--meteor-angle, 215deg)) translateX(0);\n        opacity: 1;\n      }\n      70% {\n        opacity: 1;\n      }\n      100% {\n        transform: rotate(var(--meteor-angle, 215deg)) translateX(-500px);\n        opacity: 0;\n      }\n    }\n    .animate-meteor-effect {\n      animation: meteor-effect var(--meteor-duration, 5s) linear infinite;\n      animation-delay: var(--meteor-delay, 0s);\n    }\n  `;\n\n  // Reduced-motion: meteors are pure motion — emit nothing.\n  if (reduceMotion) return null;\n\n  return (\n    <div\n      ref={containerRef}\n      aria-hidden=\"true\"\n      className={cn(\n        \"absolute inset-0 overflow-hidden pointer-events-none\",\n        className\n      )}\n      suppressHydrationWarning\n    >\n      {/* Inject component-scoped CSS only on client to prevent hydration mismatch */}\n      {isMounted && <style suppressHydrationWarning dangerouslySetInnerHTML={{ __html: meteorStyles }} />}\n      {/* Only render meteors after client mount to prevent hydration mismatch */}\n      {isMounted && meteors.map((meteor) => (\n        <span\n          key={`meteor-${meteor.id}`}\n          className=\"animate-meteor-effect absolute\"\n          style={{\n            top: \"-40px\",\n            left: `calc(50% + ${meteor.position}px)`,\n            // Longer, thinner tail for realistic meteor trail\n            width: \"120px\",\n            height: \"1px\",\n            // Bright head fading down the tail to `trailColor` (transparent by\n            // default, which is why wiring the prop up changes nothing unless\n            // you pass one).\n            background: `linear-gradient(90deg, ${meteorColor} 0%, ${meteorColor}80 10%, ${trailColor} 100%)`,\n            opacity: 0.8,\n            // Very subtle glow for thin, delicate meteor look\n            boxShadow: `0 0 2px 0px ${meteorColor}50`,\n            borderRadius: \"9999px\",\n            [\"--meteor-angle\" as string]: `${meteor.angle}deg`,\n            [\"--meteor-delay\" as string]: `${meteor.delay}s`,\n            [\"--meteor-duration\" as string]: `${meteor.duration}s`,\n            animationPlayState: isVisible ? \"running\" : \"paused\",\n          }}\n        />\n      ))}\n    </div>\n  );\n};\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/stars-background\n\nInstalled to `components/ui/aceternity/stars-background.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/aceternity/stars-background';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/stars-background\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
