{
  "name": "image-generation-loader",
  "type": "registry:ui",
  "files": [
    {
      "path": "components/ui/image-generation-loader.tsx",
      "content": "\"use client\";\n\nimport React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ImageGenerationLoaderEffect =\n  | \"shimmer\"\n  | \"wave\"\n  | \"scale-wave\"\n  | \"none\";\n\nexport type ImageGenerationLoaderColors = readonly [from: string, to: string];\n\ntype EasingPreset = \"linear\" | \"ease-in\" | \"ease-out\" | \"ease-in-out\";\n\nexport type ImageGenerationLoaderEasing =\n  | EasingPreset\n  | (string & {})\n  | readonly [x1: number, y1: number, x2: number, y2: number]\n  | ((progress: number) => number);\n\nexport type ImageGenerationLoaderProps = {\n  className?: string;\n  cellSize?: number;\n  gap?: number;\n  bandHeight?: number;\n  duration?: number;\n  effect?: ImageGenerationLoaderEffect;\n  waveAmplitude?: number;\n  scaleAmplitude?: number;\n  colors?: ImageGenerationLoaderColors;\n  text?: string;\n  textLetterSpacing?: number;\n  textWordSpacing?: number;\n  overlayOpacity?: number;\n  overlayExitDuration?: number;\n  easing?: ImageGenerationLoaderEasing;\n};\n\ntype Cell = {\n  column: number;\n  row: number;\n  opacity: number;\n  phase: number;\n  scaleSeed: number;\n  isText: boolean;\n};\n\nconst DEFAULT_COLORS = [\n  \"var(--color-blue-300)\",\n  \"var(--color-blue-600)\",\n] as const;\nconst TAU = Math.PI * 2;\nconst GLYPH_WIDTH = 5;\nconst GLYPH_HEIGHT = 7;\nconst PIXEL_FONT: Record<string, string> = {\n  A: \"01110/10001/10001/11111/10001/10001/10001\",\n  B: \"11110/10001/10001/11110/10001/10001/11110\",\n  C: \"01111/10000/10000/10000/10000/10000/01111\",\n  D: \"11110/10001/10001/10001/10001/10001/11110\",\n  E: \"11111/10000/10000/11110/10000/10000/11111\",\n  F: \"11111/10000/10000/11110/10000/10000/10000\",\n  G: \"01111/10000/10000/10111/10001/10001/01111\",\n  H: \"10001/10001/10001/11111/10001/10001/10001\",\n  I: \"11111/00100/00100/00100/00100/00100/11111\",\n  J: \"00111/00010/00010/00010/10010/10010/01100\",\n  K: \"10001/10010/10100/11000/10100/10010/10001\",\n  L: \"10000/10000/10000/10000/10000/10000/11111\",\n  M: \"10001/11011/10101/10101/10001/10001/10001\",\n  N: \"10001/11001/10101/10011/10001/10001/10001\",\n  O: \"01110/10001/10001/10001/10001/10001/01110\",\n  P: \"11110/10001/10001/11110/10000/10000/10000\",\n  Q: \"01110/10001/10001/10001/10101/10010/01101\",\n  R: \"11110/10001/10001/11110/10100/10010/10001\",\n  S: \"01111/10000/10000/01110/00001/00001/11110\",\n  T: \"11111/00100/00100/00100/00100/00100/00100\",\n  U: \"10001/10001/10001/10001/10001/10001/01110\",\n  V: \"10001/10001/10001/10001/10001/01010/00100\",\n  W: \"10001/10001/10001/10101/10101/10101/01010\",\n  X: \"10001/10001/01010/00100/01010/10001/10001\",\n  Y: \"10001/10001/01010/00100/00100/00100/00100\",\n  Z: \"11111/00001/00010/00100/01000/10000/11111\",\n  \"0\": \"01110/10001/10011/10101/11001/10001/01110\",\n  \"1\": \"00100/01100/00100/00100/00100/00100/01110\",\n  \"2\": \"01110/10001/00001/00010/00100/01000/11111\",\n  \"3\": \"11110/00001/00001/01110/00001/00001/11110\",\n  \"4\": \"00010/00110/01010/10010/11111/00010/00010\",\n  \"5\": \"11111/10000/10000/11110/00001/00001/11110\",\n  \"6\": \"01110/10000/10000/11110/10001/10001/01110\",\n  \"7\": \"11111/00001/00010/00100/01000/01000/01000\",\n  \"8\": \"01110/10001/10001/01110/10001/10001/01110\",\n  \"9\": \"01110/10001/10001/01111/00001/00001/01110\",\n  \"-\": \"00000/00000/00000/11111/00000/00000/00000\",\n  \".\": \"00000/00000/00000/00000/00000/01100/01100\",\n  \":\": \"00000/01100/01100/00000/01100/01100/00000\",\n  \"?\": \"01110/10001/00001/00010/00100/00000/00100\",\n};\n\nconst randomFrom = (x: number, y: number, salt: number) => {\n  const value = Math.sin(x * 12.9898 + y * 78.233 + salt * 37.719);\n  return value * 43758.5453 - Math.floor(value * 43758.5453);\n};\n\nconst EASING_CURVES: Record<\n  EasingPreset,\n  readonly [number, number, number, number]\n> = {\n  linear: [0, 0, 1, 1],\n  \"ease-in\": [0.42, 0, 1, 1],\n  \"ease-out\": [0, 0, 0.58, 1],\n  \"ease-in-out\": [0.42, 0, 0.58, 1],\n};\n\nconst cubicCoordinate = (time: number, point1: number, point2: number) => {\n  const inverseTime = 1 - time;\n  return (\n    3 * inverseTime * inverseTime * time * point1 +\n    3 * inverseTime * time * time * point2 +\n    time * time * time\n  );\n};\n\nconst cubicDerivative = (time: number, point1: number, point2: number) => {\n  const inverseTime = 1 - time;\n  return (\n    3 * inverseTime * inverseTime * point1 +\n    6 * inverseTime * time * (point2 - point1) +\n    3 * time * time * (1 - point2)\n  );\n};\n\nconst createCubicBezier = (x1: number, y1: number, x2: number, y2: number) => {\n  const safeX1 = Math.min(1, Math.max(0, x1));\n  const safeX2 = Math.min(1, Math.max(0, x2));\n\n  return (progress: number) => {\n    let time = progress;\n\n    for (let iteration = 0; iteration < 6; iteration++) {\n      const difference = cubicCoordinate(time, safeX1, safeX2) - progress;\n      const derivative = cubicDerivative(time, safeX1, safeX2);\n      if (Math.abs(derivative) < 0.0001) break;\n      time = Math.min(1, Math.max(0, time - difference / derivative));\n    }\n\n    return cubicCoordinate(time, y1, y2);\n  };\n};\n\nconst resolveEasing = (easing: ImageGenerationLoaderEasing) => {\n  if (typeof easing === \"function\") return easing;\n  if (typeof easing !== \"string\") {\n    return createCubicBezier(easing[0], easing[1], easing[2], easing[3]);\n  }\n\n  const preset = EASING_CURVES[easing as EasingPreset];\n  if (preset) return createCubicBezier(...preset);\n\n  const match = easing.match(\n    /^cubic-bezier\\(\\s*(-?\\d*\\.?\\d+)\\s*,\\s*(-?\\d*\\.?\\d+)\\s*,\\s*(-?\\d*\\.?\\d+)\\s*,\\s*(-?\\d*\\.?\\d+)\\s*\\)$/,\n  );\n\n  if (!match) return (progress: number) => progress;\n\n  return createCubicBezier(\n    Number(match[1]),\n    Number(match[2]),\n    Number(match[3]),\n    Number(match[4]),\n  );\n};\n\nexport function ImageGenerationLoader({\n  className,\n  cellSize = 5,\n  gap = 2,\n  bandHeight = 48,\n  duration = 4000,\n  effect = \"shimmer\",\n  waveAmplitude = 12,\n  scaleAmplitude = 0.75,\n  colors = DEFAULT_COLORS,\n  text,\n  textLetterSpacing,\n  textWordSpacing,\n  overlayOpacity = 0.3,\n  overlayExitDuration = 700,\n  easing = \"ease-in-out\",\n}: ImageGenerationLoaderProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const [fromColor, toColor] = colors;\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const overlay = overlayRef.current;\n    const parent = canvas?.parentElement;\n\n    if (!canvas || !overlay || !parent) return;\n\n    const context = canvas.getContext(\"2d\", { alpha: true });\n    if (!context) return;\n\n    const colorProbe = document.createElement(\"span\");\n    colorProbe.hidden = true;\n    parent.appendChild(colorProbe);\n\n    const colorCanvas = document.createElement(\"canvas\");\n    colorCanvas.width = 1;\n    colorCanvas.height = 1;\n    const colorContext = colorCanvas.getContext(\"2d\", {\n      willReadFrequently: true,\n    });\n\n    const decodeColor = (color: string) => {\n      colorProbe.style.color = color;\n      const resolvedColor = window.getComputedStyle(colorProbe).color;\n      colorContext?.clearRect(0, 0, 1, 1);\n      if (colorContext) {\n        colorContext.fillStyle = resolvedColor;\n        colorContext.fillRect(0, 0, 1, 1);\n      }\n      const pixel = colorContext?.getImageData(0, 0, 1, 1).data;\n      return [pixel?.[0] ?? 0, pixel?.[1] ?? 0, pixel?.[2] ?? 0] as const;\n    };\n\n    const fromRgb = decodeColor(fromColor);\n    const toRgb = decodeColor(toColor);\n    colorProbe.remove();\n\n    const rgba = (color: readonly [number, number, number], alpha: number) =>\n      `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`;\n\n    let width = 0;\n    let height = 0;\n    let cells: Cell[] = [];\n    let frameId = 0;\n    let startTime = performance.now();\n    let isVisible = true;\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const pitch = Math.max(2, cellSize + gap);\n    const easingFunction = resolveEasing(easing);\n\n    const buildCells = () => {\n      const columnCount = Math.ceil(width / pitch) + 1;\n      const rowCount = Math.max(1, Math.floor((bandHeight + gap) / pitch));\n      const nextCells: Cell[] = [];\n      const textCells = new Set<string>();\n\n      if (text && rowCount >= GLYPH_HEIGHT) {\n        const characters = Array.from(text.trim().toUpperCase());\n        const letterGapColumns =\n          textLetterSpacing === undefined\n            ? 1\n            : Math.max(0, Math.round(textLetterSpacing / pitch));\n        const wordGapColumns =\n          textWordSpacing === undefined\n            ? 3\n            : Math.max(1, Math.round(textWordSpacing / pitch));\n        const characterWidths = characters.map((character) =>\n          character === \" \" ? wordGapColumns : GLYPH_WIDTH,\n        );\n        const textWidth =\n          characterWidths.reduce(\n            (total, characterWidth) => total + characterWidth,\n            0,\n          ) +\n          Math.max(0, characters.length - 1) * letterGapColumns;\n        const visibleColumnCount = Math.max(\n          1,\n          Math.floor((width + gap) / pitch),\n        );\n        const startColumn = Math.max(\n          0,\n          Math.floor((visibleColumnCount - textWidth) / 2),\n        );\n        const startRow = Math.floor((rowCount - GLYPH_HEIGHT) / 2);\n        let glyphColumn = startColumn;\n\n        characters.forEach((character, characterIndex) => {\n          if (character === \" \") {\n            glyphColumn += wordGapColumns;\n          } else {\n            const glyph = (PIXEL_FONT[character] ?? PIXEL_FONT[\"?\"]).split(\"/\");\n\n            for (let glyphRow = 0; glyphRow < GLYPH_HEIGHT; glyphRow++) {\n              for (let glyphPixel = 0; glyphPixel < GLYPH_WIDTH; glyphPixel++) {\n                if (glyph[glyphRow]?.[glyphPixel] === \"1\") {\n                  textCells.add(\n                    `${glyphColumn + glyphPixel}:${startRow + glyphRow}`,\n                  );\n                }\n              }\n            }\n\n            glyphColumn += GLYPH_WIDTH;\n          }\n\n          if (characterIndex < characters.length - 1) {\n            glyphColumn += letterGapColumns;\n          }\n        });\n      }\n\n      for (let row = 0; row < rowCount; row++) {\n        for (let column = 0; column < columnCount; column++) {\n          nextCells.push({\n            column,\n            row,\n            opacity: 0.45 + randomFrom(column, row, 4) * 0.55,\n            phase: randomFrom(column, row, 5) * TAU,\n            scaleSeed: randomFrom(column, row, 6),\n            isText: textCells.has(`${column}:${row}`),\n          });\n        }\n      }\n\n      cells = nextCells;\n    };\n\n    const resize = () => {\n      const bounds = parent.getBoundingClientRect();\n      width = Math.max(1, bounds.width);\n      height = Math.max(1, bounds.height);\n\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      context.setTransform(dpr, 0, 0, dpr, 0, 0);\n      buildCells();\n    };\n\n    const draw = (now: number) => {\n      context.clearRect(0, 0, width, height);\n\n      const cycleDuration = Math.max(1, duration);\n      const cycleElapsed = (now - startTime) % cycleDuration;\n      const linearProgress = cycleElapsed / cycleDuration;\n      const progress = reducedMotion.matches\n        ? 0.5\n        : easingFunction(linearProgress);\n      const centerY = -bandHeight + progress * (height + bandHeight * 2);\n      const bandTop = Math.round(centerY - bandHeight / 2);\n      const bandCenter = bandTop + bandHeight / 2;\n      const revealEdge = Math.max(0, Math.min(height, bandTop));\n      const exitWindow = Math.min(\n        Math.max(1, overlayExitDuration),\n        cycleDuration / 2,\n      );\n      const exitAmount = reducedMotion.matches\n        ? 1\n        : Math.min(1, (cycleDuration - cycleElapsed) / exitWindow);\n      const easedExit = exitAmount * exitAmount * (3 - 2 * exitAmount);\n      overlay.style.clipPath = `inset(0 0 ${height - revealEdge}px 0)`;\n      overlay.style.opacity = `${overlayOpacity * easedExit}`;\n      const rowCount = Math.max(1, Math.floor((bandHeight + gap) / pitch));\n      const gridHeight = rowCount * cellSize + (rowCount - 1) * gap;\n      const gridTop = bandTop + Math.round((bandHeight - gridHeight) / 2);\n      const waveProgress = reducedMotion.matches\n        ? 0.5\n        : ((now - startTime) % 1800) / 1800;\n      const waveX = -100 + waveProgress * (width + 200);\n      const getBulge = (x: number) => {\n        if (effect !== \"wave\" || reducedMotion.matches) return 0;\n        const distance = x - waveX;\n        return waveAmplitude * Math.exp(-(distance * distance) / (2 * 72 * 72));\n      };\n\n      const backgroundGradient = context.createLinearGradient(\n        0,\n        bandTop - waveAmplitude,\n        0,\n        bandTop + bandHeight + waveAmplitude,\n      );\n      backgroundGradient.addColorStop(0, rgba(toRgb, 0.03));\n      backgroundGradient.addColorStop(0.18, rgba(toRgb, 0.12));\n      backgroundGradient.addColorStop(0.5, rgba(fromRgb, 0.2));\n      backgroundGradient.addColorStop(0.82, rgba(toRgb, 0.12));\n      backgroundGradient.addColorStop(1, rgba(toRgb, 0.03));\n      context.fillStyle = backgroundGradient;\n\n      if (effect === \"wave\" && !reducedMotion.matches) {\n        context.beginPath();\n        context.moveTo(0, bandTop - getBulge(0));\n        for (let x = 4; x <= width; x += 4) {\n          context.lineTo(x, bandTop - getBulge(x));\n        }\n        for (let x = width; x >= 0; x -= 4) {\n          context.lineTo(x, bandTop + bandHeight + getBulge(x));\n        }\n        context.closePath();\n        context.fill();\n      } else {\n        context.fillRect(0, bandTop, width, bandHeight);\n      }\n\n      if (effect === \"shimmer\") {\n        const shineProgress = reducedMotion.matches\n          ? 0.5\n          : ((now - startTime) % 1400) / 1400;\n        const shineX = -120 + shineProgress * (width + 240);\n        const shineGradient = context.createLinearGradient(\n          shineX - 120,\n          0,\n          shineX + 120,\n          0,\n        );\n        shineGradient.addColorStop(0, rgba(fromRgb, 0));\n        shineGradient.addColorStop(0.5, rgba(fromRgb, 0.18));\n        shineGradient.addColorStop(1, rgba(fromRgb, 0));\n        context.fillStyle = shineGradient;\n        context.fillRect(0, bandTop, width, bandHeight);\n      }\n\n      for (const cell of cells) {\n        const x = cell.column * pitch;\n        const baseY = gridTop + cell.row * pitch;\n        const distanceFromCenter = baseY + cellSize / 2 - bandCenter;\n        const stretch = 1 + getBulge(x + cellSize / 2) / (bandHeight / 2);\n        const y = bandCenter + distanceFromCenter * stretch - cellSize / 2;\n        if (y + cellSize < 0 || y > height) continue;\n\n        const animatedColorMix = (Math.sin(now * 0.0022 + cell.phase) + 1) / 2;\n        const colorMix = text\n          ? cell.isText\n            ? 0.82 + animatedColorMix * 0.18\n            : animatedColorMix * 0.35\n          : animatedColorMix;\n        const red = Math.round(fromRgb[0] + (toRgb[0] - fromRgb[0]) * colorMix);\n        const green = Math.round(\n          fromRgb[1] + (toRgb[1] - fromRgb[1]) * colorMix,\n        );\n        const blue = Math.round(\n          fromRgb[2] + (toRgb[2] - fromRgb[2]) * colorMix,\n        );\n        const pulse =\n          0.16 + ((Math.sin(now * 0.0045 + cell.phase) + 1) / 2) * 0.84;\n        const cellCenterX = x + cellSize / 2;\n        let renderedSize = cellSize;\n\n        if (\n          effect === \"scale-wave\" &&\n          !reducedMotion.matches &&\n          cell.scaleSeed > 0.42\n        ) {\n          const rowStagger = cell.row * 9;\n          const distanceFromScaleWave = cellCenterX - (waveX - rowStagger);\n          const scaleWaveInfluence = Math.exp(\n            -(distanceFromScaleWave * distanceFromScaleWave) / (2 * 52 * 52),\n          );\n          const randomStrength = (cell.scaleSeed - 0.42) / 0.58;\n          renderedSize =\n            cellSize *\n            (1 +\n              scaleAmplitude *\n                scaleWaveInfluence *\n                (0.35 + randomStrength * 0.65));\n        }\n\n        const cellCenterY = y + cellSize / 2;\n\n        if (text && cell.isText) {\n          context.globalAlpha = 0.76 + pulse * 0.24;\n          context.fillStyle = `rgb(${fromRgb[0]} ${fromRgb[1]} ${fromRgb[2]})`;\n          context.fillRect(\n            cellCenterX - renderedSize / 2,\n            cellCenterY - renderedSize / 2,\n            renderedSize,\n            renderedSize,\n          );\n\n          const innerSize = Math.max(1, renderedSize * 0.55);\n          context.globalAlpha = 0.84 + pulse * 0.16;\n          context.fillStyle = `rgb(${toRgb[0]} ${toRgb[1]} ${toRgb[2]})`;\n          context.fillRect(\n            cellCenterX - innerSize / 2,\n            cellCenterY - innerSize / 2,\n            innerSize,\n            innerSize,\n          );\n        } else {\n          context.globalAlpha = text\n            ? pulse * (0.32 + cell.opacity * 0.3)\n            : pulse * (0.7 + cell.opacity * 0.3);\n          context.fillStyle = `rgb(${red} ${green} ${blue})`;\n          context.fillRect(\n            cellCenterX - renderedSize / 2,\n            cellCenterY - renderedSize / 2,\n            renderedSize,\n            renderedSize,\n          );\n        }\n      }\n\n      context.globalAlpha = 1;\n\n      if (!reducedMotion.matches && isVisible) {\n        frameId = requestAnimationFrame(draw);\n      }\n    };\n\n    const start = () => {\n      cancelAnimationFrame(frameId);\n      startTime = performance.now();\n      frameId = requestAnimationFrame(draw);\n    };\n\n    const resizeObserver = new ResizeObserver(() => {\n      resize();\n      if (reducedMotion.matches) draw(performance.now());\n    });\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      isVisible = entry.isIntersecting;\n      if (isVisible) start();\n      else cancelAnimationFrame(frameId);\n    });\n    const handleMotionPreference = () => start();\n\n    resize();\n    resizeObserver.observe(parent);\n    intersectionObserver.observe(canvas);\n    reducedMotion.addEventListener(\"change\", handleMotionPreference);\n    start();\n\n    return () => {\n      cancelAnimationFrame(frameId);\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      reducedMotion.removeEventListener(\"change\", handleMotionPreference);\n    };\n  }, [\n    bandHeight,\n    cellSize,\n    duration,\n    easing,\n    effect,\n    fromColor,\n    gap,\n    overlayExitDuration,\n    overlayOpacity,\n    scaleAmplitude,\n    text,\n    textLetterSpacing,\n    textWordSpacing,\n    toColor,\n    waveAmplitude,\n  ]);\n\n  return (\n    <>\n      <div\n        ref={overlayRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 bg-(--loader-overlay-color) will-change-[clip-path,opacity]\"\n        style={\n          {\n            \"--loader-overlay-color\": toColor,\n            clipPath: \"inset(0 0 100% 0)\",\n            opacity: overlayOpacity,\n          } as React.CSSProperties\n        }\n      />\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute inset-0 size-full\",\n          className,\n        )}\n      />\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/image-generation-loader.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Image Generation Loader"
}