{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["effects","pixel-art"],"dependencies":["cn"],"description":"Dot-matrix pixel loading spinners — 20 grid animations (wave, spiral, radar, rain, snake, heart…) with square or round dots, neon glow and rainbow hue cycling.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useState } from \"react\";\n\nimport { SpinnerPixelGrid, spinnerDots, spinnerVariants } from \"./spinner-pixel-grid\";\nimport type { SpinnerDot } from \"./spinner-pixel-grid\";\n\nconst Preview = () => {\n  const [glow, setGlow] = useState(true);\n  const [rainbow, setRainbow] = useState(true);\n  const [dot, setDot] = useState<SpinnerDot>(\"square\");\n\n  return (\n    <div className=\"min-h-screen bg-black text-yellow-300\">\n      <div className=\"sticky top-0 z-10 flex flex-wrap items-center gap-4 border-b border-white/10 bg-black/80 p-4 backdrop-blur\">\n        <label className=\"flex items-center gap-2 text-sm text-gray-300\">\n          <input type=\"checkbox\" checked={glow} onChange={(e) => setGlow(e.target.checked)} />\n          Glow\n        </label>\n        <label className=\"flex items-center gap-2 text-sm text-gray-300\">\n          <input type=\"checkbox\" checked={rainbow} onChange={(e) => setRainbow(e.target.checked)} />\n          Rainbow\n        </label>\n        <div className=\"flex items-center gap-2 text-sm text-gray-300\">\n          Shape:\n          {spinnerDots.map((s) => (\n            <button\n              key={s}\n              type=\"button\"\n              aria-pressed={dot === s}\n              onClick={() => setDot(s)}\n              className={\n                dot === s\n                  ? \"rounded bg-white/15 px-2 py-1 capitalize\"\n                  : \"rounded px-2 py-1 capitalize text-gray-500 hover:text-gray-300\"\n              }\n            >\n              {s}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"grid grid-cols-2 pb-32 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5\">\n        {spinnerVariants.map((variant) => (\n          <div\n            key={variant}\n            className=\"flex flex-col items-center justify-center gap-4 border border-white/10 p-8\"\n          >\n            <SpinnerPixelGrid\n              variant={variant}\n              dot={dot}\n              glow={glow}\n              rainbow={rainbow}\n              size={10}\n              gridSize={5}\n            />\n            <span className=\"text-xs text-gray-500\">{variant}</span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/spinner-pixel-grid/preview.tsx","type":"registry:file"},{"content":"/* Spinner Pixel Grid keyframe animations */\n\n@keyframes pixel-scale {\n  0%,\n  100% {\n    opacity: 0.2;\n    transform: scale(0.5);\n  }\n  50% {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n@keyframes pixel-chase {\n  0%,\n  100% {\n    opacity: 0.1;\n  }\n  50% {\n    opacity: 1;\n  }\n}\n\n@keyframes pixel-rain {\n  0% {\n    opacity: 0;\n    transform: translateY(-100%);\n  }\n  50% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0;\n    transform: translateY(100%);\n  }\n}\n\n@keyframes pixel-crosshair {\n  0%,\n  100% {\n    opacity: 0.2;\n  }\n  50% {\n    opacity: 1;\n  }\n}\n\n@keyframes pixel-beat {\n  0%,\n  100% {\n    opacity: 0.3;\n    transform: scale(0.7);\n  }\n  15% {\n    opacity: 1;\n    transform: scale(1);\n  }\n  30% {\n    opacity: 0.5;\n    transform: scale(0.85);\n  }\n  45% {\n    opacity: 1;\n    transform: scale(1);\n  }\n  60% {\n    opacity: 0.3;\n    transform: scale(0.7);\n  }\n}\n\n@keyframes hue-rotate {\n  from {\n    filter: hue-rotate(0deg);\n  }\n  to {\n    filter: hue-rotate(360deg);\n  }\n}\n","path":"/spinner-pixel-grid.css","target":"uicapsule/spinner-pixel-grid/spinner-pixel-grid.css","type":"registry:file"},{"content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport type { ComponentProps, CSSProperties } from \"react\";\n\nimport \"./spinner-pixel-grid.css\";\nimport { cn } from \"cn\";\n\ntype CellValue = (x: number, y: number, gridSize: number) => number;\ntype CellMask = (x: number, y: number, gridSize: number) => boolean;\n\ninterface VariantConfig {\n  /** Keyframe animation applied to each visible dot. */\n  keyframe: string;\n  /** Base animation duration in seconds. */\n  duration: number;\n  /** Animation delay (seconds) for the dot at (x, y). */\n  delay: CellValue;\n  /** Which dots are part of the pattern. Defaults to all. */\n  mask?: CellMask;\n}\n\n// --- Shape masks (which dots make up a variant's pattern) ---\n\nconst edgeMask: CellMask = (x, y, g) => x === 0 || y === 0 || x === g - 1 || y === g - 1;\n\nconst starMask: CellMask = (x, y, g) => {\n  const c = Math.floor(g / 2);\n  const isCross = x === c || y === c;\n  const isDiagonal = Math.abs(x - c) === Math.abs(y - c);\n  const isCorner = (x === 0 || x === g - 1) && (y === 0 || y === g - 1);\n  return (isCross || isDiagonal) && !isCorner;\n};\n\nconst saltireMask: CellMask = (x, y, g) => {\n  const c = Math.floor(g / 2);\n  return Math.abs(x - c) === Math.abs(y - c);\n};\n\nconst crosshairMask: CellMask = (x, y, g) => {\n  const c = Math.floor(g / 2);\n  return x === c || y === c;\n};\n\n// Heart shape via the implicit heart curve, normalized to the grid.\nconst heartMask: CellMask = (x, y, g) => {\n  const c = (g - 1) / 2;\n  const nx = (x - c) / (g * 0.48);\n  const ny = (c - y) / (g * 0.34) + 0.05;\n  return (nx * nx + ny * ny - 1) ** 3 - nx * nx * ny ** 3 <= 0;\n};\n\nconst variants = [\n  \"default\",\n  \"wave\",\n  \"cascade\",\n  \"spiral\",\n  \"vortex\",\n  \"chase\",\n  \"frame\",\n  \"rain\",\n  \"scan\",\n  \"ripple\",\n  \"diamond\",\n  \"star\",\n  \"saltire\",\n  \"crosshair\",\n  \"corners\",\n  \"checker\",\n  \"snake\",\n  \"radar\",\n  \"pulse\",\n  \"heart\",\n] as const;\n\ntype SpinnerVariant = (typeof variants)[number];\n\n// Per-variant configuration. Delay functions only run for dots the mask keeps\n// visible, so they never need to guard against out-of-pattern positions.\nconst variantConfigs = {\n  // Vertical wave, top to bottom\n  cascade: { delay: (_x, y) => 0.12 * y, duration: 1, keyframe: \"pixel-scale\" },\n  // Chase around the perimeter\n  chase: {\n    delay: (x, y, g) => {\n      const last = g - 1;\n      if (last === 0) {\n        return 0;\n      }\n      let order = 0;\n      if (y === 0) {\n        order = x;\n      } else if (x === last) {\n        order = last + y;\n      } else if (y === last) {\n        order = 2 * last + (last - x);\n      } else {\n        order = 3 * last + (last - y);\n      }\n      return (order / (4 * last)) * 0.8;\n    },\n    duration: 0.8,\n    keyframe: \"pixel-chase\",\n    mask: edgeMask,\n  },\n  // Alternating checkerboard pulse\n  checker: { delay: (x, y) => ((x + y) % 2) * 0.5, duration: 1, keyframe: \"pixel-scale\" },\n  // Collapse inward from all four corners\n  corners: {\n    delay: (x, y, g) => {\n      const last = g - 1;\n      const toX = Math.min(x, last - x);\n      const toY = Math.min(y, last - y);\n      return Math.hypot(toX, toY) * 0.15;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n  },\n  // Center row and column animate outward from the center\n  crosshair: {\n    delay: (x, y, g) => {\n      const c = Math.floor(g / 2);\n      return (y === c ? Math.abs(x - c) : Math.abs(y - c)) * 0.1;\n    },\n    duration: 0.6,\n    keyframe: \"pixel-crosshair\",\n    mask: crosshairMask,\n  },\n  // Diagonal wave from the top-left corner\n  default: { delay: (x, y) => 0.05 * (x + y), duration: 1, keyframe: \"pixel-scale\" },\n  // Diamond-shaped rings (manhattan distance) from the center\n  diamond: {\n    delay: (x, y, g) => {\n      const c = (g - 1) / 2;\n      return (Math.abs(x - c) + Math.abs(y - c)) * 0.12;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n  },\n  // Whole border pulses together\n  frame: { delay: () => 0, duration: 1, keyframe: \"pixel-chase\", mask: edgeMask },\n  // Whole heart beats together\n  heart: { delay: () => 0, duration: 1.2, keyframe: \"pixel-beat\", mask: heartMask },\n  // Every dot breathes together\n  pulse: { delay: () => 0, duration: 1, keyframe: \"pixel-scale\" },\n  // Radar sweep around the center\n  radar: {\n    delay: (x, y, g) => {\n      const c = (g - 1) / 2;\n      return ((Math.atan2(y - c, x - c) + Math.PI) / (2 * Math.PI)) * 1.2;\n    },\n    duration: 1.2,\n    keyframe: \"pixel-chase\",\n  },\n  // Rain falling from the top, each column offset\n  rain: { delay: (x, y) => y * 0.1 + x * 0.05, duration: 0.8, keyframe: \"pixel-rain\" },\n  // Concentric rings radiating from the center\n  ripple: {\n    delay: (x, y, g) => {\n      const c = (g - 1) / 2;\n      return Math.hypot(x - c, y - c) * 0.18;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n  },\n  // Diagonal cross (X) radiating from the center\n  saltire: {\n    delay: (x, y, g) => {\n      const c = Math.floor(g / 2);\n      return Math.max(Math.abs(x - c), Math.abs(y - c)) * 0.12;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n    mask: saltireMask,\n  },\n  // Sharp scanline sweeping downward\n  scan: { delay: (_x, y) => y * 0.15, duration: 1.2, keyframe: \"pixel-chase\" },\n  // Snake (alternating direction per row)\n  snake: {\n    delay: (x, y, g) => {\n      const effectiveX = y % 2 === 0 ? x : g - 1 - x;\n      return (y * g + effectiveX) * 0.05;\n    },\n    duration: 1.5,\n    keyframe: \"pixel-scale\",\n  },\n  // Spiral radiating from the center\n  spiral: {\n    delay: (x, y, g) => {\n      const c = (g - 1) / 2;\n      const distance = Math.hypot(x - c, y - c);\n      const normalizedAngle = (Math.atan2(y - c, x - c) + Math.PI) / (2 * Math.PI);\n      return distance * 0.15 + normalizedAngle * 0.3;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n  },\n  // Cross + diagonals radiating from the center\n  star: {\n    delay: (x, y, g) => {\n      const c = Math.floor(g / 2);\n      return Math.max(Math.abs(x - c), Math.abs(y - c)) * 0.12;\n    },\n    duration: 1,\n    keyframe: \"pixel-scale\",\n    mask: starMask,\n  },\n  // Rotating arm that also pulses radially\n  vortex: {\n    delay: (x, y, g) => {\n      const c = (g - 1) / 2;\n      const distance = Math.hypot(x - c, y - c);\n      const normalizedAngle = (Math.atan2(y - c, x - c) + Math.PI) / (2 * Math.PI);\n      return normalizedAngle * 1.2 + distance * 0.1;\n    },\n    duration: 1.2,\n    keyframe: \"pixel-chase\",\n  },\n  // Horizontal wave\n  wave: { delay: (x) => 0.1 * x, duration: 1, keyframe: \"pixel-scale\" },\n} satisfies Record<SpinnerVariant, VariantConfig>;\n\nconst dots = [\"square\", \"circle\"] as const;\n\ntype SpinnerDot = (typeof dots)[number];\n\ntype SpinnerProps = ComponentProps<\"output\"> & {\n  /** Pixel size of each dot. */\n  size?: number;\n  /** Number of dots per row/column. */\n  gridSize?: number;\n  variant?: SpinnerVariant;\n  /** Geometry of each dot. */\n  dot?: SpinnerDot;\n  /** Dot color. Any CSS color; defaults to the inherited text color. */\n  color?: string;\n  /** Render the neon glow around each dot. */\n  glow?: boolean;\n  /** Animation speed multiplier. 2 = twice as fast, 0.5 = half speed. */\n  speed?: number;\n  /** Cycle the hue of every dot for a rainbow effect. */\n  rainbow?: boolean;\n  /** Accessible label announced by screen readers. */\n  ariaLabel?: string;\n};\n\n// React's CSSProperties has no index signature for custom properties, so widen it\n// rather than reaching for a type assertion.\ntype SpinnerStyle = CSSProperties & Record<`--${string}`, string>;\n\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nconst subscribeToReducedMotion = (onChange: () => void) => {\n  const query = window.matchMedia(REDUCED_MOTION_QUERY);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\n\nconst getReducedMotion = () => window.matchMedia(REDUCED_MOTION_QUERY).matches;\n\n// The server cannot know the preference. Reporting \"no preference\" keeps the\n// server and client markup identical, and React re-renders after hydration if\n// the real preference differs.\nconst getServerReducedMotion = () => false;\n\n// Respect the user's reduced-motion preference, kept in sync with the media query.\nconst usePrefersReducedMotion = (): boolean =>\n  useSyncExternalStore(subscribeToReducedMotion, getReducedMotion, getServerReducedMotion);\n\nexport const SpinnerPixelGrid = ({\n  className,\n  style,\n  size = 8,\n  gridSize = 3,\n  variant = \"default\",\n  dot = \"square\",\n  color = \"currentColor\",\n  glow = true,\n  speed = 1,\n  rainbow = false,\n  ariaLabel = \"Loading\",\n  ...props\n}: SpinnerProps) => {\n  const animate = !usePrefersReducedMotion();\n  const safeSpeed = speed > 0 ? speed : 1;\n  const range = Array.from({ length: gridSize }, (_, i) => i);\n\n  const config: VariantConfig = variantConfigs[variant];\n  const duration = config.duration / safeSpeed;\n  // Rainbow runs without a delay so every dot cycles hue in sync.\n  const rainbowAnimation = rainbow ? `, hue-rotate ${10 / safeSpeed}s linear infinite` : \"\";\n\n  // The custom properties come last so a caller-supplied `style` cannot drop the\n  // two variables every dot depends on.\n  const rootStyle: SpinnerStyle = {\n    ...style,\n    \"--spinner-color\": color,\n    \"--square-size\": `${size}px`,\n  };\n\n  return (\n    <output\n      aria-label={ariaLabel}\n      className={cn(\"inline-flex flex-col\", className)}\n      style={rootStyle}\n      {...props}\n    >\n      {range.map((y) => (\n        <div key={y} className=\"flex\">\n          {range.map((x) => (\n            <div\n              key={x}\n              className=\"relative\"\n              style={{ height: \"var(--square-size)\", width: \"var(--square-size)\" }}\n            >\n              {(config.mask?.(x, y, gridSize) ?? true) && (\n                <div\n                  className=\"absolute inset-0\"\n                  style={{\n                    animation: animate\n                      ? `${config.keyframe} ${duration}s linear ${config.delay(x, y, gridSize) / safeSpeed}s infinite${rainbowAnimation}`\n                      : undefined,\n                    backgroundColor: \"var(--spinner-color)\",\n                    borderRadius: dot === \"circle\" ? \"50%\" : undefined,\n                    boxShadow: glow\n                      ? \"0 0 10px var(--spinner-color), 0 0 20px var(--spinner-color), 0 0 40px var(--spinner-color)\"\n                      : undefined,\n                  }}\n                />\n              )}\n            </div>\n          ))}\n        </div>\n      ))}\n    </output>\n  );\n};\n\nexport { variants as spinnerVariants, dots as spinnerDots };\nexport type { SpinnerVariant, SpinnerDot };\n","path":"/spinner-pixel-grid.tsx","target":"uicapsule/spinner-pixel-grid/spinner-pixel-grid.tsx","type":"registry:file"},{"content":"declare module \"*.css\" {}\n","path":"/styles.d.ts","target":"uicapsule/spinner-pixel-grid/styles.d.ts","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/spinner-pixel-grid","name":"spinner-pixel-grid","registryDependencies":[],"title":"Spinner Pixel Grid","type":"registry:block"}