{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "neon-loader",
  "title": "NeonLoader",
  "description": "Noise-resolve loading indicator animated from the official Neon mark.",
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/neon-tokens.json"
  ],
  "files": [
    {
      "path": "src/components/neon-loader/neon-loader.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ComponentProps, CSSProperties } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type NeonLoaderSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface LoaderMark {\n  /** SVG path data for the mark, in the mark's own coordinate space. */\n  path: string;\n  /** ViewBox width of the path. */\n  width: number;\n  /** ViewBox height of the path. */\n  height: number;\n}\n\nexport type NeonLoaderProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Accessible status text and, by default, the visible label. */\n  label?: string;\n  /** Preset or exact pixel size of the Neon mark. */\n  size?: NeonLoaderSize | number;\n  /** Hide the visible label while retaining it for assistive technology. */\n  showLabel?: boolean;\n  /** Remove status semantics when the loader is purely decorative. */\n  decorative?: boolean;\n  /** Duration of one noise-resolve loop in milliseconds. */\n  duration?: number;\n  /** Swap the Neon mark for your own logo path. */\n  mark?: LoaderMark;\n};\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD (one loop, canvas-rendered)\n *\n *   0%   pure grain, logo fully hidden\n *  20%   grain starts collapsing into the mark\n *  45%   mark fully resolved, grain gone\n *  78%   mark holds solid\n * 100%   mark dissolved back into grain; loop restarts\n * ───────────────────────────────────────────────────────── */\nconst LOADER_TIMING = {\n  /** One full noise → logo → noise loop. */\n  durationMs: 4000,\n  /** Mark begins dissolving. */\n  holdEnd: 0.78,\n  /** Mark fully resolved. */\n  holdStart: 0.45,\n  /** Grain refresh rate in frames per second. */\n  noiseFps: 20,\n  /** Grain begins collapsing into the mark. */\n  resolveStart: 0.2,\n};\n\nconst LOADER_SIZE: Record<NeonLoaderSize, number> = {\n  lg: 48,\n  md: 32,\n  sm: 24,\n};\n\n/** Official Neon mark, from the published brand SVG (viewBox 0 0 31.3 31.6). */\nconst NEON_MARK_PATH =\n  \"M31.3,0v31.6l-12.2-10.6v10.6H0V0h31.3ZM3.8,27.7h11.4v-15.2l12.2,10.8V3.8H3.8s0,23.9,0,23.9Z\";\n\nconst MARK_WIDTH = 31.3;\nconst MARK_HEIGHT = 31.6;\n\n/** Default mark: the official Neon logo. */\nconst NEON_MARK: LoaderMark = {\n  height: MARK_HEIGHT,\n  path: NEON_MARK_PATH,\n  width: MARK_WIDTH,\n};\n\n/** Encode any mark as an SVG data URI for use as a CSS mask. */\nconst markUri = (mark: LoaderMark) =>\n  `data:image/svg+xml,${encodeURIComponent(\n    `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${mark.width} ${mark.height}\"><path d=\"${mark.path}\"/></svg>`\n  )}`;\n/** Mask cells per row: fine enough to read as grain over the crisp mark. */\nconst GRID = 40;\n\n/** Deterministic pseudo-random noise, stable for a given cell and tick. */\nconst hashNoise = (cell: number, tick: number) => {\n  const raw = Math.sin(cell * 127.1 + tick * 311.7) * 43_758.5453;\n  return raw - Math.floor(raw);\n};\n\n/** Smoothstep ease-in-out: slow start, fast middle, slow settle. */\nconst easeInOut = (x: number) => x * x * (3 - 2 * x);\n\n/** 0 → all grain, 1 → fully resolved mark. */\nconst resolveProgress = (t: number) => {\n  const { holdEnd, holdStart, resolveStart } = LOADER_TIMING;\n\n  if (t < resolveStart) {\n    return 0;\n  }\n  if (t < holdStart) {\n    return easeInOut((t - resolveStart) / (holdStart - resolveStart));\n  }\n  if (t < holdEnd) {\n    return 1;\n  }\n\n  return 1 - easeInOut((t - holdEnd) / (1 - holdEnd));\n};\n\nexport const NeonLoader = ({\n  className,\n  decorative = false,\n  duration = LOADER_TIMING.durationMs,\n  label = \"Loading\",\n  mark = NEON_MARK,\n  showLabel = false,\n  size = \"md\",\n  ...props\n}: NeonLoaderProps) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const resolvedSize = typeof size === \"number\" ? size : LOADER_SIZE[size];\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n\n    if (!canvas) {\n      return;\n    }\n\n    const context = canvas.getContext(\"2d\");\n\n    if (!context) {\n      return;\n    }\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const pixelSize = resolvedSize * dpr;\n    canvas.width = pixelSize;\n    canvas.height = pixelSize;\n\n    const cellSize = pixelSize / GRID;\n    const scale = pixelSize / Math.max(mark.width, mark.height);\n    const markShape = new Path2D(mark.path);\n\n    const styles = getComputedStyle(canvas);\n    const primary = styles.color;\n    const mono =\n      styles.getPropertyValue(\"--muted-foreground\").trim() || \"#9ca3af\";\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let frame = 0;\n\n    const drawResolvedMark = () => {\n      context.clearRect(0, 0, pixelSize, pixelSize);\n      context.save();\n      context.fillStyle = primary;\n      context.scale(scale, scale);\n      // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- canvas path fill, not Array#fill\n      context.fill(markShape, \"nonzero\");\n      context.restore();\n    };\n\n    const draw = (now: number) => {\n      // Skip canvas work while hidden — keep the loop alive, drop the cost.\n      if (!(canvas.checkVisibility?.() ?? true)) {\n        frame = requestAnimationFrame(draw);\n        return;\n      }\n\n      const t = (now % duration) / duration;\n      const progress = resolveProgress(t);\n      const tick = Math.floor((now / 1000) * LOADER_TIMING.noiseFps);\n\n      context.clearRect(0, 0, pixelSize, pixelSize);\n\n      // 1. Draw the crisp vector mark: monochrome while grainy, easing\n      // into the neon primary as it resolves. Never redrawn as blocks.\n      context.save();\n      context.scale(scale, scale);\n      context.fillStyle = mono;\n      // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- canvas path fill, not Array#fill\n      context.fill(markShape, \"nonzero\");\n\n      if (progress > 0) {\n        context.globalAlpha = progress ** 1.5;\n        context.fillStyle = primary;\n        // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- canvas path fill, not Array#fill\n        context.fill(markShape, \"nonzero\");\n        context.globalAlpha = 1;\n      }\n\n      context.restore();\n\n      // 2. Erode it with grain: cells whose noise exceeds the resolve\n      // progress are punched out, so low progress = mostly static.\n      context.globalCompositeOperation = \"destination-out\";\n\n      for (let cell = 0; cell < GRID * GRID; cell += 1) {\n        const noise = hashNoise(cell, tick);\n\n        if (noise < progress) {\n          continue;\n        }\n\n        const x = (cell % GRID) * cellSize;\n        const y = Math.floor(cell / GRID) * cellSize;\n\n        context.globalAlpha = 0.55 + noise * 0.45;\n        context.fillRect(x, y, cellSize + 0.5, cellSize + 0.5);\n      }\n\n      context.globalCompositeOperation = \"source-over\";\n      context.globalAlpha = 1;\n      frame = requestAnimationFrame(draw);\n    };\n\n    if (reduced.matches) {\n      drawResolvedMark();\n      return;\n    }\n\n    frame = requestAnimationFrame(draw);\n\n    return () => cancelAnimationFrame(frame);\n  }, [duration, resolvedSize, mark]);\n\n  return (\n    <div\n      aria-hidden={decorative || undefined}\n      aria-label={decorative ? undefined : label}\n      aria-live={decorative ? undefined : \"polite\"}\n      className={cn(\"inline-flex items-center gap-3\", className)}\n      data-slot=\"neon-loader\"\n      role={decorative ? undefined : \"status\"}\n      {...props}\n    >\n      <canvas\n        aria-hidden=\"true\"\n        className=\"shrink-0 text-primary\"\n        ref={canvasRef}\n        style={{ height: resolvedSize, width: resolvedSize }}\n      />\n      {showLabel ? (\n        <span className=\"font-mono text-[10px] text-muted-foreground\">\n          {label}\n        </span>\n      ) : (\n        <span className=\"sr-only\">{label}</span>\n      )}\n    </div>\n  );\n};\n\nexport type NeonMarkShimmerProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** Pixel height of the mark; width follows the mark's aspect ratio. */\n  size?: number;\n  /** Swap the Neon mark for your own logo path. */\n  mark?: LoaderMark;\n};\n\n/**\n * The Neon mark painted with the shadcn `shimmer` gradient. Mount it next to\n * a `shimmer` text element and both sweep on the same clock; tune both at\n * once with `shimmer-duration-*` / `shimmer-color-*` on a shared parent.\n */\nexport const NeonMarkShimmer = ({\n  className,\n  mark = NEON_MARK,\n  size = 16,\n  style,\n  ...props\n}: NeonMarkShimmerProps) => (\n  <span\n    aria-hidden=\"true\"\n    className={cn(\"neon-mark-shimmer shrink-0\", className)}\n    data-slot=\"neon-mark-shimmer\"\n    style={\n      {\n        \"--neon-mark-uri\": `url(\"${markUri(mark)}\")`,\n        height: size,\n        width: (size * mark.width) / mark.height,\n        ...style,\n      } as CSSProperties\n    }\n    {...props}\n  />\n);\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}