{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A GravityStars particle background component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nexport interface GravityStarsProps {\n  /** Foreground content, always painted above the decorative starfield. */\n  children?: ReactNode;\n  /** Extra classes for the positioned wrapper element. */\n  className?: string;\n  /**\n   * Base star colour. Accepts any CSS colour, a `var(--token)` expression, or a\n   * bare custom property name such as `--color-foreground`.\n   */\n  color?: string;\n  /** Draws thin, distance-faded links between stars that are close together. */\n  connect?: boolean;\n  /** Maximum distance in CSS pixels at which two stars are linked. */\n  connectDistance?: number;\n  /** Number of stars in the pool. Reduced automatically on narrow viewports. */\n  count?: number;\n  /** Velocity damping per frame, from 0 (frictionless) to 1 (frozen). */\n  drag?: number;\n  /** Halo radius multiplier for the glow sprite. 1 is a bare dot, 8 is a bloom. */\n  glow?: number;\n  /** Strength of the pointer gravity well. */\n  gravity?: number;\n  /** Freezes the animation on the current frame. */\n  paused?: boolean;\n  /** Ambient drift speed multiplier. 1 is the calibrated default. */\n  speed?: number;\n  /** Base star radius in CSS pixels, before the depth multiplier. */\n  starSize?: number;\n  /**\n   * How far individual stars are tinted away from `color` towards a cool\n   * blue-white and a warm amber, from 0 (one flat colour) to 1 (full spread).\n   * A small amount is what makes the field read as a sky rather than grey dots.\n   */\n  tint?: number;\n  /** Brightness oscillation amount, from 0 (steady) to 1 (strong). */\n  twinkle?: number;\n}\n\ntype Rgb = [number, number, number];\n\ninterface Palette {\n  base: Rgb;\n  cool: Rgb;\n  warm: Rgb;\n}\n\ninterface StarSettings {\n  connect: boolean;\n  connectDistance: number;\n  count: number;\n  drag: number;\n  glow: number;\n  gravity: number;\n  palette: Palette;\n  speed: number;\n  starSize: number;\n  still: boolean;\n  tint: number;\n  twinkle: number;\n}\n\ninterface StarController {\n  destroy: () => void;\n  render: () => void;\n  resize: () => void;\n  setPointer: (x: number, y: number, active: boolean) => void;\n  setRunning: (running: boolean) => void;\n  setSettings: (settings: StarSettings) => void;\n}\n\nconst MAX_DPR = 2;\nconst MS_PER_SECOND = 1000;\nconst TAU = Math.PI * 2;\n\nconst DEFAULT_COUNT = 220;\nconst DEFAULT_STAR_SIZE = 1.5;\nconst DEFAULT_GRAVITY = 1;\nconst DEFAULT_DRAG = 0.03;\nconst DEFAULT_CONNECT_DISTANCE = 130;\nconst DEFAULT_TWINKLE = 0.55;\nconst DEFAULT_GLOW = 5.5;\nconst DEFAULT_SPEED = 1;\nconst DEFAULT_TINT = 0.5;\nconst DEFAULT_COLOR = \"var(--color-foreground, oklch(0.93 0.004 265))\";\n\n/**\n * Stellar tint endpoints. Real skies are near-neutral with a slight hue\n * spread — hot stars lean blue-white, cool ones amber. Lightness is held\n * constant across both so the spread reads as colour, never as brightness, and\n * each hue gets a comparable fraction of its own sRGB chroma ceiling.\n */\nconst TINT_COOL = \"oklch(0.92 0.028 255)\";\nconst TINT_WARM = \"oklch(0.92 0.055 75)\";\n\n/** oklch(0.93 0.004 265) — the neutral used when the probe cannot resolve. */\nconst FALLBACK_BASE: Rgb = [232, 233, 238];\n/** oklch(0.92 0.028 255) */\nconst FALLBACK_COOL: Rgb = [221, 233, 247];\n/** oklch(0.92 0.055 75) */\nconst FALLBACK_WARM: Rgb = [243, 226, 194];\n\nconst FALLBACK_PALETTE: Palette = {\n  base: FALLBACK_BASE,\n  cool: FALLBACK_COOL,\n  warm: FALLBACK_WARM,\n};\n\nconst MAX_COUNT = 600;\nconst MIN_COUNT = 8;\nconst SMALL_VIEWPORT_WIDTH = 640;\nconst SMALL_VIEWPORT_RATIO = 0.6;\n\n/** Gravity well tuning. Softening keeps the inverse square finite at r → 0. */\nconst POINTER_RANGE = 260;\nconst POINTER_ACCEL = 4_000_000;\nconst SOFTENING_SQ = 676;\nconst MAX_ACCEL = 5000;\nconst MAX_SPEED = 1200;\nconst SWIRL = 0.85;\nconst DRIFT_SPEED = 12;\n\nconst MAX_DELTA = 0.05;\nconst REFERENCE_FPS = 60;\nconst MIN_GRID_CELL = 24;\nconst MAX_LINKS_PER_STAR = 8;\n\n/** Depth shaping: `u ** DEPTH_BIAS` skews the pool towards small, far stars. */\nconst DEPTH_BIAS = 1.7;\nconst NEAR_SIZE_GAIN = 1.9;\nconst FAR_SIZE_FLOOR = 0.42;\nconst NEAR_ALPHA_GAIN = 0.82;\nconst FAR_ALPHA_FLOOR = 0.18;\nconst NEAR_HALO_GAIN = 0.7;\nconst FAR_HALO_FLOOR = 0.6;\nconst NEAR_REACH_GAIN = 0.8;\nconst FAR_REACH_FLOOR = 0.6;\nconst NEAR_PULL_GAIN = 1;\nconst FAR_PULL_FLOOR = 0.35;\nconst MIN_VISIBLE_ALPHA = 0.01;\n\nconst LINE_LEVELS = 6;\nconst LINE_BASE_ALPHA = 0.3;\nconst LINE_MIN_WIDTH = 0.3;\nconst LINE_WIDTH_STEP = 0.12;\nconst LINE_CLOSENESS_CURVE = 1.5;\nconst LINE_DEPTH_FLOOR = 0.25;\nconst LINE_DEPTH_GAIN = 0.75;\n\nconst TWINKLE_RATE = 1.15;\nconst TWINKLE_RATE_SPREAD = 0.8;\nconst TWINKLE_DEPTH = 0.5;\nconst SIZE_JITTER = 0.5;\n\nconst SPRITE_PX = 128;\nconst SPRITE_LEVELS = 5;\nconst SEED = 987_654_321;\nconst LEHMER_MULTIPLIER = 16_807;\nconst LEHMER_MODULUS = 2_147_483_647;\n\n/**\n * Half the neighbourhood as flat `(dCol, dRow)` pairs, so every pair of cells is\n * compared exactly once and the hot loop allocates nothing.\n */\nconst FORWARD_NEIGHBOURS = [1, 0, -1, 1, 0, 1, 1, 1];\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\n\nconst mix = (from: number, to: number, amount: number) =>\n  from + (to - from) * amount;\n\nconst toCssColor = (input: string) =>\n  input.trim().startsWith(\"--\") ? `var(${input.trim()})` : input.trim();\n\n/**\n * Reads real computed colours out of the document, so tokens such as\n * `--color-foreground` follow the active theme instead of being duplicated in a\n * parallel JS palette. A hidden probe span resolves the cascade, then a 1×1\n * canvas converts whatever notation came back into channels.\n */\nconst resolveCssColors = (inputs: string[], host: HTMLElement): Rgb[] => {\n  const probe = document.createElement(\"span\");\n  probe.style.display = \"none\";\n  host.append(probe);\n\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = 1;\n  canvas.height = 1;\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n\n  const resolved = inputs.map((input, index) => {\n    const fallback =\n      index === 0\n        ? FALLBACK_BASE\n        : (FALLBACK_PALETTE[index === 1 ? \"cool\" : \"warm\"] ?? FALLBACK_BASE);\n    if (!context) {\n      return fallback;\n    }\n    probe.style.color = \"\";\n    probe.style.color = toCssColor(input);\n    const computed = window.getComputedStyle(probe).color;\n    if (!computed) {\n      return fallback;\n    }\n    context.clearRect(0, 0, 1, 1);\n    context.fillStyle = \"#000000\";\n    context.fillStyle = computed;\n    context.fillRect(0, 0, 1, 1);\n    const { data } = context.getImageData(0, 0, 1, 1);\n    return [data[0], data[1], data[2]] as Rgb;\n  });\n\n  probe.remove();\n  return resolved;\n};\n\n/**\n * Deterministic Lehmer PRNG so the seeded layout is identical on every render\n * and on both sides of hydration.\n */\nconst createRandom = (seed: number) => {\n  let state = seed % LEHMER_MODULUS || 1;\n  return () => {\n    state = (state * LEHMER_MULTIPLIER) % LEHMER_MODULUS;\n    return state / LEHMER_MODULUS;\n  };\n};\n\n/**\n * Radial gradient sprite: a hot core dissolving into a wide, low-alpha halo.\n * Built once per tint and blitted per star, which is far cheaper — and far\n * softer — than filling a path or paying `shadowBlur` for every particle. The\n * falloff is deliberately wide and faint so it reads astronomical, not neon.\n */\nconst paintSprite = (sprite: HTMLCanvasElement, color: Rgb) => {\n  const context = sprite.getContext(\"2d\");\n  if (!context) {\n    return;\n  }\n  const [r, g, b] = color;\n  const center = SPRITE_PX / 2;\n  context.clearRect(0, 0, SPRITE_PX, SPRITE_PX);\n  const gradient = context.createRadialGradient(\n    center,\n    center,\n    0,\n    center,\n    center,\n    center\n  );\n  gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 1)`);\n  gradient.addColorStop(0.06, `rgba(${r}, ${g}, ${b}, 0.95)`);\n  gradient.addColorStop(0.14, `rgba(${r}, ${g}, ${b}, 0.5)`);\n  gradient.addColorStop(0.28, `rgba(${r}, ${g}, ${b}, 0.16)`);\n  gradient.addColorStop(0.55, `rgba(${r}, ${g}, ${b}, 0.04)`);\n  gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);\n  context.fillStyle = gradient;\n  context.fillRect(0, 0, SPRITE_PX, SPRITE_PX);\n};\n\n/**\n * One sprite per tint step, from the cool end to the warm end. `tint` controls\n * how far each step travels away from the base colour, so 0 collapses the whole\n * ramp back onto a single flat hue.\n */\nconst paintSprites = (\n  sprites: HTMLCanvasElement[],\n  palette: Palette,\n  tint: number\n) => {\n  const last = Math.max(sprites.length - 1, 1);\n  for (let level = 0; level < sprites.length; level++) {\n    const ramp = level / last;\n    const color: Rgb = [\n      Math.round(\n        mix(palette.base[0], mix(palette.cool[0], palette.warm[0], ramp), tint)\n      ),\n      Math.round(\n        mix(palette.base[1], mix(palette.cool[1], palette.warm[1], ramp), tint)\n      ),\n      Math.round(\n        mix(palette.base[2], mix(palette.cool[2], palette.warm[2], ramp), tint)\n      ),\n    ];\n    paintSprite(sprites[level], color);\n  }\n};\n\nconst samePalette = (a: Palette, b: Palette) =>\n  a.base[0] === b.base[0] &&\n  a.base[1] === b.base[1] &&\n  a.base[2] === b.base[2] &&\n  a.cool[0] === b.cool[0] &&\n  a.warm[0] === b.warm[0];\n\nconst createStarController = (\n  canvas: HTMLCanvasElement\n): StarController | null => {\n  const context = canvas.getContext(\"2d\");\n  if (!context) {\n    return null;\n  }\n\n  const sprites: HTMLCanvasElement[] = [];\n  for (let level = 0; level < SPRITE_LEVELS; level++) {\n    const sprite = document.createElement(\"canvas\");\n    sprite.width = SPRITE_PX;\n    sprite.height = SPRITE_PX;\n    sprites.push(sprite);\n  }\n\n  const posX = new Float32Array(MAX_COUNT);\n  const posY = new Float32Array(MAX_COUNT);\n  const velX = new Float32Array(MAX_COUNT);\n  const velY = new Float32Array(MAX_COUNT);\n  const driftX = new Float32Array(MAX_COUNT);\n  const driftY = new Float32Array(MAX_COUNT);\n  const depth = new Float32Array(MAX_COUNT);\n  const phase = new Float32Array(MAX_COUNT);\n  const rate = new Float32Array(MAX_COUNT);\n  const jitter = new Float32Array(MAX_COUNT);\n  const seedX = new Float32Array(MAX_COUNT);\n  const seedY = new Float32Array(MAX_COUNT);\n  const tintLevel = new Uint8Array(MAX_COUNT);\n\n  const random = createRandom(SEED);\n  for (let i = 0; i < MAX_COUNT; i++) {\n    seedX[i] = random();\n    seedY[i] = random();\n    const z = random() ** DEPTH_BIAS;\n    depth[i] = z;\n    phase[i] = random() * TAU;\n    rate[i] = TWINKLE_RATE * (1 + (random() - 0.5) * TWINKLE_RATE_SPREAD);\n    jitter[i] = 1 + (random() - 0.5) * SIZE_JITTER;\n    // Averaging two draws gives a centre-weighted spread, so most stars sit\n    // near the neutral base and only a few reach the blue or amber ends.\n    const hue = (random() + random()) / 2;\n    tintLevel[i] = Math.min(SPRITE_LEVELS - 1, Math.floor(hue * SPRITE_LEVELS));\n    const angle = random() * TAU;\n    // Nearer stars drift faster: the parallax that sells the depth.\n    const magnitude = DRIFT_SPEED * (0.15 + z);\n    driftX[i] = Math.cos(angle) * magnitude;\n    driftY[i] = Math.sin(angle) * magnitude;\n  }\n\n  const maxSegments = MAX_COUNT * MAX_LINKS_PER_STAR;\n  const segments = new Float32Array(maxSegments * 4);\n  const segmentLevel = new Uint8Array(maxSegments);\n\n  let cellStart = new Int32Array(1);\n  let cellCursor = new Int32Array(1);\n  let cellItems = new Int32Array(MAX_COUNT);\n\n  const startedAt = performance.now();\n  let settings: StarSettings = {\n    connect: true,\n    connectDistance: DEFAULT_CONNECT_DISTANCE,\n    count: DEFAULT_COUNT,\n    drag: DEFAULT_DRAG,\n    glow: DEFAULT_GLOW,\n    gravity: DEFAULT_GRAVITY,\n    palette: FALLBACK_PALETTE,\n    speed: DEFAULT_SPEED,\n    starSize: DEFAULT_STAR_SIZE,\n    still: false,\n    tint: DEFAULT_TINT,\n    twinkle: DEFAULT_TWINKLE,\n  };\n  paintSprites(sprites, settings.palette, settings.tint);\n\n  let width = 1;\n  let height = 1;\n  let activeCount = 0;\n  let seeded = false;\n  let pointerX = 0;\n  let pointerY = 0;\n  let pointerActive = false;\n  let lastFrameAt = startedAt;\n  let frame = 0;\n  let running = false;\n  let destroyed = false;\n\n  const effectiveCount = () => {\n    const base = clamp(Math.round(settings.count), MIN_COUNT, MAX_COUNT);\n    return width < SMALL_VIEWPORT_WIDTH\n      ? Math.max(MIN_COUNT, Math.round(base * SMALL_VIEWPORT_RATIO))\n      : base;\n  };\n\n  const seedPositions = (next: number) => {\n    for (let i = 0; i < next; i++) {\n      posX[i] = seedX[i] * width;\n      posY[i] = seedY[i] * height;\n      velX[i] = 0;\n      velY[i] = 0;\n    }\n    activeCount = next;\n    seeded = true;\n  };\n\n  const resize = () => {\n    const rect = canvas.getBoundingClientRect();\n    const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n    const previousWidth = width;\n    const previousHeight = height;\n    width = Math.max(1, rect.width);\n    height = Math.max(1, rect.height);\n    const pixelWidth = Math.max(1, Math.round(width * dpr));\n    const pixelHeight = Math.max(1, Math.round(height * dpr));\n    if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {\n      canvas.width = pixelWidth;\n      canvas.height = pixelHeight;\n    }\n    context.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const next = effectiveCount();\n    if (!seeded || next !== activeCount) {\n      seedPositions(next);\n      return;\n    }\n    const scaleX = width / previousWidth;\n    const scaleY = height / previousHeight;\n    for (let i = 0; i < activeCount; i++) {\n      posX[i] *= scaleX;\n      posY[i] *= scaleY;\n    }\n  };\n\n  const gridGeometry = () => {\n    const cell = Math.max(settings.connectDistance, MIN_GRID_CELL);\n    return {\n      cell,\n      cols: Math.max(1, Math.ceil(width / cell)),\n      rows: Math.max(1, Math.ceil(height / cell)),\n    };\n  };\n\n  /** Counting sort of the particle pool into a uniform grid. */\n  const sortIntoGrid = (cell: number, cols: number, rows: number) => {\n    const total = cols * rows;\n    if (cellStart.length < total + 1) {\n      cellStart = new Int32Array(total + 1);\n      cellCursor = new Int32Array(total + 1);\n    }\n    cellStart.fill(0, 0, total + 1);\n    if (cellItems.length < activeCount) {\n      cellItems = new Int32Array(activeCount);\n    }\n\n    for (let i = 0; i < activeCount; i++) {\n      const col = clamp(Math.floor(posX[i] / cell), 0, cols - 1);\n      const row = clamp(Math.floor(posY[i] / cell), 0, rows - 1);\n      cellStart[row * cols + col + 1]++;\n    }\n    for (let c = 0; c < total; c++) {\n      cellStart[c + 1] += cellStart[c];\n      cellCursor[c] = cellStart[c];\n    }\n    for (let i = 0; i < activeCount; i++) {\n      const col = clamp(Math.floor(posX[i] / cell), 0, cols - 1);\n      const row = clamp(Math.floor(posY[i] / cell), 0, rows - 1);\n      const index = row * cols + col;\n      cellItems[cellCursor[index]] = i;\n      cellCursor[index]++;\n    }\n  };\n\n  /**\n   * Softened inverse-square attraction with a tangential term, so stars fall\n   * in, swirl past the pointer and are flung back out carrying momentum.\n   * Nearer stars feel a wider, stronger well than distant ones.\n   */\n  const applyPointer = (i: number, out: Float32Array) => {\n    const z = depth[i];\n    const reach = POINTER_RANGE * (FAR_REACH_FLOOR + z * NEAR_REACH_GAIN);\n    const dx = pointerX - posX[i];\n    const dy = pointerY - posY[i];\n    const distanceSq = dx * dx + dy * dy;\n    if (distanceSq > reach * reach) {\n      return;\n    }\n    const distance = Math.sqrt(distanceSq);\n    const softened = Math.sqrt(distanceSq + SOFTENING_SQ);\n    const fade = 1 - distance / reach;\n    const pull = FAR_PULL_FLOOR + z * NEAR_PULL_GAIN;\n    const strength =\n      Math.min(\n        (settings.gravity * POINTER_ACCEL * pull) / (softened * softened),\n        MAX_ACCEL\n      ) *\n      fade *\n      fade;\n    const nx = dx / softened;\n    const ny = dy / softened;\n    out[0] += nx * strength - ny * strength * SWIRL;\n    out[1] += ny * strength + nx * strength * SWIRL;\n  };\n\n  const accel = new Float32Array(2);\n\n  const step = (delta: number) => {\n    const damping = (1 - settings.drag) ** (delta * REFERENCE_FPS);\n    const { speed } = settings;\n\n    for (let i = 0; i < activeCount; i++) {\n      accel[0] = 0;\n      accel[1] = 0;\n      if (pointerActive) {\n        applyPointer(i, accel);\n      }\n\n      let vx = (velX[i] + accel[0] * delta) * damping;\n      let vy = (velY[i] + accel[1] * delta) * damping;\n      const speedSq = vx * vx + vy * vy;\n      if (speedSq > MAX_SPEED * MAX_SPEED) {\n        const scale = MAX_SPEED / Math.sqrt(speedSq);\n        vx *= scale;\n        vy *= scale;\n      }\n      velX[i] = vx;\n      velY[i] = vy;\n\n      // Ambient drift is added outside the damped term, so a flung star bleeds\n      // its momentum away and settles back into the calm parallax layer.\n      posX[i] += (vx + driftX[i] * speed) * delta;\n      posY[i] += (vy + driftY[i] * speed) * delta;\n\n      if (posX[i] < 0) {\n        posX[i] += width;\n      } else if (posX[i] > width) {\n        posX[i] -= width;\n      }\n      if (posY[i] < 0) {\n        posY[i] += height;\n      } else if (posY[i] > height) {\n        posY[i] -= height;\n      }\n    }\n  };\n\n  const buildSegments = (cols: number, rows: number) => {\n    const limit = settings.connectDistance * settings.connectDistance;\n    let segmentTotal = 0;\n\n    const consider = (a: number, b: number) => {\n      if (segmentTotal >= maxSegments) {\n        return;\n      }\n      const dx = posX[a] - posX[b];\n      const dy = posY[a] - posY[b];\n      const d2 = dx * dx + dy * dy;\n      if (d2 > limit || d2 === 0) {\n        return;\n      }\n      // Alpha falls off with distance and with how far back the endpoints sit,\n      // so the constellation reads as depth rather than a web of grey sticks.\n      const closeness =\n        (1 - Math.sqrt(d2) / settings.connectDistance) ** LINE_CLOSENESS_CURVE;\n      const depthWeight =\n        LINE_DEPTH_FLOOR + ((depth[a] + depth[b]) / 2) * LINE_DEPTH_GAIN;\n      const level = Math.min(\n        LINE_LEVELS - 1,\n        Math.floor(closeness * depthWeight * LINE_LEVELS)\n      );\n      const offset = segmentTotal * 4;\n      segments[offset] = posX[a];\n      segments[offset + 1] = posY[a];\n      segments[offset + 2] = posX[b];\n      segments[offset + 3] = posY[b];\n      segmentLevel[segmentTotal] = level;\n      segmentTotal++;\n    };\n\n    // Only forward neighbours are visited, so every pair is tested once.\n    for (let row = 0; row < rows; row++) {\n      for (let col = 0; col < cols; col++) {\n        const index = row * cols + col;\n        const start = cellStart[index];\n        const end = cellStart[index + 1];\n        for (let a = start; a < end; a++) {\n          const i = cellItems[a];\n          for (let b = a + 1; b < end; b++) {\n            consider(i, cellItems[b]);\n          }\n          for (let n = 0; n < FORWARD_NEIGHBOURS.length; n += 2) {\n            const nc = col + FORWARD_NEIGHBOURS[n];\n            const nr = row + FORWARD_NEIGHBOURS[n + 1];\n            if (nc < 0 || nc >= cols || nr >= rows) {\n              continue;\n            }\n            const nIndex = nr * cols + nc;\n            const nStart = cellStart[nIndex];\n            const nEnd = cellStart[nIndex + 1];\n            for (let b = nStart; b < nEnd; b++) {\n              consider(i, cellItems[b]);\n            }\n          }\n        }\n      }\n    }\n\n    return segmentTotal;\n  };\n\n  /**\n   * One `beginPath`/`stroke` pair per alpha bucket. Stroking each pair\n   * individually would blow the frame budget on its own.\n   */\n  const drawLinks = (segmentTotal: number) => {\n    const [r, g, b] = settings.palette.base;\n    for (let level = 0; level < LINE_LEVELS; level++) {\n      const alpha = (LINE_BASE_ALPHA * (level + 1)) / LINE_LEVELS;\n      context.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;\n      context.lineWidth = LINE_MIN_WIDTH + level * LINE_WIDTH_STEP;\n      context.beginPath();\n      let drew = false;\n      for (let s = 0; s < segmentTotal; s++) {\n        if (segmentLevel[s] !== level) {\n          continue;\n        }\n        const offset = s * 4;\n        context.moveTo(segments[offset], segments[offset + 1]);\n        context.lineTo(segments[offset + 2], segments[offset + 3]);\n        drew = true;\n      }\n      if (drew) {\n        context.stroke();\n      }\n    }\n  };\n\n  const drawStars = () => {\n    const elapsed = (performance.now() - startedAt) / MS_PER_SECOND;\n    const { glow, starSize, still, twinkle } = settings;\n\n    for (let i = 0; i < activeCount; i++) {\n      const z = depth[i];\n      // Each star carries its own phase and rate, so the field never pulses in\n      // unison. When still, the wave is frozen at the seeded phase.\n      const wave = still\n        ? Math.sin(phase[i])\n        : Math.sin(elapsed * rate[i] + phase[i]);\n      const brightness =\n        (FAR_ALPHA_FLOOR + z * NEAR_ALPHA_GAIN) *\n        (1 - twinkle * TWINKLE_DEPTH * (1 - wave));\n      const alpha = clamp(brightness, 0, 1);\n      if (alpha <= MIN_VISIBLE_ALPHA) {\n        continue;\n      }\n      const core = starSize * (FAR_SIZE_FLOOR + z * NEAR_SIZE_GAIN) * jitter[i];\n      const halo = core * glow * (FAR_HALO_FLOOR + z * NEAR_HALO_GAIN);\n      context.globalAlpha = alpha;\n      context.drawImage(\n        sprites[tintLevel[i]],\n        posX[i] - halo,\n        posY[i] - halo,\n        halo * 2,\n        halo * 2\n      );\n    }\n    context.globalAlpha = 1;\n  };\n\n  const draw = (segmentTotal: number) => {\n    context.clearRect(0, 0, width, height);\n    if (settings.connect && segmentTotal > 0) {\n      drawLinks(segmentTotal);\n    }\n    drawStars();\n  };\n\n  const renderStatic = () => {\n    const { cell, cols, rows } = gridGeometry();\n    sortIntoGrid(cell, cols, rows);\n    const segmentTotal = settings.connect ? buildSegments(cols, rows) : 0;\n    draw(segmentTotal);\n  };\n\n  const tick = () => {\n    if (destroyed || !running) {\n      return;\n    }\n    const now = performance.now();\n    const delta = Math.min((now - lastFrameAt) / MS_PER_SECOND, MAX_DELTA);\n    lastFrameAt = now;\n    step(delta);\n    let segmentTotal = 0;\n    if (settings.connect) {\n      const { cell, cols, rows } = gridGeometry();\n      sortIntoGrid(cell, cols, rows);\n      segmentTotal = buildSegments(cols, rows);\n    }\n    draw(segmentTotal);\n    frame = requestAnimationFrame(tick);\n  };\n\n  resize();\n\n  return {\n    destroy: () => {\n      destroyed = true;\n      running = false;\n      cancelAnimationFrame(frame);\n      context.setTransform(1, 0, 0, 1, 0, 0);\n      context.clearRect(0, 0, canvas.width, canvas.height);\n      canvas.width = 0;\n      canvas.height = 0;\n      for (const sprite of sprites) {\n        sprite.width = 0;\n        sprite.height = 0;\n      }\n    },\n    render: () => {\n      resize();\n      renderStatic();\n    },\n    resize: () => {\n      resize();\n      if (!running) {\n        renderStatic();\n      }\n    },\n    setPointer: (x: number, y: number, active: boolean) => {\n      pointerX = x;\n      pointerY = y;\n      pointerActive = active;\n    },\n    setRunning: (next: boolean) => {\n      if (destroyed || running === next) {\n        return;\n      }\n      running = next;\n      if (next) {\n        lastFrameAt = performance.now();\n        frame = requestAnimationFrame(tick);\n      } else {\n        cancelAnimationFrame(frame);\n      }\n    },\n    setSettings: (next: StarSettings) => {\n      const changedCount = next.count !== settings.count;\n      const changedSprites =\n        next.tint !== settings.tint ||\n        !samePalette(next.palette, settings.palette);\n      settings = next;\n      if (changedSprites) {\n        paintSprites(sprites, next.palette, next.tint);\n      }\n      if (changedCount) {\n        seedPositions(effectiveCount());\n      }\n    },\n  };\n};\n\nconst GravityStars = ({\n  children,\n  className,\n  color = DEFAULT_COLOR,\n  connect = true,\n  connectDistance = DEFAULT_CONNECT_DISTANCE,\n  count = DEFAULT_COUNT,\n  drag = DEFAULT_DRAG,\n  glow = DEFAULT_GLOW,\n  gravity = DEFAULT_GRAVITY,\n  paused = false,\n  speed = DEFAULT_SPEED,\n  starSize = DEFAULT_STAR_SIZE,\n  tint = DEFAULT_TINT,\n  twinkle = DEFAULT_TWINKLE,\n}: GravityStarsProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const hostRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const controllerRef = useRef<StarController | null>(null);\n  const [isSupported, setIsSupported] = useState(true);\n  const [isActive, setIsActive] = useState(false);\n  const [palette, setPalette] = useState<Palette>(FALLBACK_PALETTE);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    const [base, cool, warm] = resolveCssColors(\n      [color, TINT_COOL, TINT_WARM],\n      host\n    );\n    setPalette({\n      base: base ?? FALLBACK_BASE,\n      cool: cool ?? FALLBACK_COOL,\n      warm: warm ?? FALLBACK_WARM,\n    });\n  }, [color]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const controller = createStarController(canvas);\n    controllerRef.current = controller;\n    setIsSupported(controller !== null);\n    if (!controller) {\n      return;\n    }\n\n    const observer = new ResizeObserver(() => controller.resize());\n    observer.observe(canvas);\n\n    return () => {\n      observer.disconnect();\n      controller.destroy();\n      controllerRef.current = null;\n    };\n  }, []);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!(host && isSupported)) {\n      return;\n    }\n\n    let isOnScreen = true;\n    const sync = () => {\n      setIsActive(isOnScreen && document.visibilityState === \"visible\");\n    };\n\n    const observer = new IntersectionObserver((entries) => {\n      isOnScreen = entries.some((entry) => entry.isIntersecting);\n      sync();\n    });\n    observer.observe(host);\n    document.addEventListener(\"visibilitychange\", sync);\n    sync();\n\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n    };\n  }, [isSupported]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    controller.setSettings({\n      connect,\n      connectDistance: Math.max(connectDistance, 1),\n      count: clamp(Math.round(count), MIN_COUNT, MAX_COUNT),\n      drag: clamp(drag, 0, 1),\n      glow: Math.max(glow, 1),\n      gravity: Math.max(gravity, 0),\n      palette,\n      speed: Math.max(speed, 0),\n      starSize: Math.max(starSize, 0.2),\n      still: shouldReduceMotion === true,\n      tint: clamp(tint, 0, 1),\n      twinkle: clamp(twinkle, 0, 1),\n    });\n    controller.render();\n  }, [\n    connect,\n    connectDistance,\n    count,\n    drag,\n    glow,\n    gravity,\n    palette,\n    shouldReduceMotion,\n    speed,\n    starSize,\n    tint,\n    twinkle,\n  ]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    const shouldRun = isActive && !paused && !shouldReduceMotion;\n    controller.setRunning(shouldRun);\n    if (!shouldRun) {\n      controller.render();\n    }\n  }, [isActive, paused, shouldReduceMotion]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    const canvas = canvasRef.current;\n    if (!(host && canvas) || shouldReduceMotion) {\n      return;\n    }\n\n    // Pointer events, not hover: the well has to answer a dragged finger as\n    // well as a mouse.\n    const handleMove = (event: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      controllerRef.current?.setPointer(\n        event.clientX - rect.left,\n        event.clientY - rect.top,\n        true\n      );\n    };\n    const handleLeave = () => {\n      controllerRef.current?.setPointer(0, 0, false);\n    };\n\n    host.addEventListener(\"pointermove\", handleMove, { passive: true });\n    host.addEventListener(\"pointerleave\", handleLeave);\n    host.addEventListener(\"pointercancel\", handleLeave);\n\n    return () => {\n      host.removeEventListener(\"pointermove\", handleMove);\n      host.removeEventListener(\"pointerleave\", handleLeave);\n      host.removeEventListener(\"pointercancel\", handleLeave);\n    };\n  }, [shouldReduceMotion]);\n\n  const fallbackColor = toCssColor(color);\n\n  return (\n    <div\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      ref={hostRef}\n    >\n      {isSupported ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n        >\n          <canvas className=\"h-full w-full\" ref={canvasRef} />\n        </div>\n      ) : (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 opacity-60\"\n          style={{\n            backgroundImage: [\n              `radial-gradient(circle at 30% 30%, ${fallbackColor} 0px, transparent 1.5px)`,\n              `radial-gradient(circle at 70% 65%, ${fallbackColor} 0px, transparent 1px)`,\n            ].join(\", \"),\n            backgroundSize: \"96px 96px, 53px 53px\",\n          }}\n        />\n      )}\n      <div className=\"relative z-10 h-full\">{children}</div>\n    </div>\n  );\n};\n\nexport default GravityStars;\n","path":"index.tsx","target":"components/smoothui/gravity-stars/index.tsx","type":"registry:ui"}],"name":"gravity-stars","registryDependencies":[],"title":"Gravity Stars","type":"registry:ui"}