{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A DitherImage component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nexport type DitherAlgorithm =\n  | \"bayer\"\n  | \"atkinson\"\n  | \"floyd-steinberg\"\n  | \"threshold\";\n\nexport interface DitherImageProps {\n  /** Dithering kernel used to quantise the image. */\n  algorithm?: DitherAlgorithm;\n  /** Alternative text. Always required — it is the accessible name of the result. */\n  alt: string;\n  /** Extra classes for the wrapper. */\n  className?: string;\n  /** Rendered height in CSS pixels. */\n  height?: number;\n  /** Number of tones in the ramp, clamped to `2`–`8`. */\n  levels?: number;\n  /** Colour ramp, dark to light. Defaults to a monochrome ramp from the theme. */\n  palette?: string[];\n  /** Size of one dithered block in CSS pixels. */\n  pixelSize?: number;\n  /** Wipe the dithered pass in when the image scrolls into view. */\n  progressive?: boolean;\n  /** Image URL. Must allow cross-origin reads to be dithered. */\n  src: string;\n  /** Rendered width in CSS pixels. */\n  width?: number;\n}\n\ntype Rgb = [number, number, number];\ntype RenderStatus = \"fallback\" | \"loading\" | \"ready\";\n\nconst DEFAULT_WIDTH = 480;\nconst DEFAULT_HEIGHT = 320;\nconst DEFAULT_PIXEL_SIZE = 4;\nconst DEFAULT_LEVELS = 2;\nconst MIN_LEVELS = 2;\nconst MAX_LEVELS = 8;\nconst MIN_PIXEL_SIZE = 1;\nconst MAX_DPR = 2;\nconst CHANNELS = 4;\nconst MAX_CHANNEL = 255;\nconst BAYER_SIZE = 8;\nconst BAYER_DIVISOR = BAYER_SIZE * BAYER_SIZE;\nconst LUMA_R = 0.2126;\nconst LUMA_G = 0.7152;\nconst LUMA_B = 0.0722;\nconst REVEAL_DURATION_S = 0.6;\nconst REVEAL_EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst REVEAL_MARGIN = \"0px 0px -10% 0px\";\nconst HIDDEN_CLIP = \"inset(0% 0% 100% 0%)\";\nconst VISIBLE_CLIP = \"inset(0% 0% 0% 0%)\";\nconst FALLBACK_DARK = \"black\";\nconst FALLBACK_LIGHT = \"white\";\n\nconst BAYER_MATRIX = [\n  0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36,\n  14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41,\n  51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23,\n  61, 29, 53, 21,\n];\n\nconst FLOYD_STEINBERG_KERNEL = [\n  { dx: 1, dy: 0, weight: 7 / 16 },\n  { dx: -1, dy: 1, weight: 3 / 16 },\n  { dx: 0, dy: 1, weight: 5 / 16 },\n  { dx: 1, dy: 1, weight: 1 / 16 },\n];\n\nconst ATKINSON_WEIGHT = 1 / 8;\nconst ATKINSON_KERNEL = [\n  { dx: 1, dy: 0, weight: ATKINSON_WEIGHT },\n  { dx: 2, dy: 0, weight: ATKINSON_WEIGHT },\n  { dx: -1, dy: 1, weight: ATKINSON_WEIGHT },\n  { dx: 0, dy: 1, weight: ATKINSON_WEIGHT },\n  { dx: 1, dy: 1, weight: ATKINSON_WEIGHT },\n  { dx: 0, dy: 2, weight: ATKINSON_WEIGHT },\n];\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\n\nconst luminanceOf = ([r, g, b]: Rgb) =>\n  (r * LUMA_R + g * LUMA_G + b * LUMA_B) / MAX_CHANNEL;\n\nconst readCssColor = (\n  scratch: CanvasRenderingContext2D,\n  value: string\n): Rgb => {\n  scratch.clearRect(0, 0, 1, 1);\n  scratch.fillStyle = value;\n  scratch.fillRect(0, 0, 1, 1);\n  const { data } = scratch.getImageData(0, 0, 1, 1);\n  return [data[0], data[1], data[2]];\n};\n\nconst readThemeStops = (\n  scratch: CanvasRenderingContext2D,\n  element: HTMLElement\n): Rgb[] => {\n  const styles = getComputedStyle(element);\n  const foreground =\n    styles.getPropertyValue(\"--color-foreground\").trim() || FALLBACK_DARK;\n  const background =\n    styles.getPropertyValue(\"--color-background\").trim() || FALLBACK_LIGHT;\n  const first = readCssColor(scratch, foreground);\n  const second = readCssColor(scratch, background);\n  return luminanceOf(first) <= luminanceOf(second)\n    ? [first, second]\n    : [second, first];\n};\n\nconst buildRamp = (stops: Rgb[], levels: number): Rgb[] => {\n  if (stops.length === 1) {\n    return Array.from({ length: levels }, () => stops[0]);\n  }\n  const lastStop = stops.length - 1;\n  const lastLevel = levels - 1;\n  return Array.from({ length: levels }, (_unused, index) => {\n    const position = (index / lastLevel) * lastStop;\n    const lower = Math.floor(position);\n    const upper = Math.min(lastStop, lower + 1);\n    const mix = position - lower;\n    const from = stops[lower];\n    const to = stops[upper];\n    return [\n      Math.round(from[0] + (to[0] - from[0]) * mix),\n      Math.round(from[1] + (to[1] - from[1]) * mix),\n      Math.round(from[2] + (to[2] - from[2]) * mix),\n    ] as Rgb;\n  });\n};\n\nconst readLuminance = (data: Uint8ClampedArray) => {\n  const values = new Float32Array(data.length / CHANNELS);\n  for (let index = 0; index < values.length; index++) {\n    const offset = index * CHANNELS;\n    values[index] =\n      (data[offset] * LUMA_R +\n        data[offset + 1] * LUMA_G +\n        data[offset + 2] * LUMA_B) /\n      MAX_CHANNEL;\n  }\n  return values;\n};\n\nconst diffuse = (\n  values: Float32Array,\n  gridWidth: number,\n  gridHeight: number,\n  levels: number,\n  kernel: { dx: number; dy: number; weight: number }[]\n) => {\n  const steps = levels - 1;\n  const quantised = new Uint8Array(values.length);\n  for (let y = 0; y < gridHeight; y++) {\n    for (let x = 0; x < gridWidth; x++) {\n      const index = y * gridWidth + x;\n      const original = values[index];\n      const level = clamp(Math.round(original * steps), 0, steps);\n      quantised[index] = level;\n      const error = original - level / steps;\n      for (const tap of kernel) {\n        const nx = x + tap.dx;\n        const ny = y + tap.dy;\n        if (nx < 0 || nx >= gridWidth || ny >= gridHeight) {\n          continue;\n        }\n        values[ny * gridWidth + nx] += error * tap.weight;\n      }\n    }\n  }\n  return quantised;\n};\n\nconst quantiseOrdered = (\n  values: Float32Array,\n  gridWidth: number,\n  levels: number,\n  ordered: boolean\n) => {\n  const steps = levels - 1;\n  const quantised = new Uint8Array(values.length);\n  for (let index = 0; index < values.length; index++) {\n    let value = values[index];\n    if (ordered) {\n      const x = index % gridWidth;\n      const y = Math.floor(index / gridWidth);\n      const cell =\n        BAYER_MATRIX[(y % BAYER_SIZE) * BAYER_SIZE + (x % BAYER_SIZE)];\n      value += ((cell + 0.5) / BAYER_DIVISOR - 0.5) / steps;\n    }\n    quantised[index] = clamp(Math.round(value * steps), 0, steps);\n  }\n  return quantised;\n};\n\nconst applyRamp = (\n  data: Uint8ClampedArray,\n  quantised: Uint8Array,\n  ramp: Rgb[]\n) => {\n  for (let index = 0; index < quantised.length; index++) {\n    const offset = index * CHANNELS;\n    const tone = ramp[quantised[index]];\n    data[offset] = tone[0];\n    data[offset + 1] = tone[1];\n    data[offset + 2] = tone[2];\n  }\n};\n\nconst DitherImage = ({\n  algorithm = \"bayer\",\n  alt,\n  className,\n  height = DEFAULT_HEIGHT,\n  levels = DEFAULT_LEVELS,\n  palette,\n  pixelSize = DEFAULT_PIXEL_SIZE,\n  progressive = false,\n  src,\n  width = DEFAULT_WIDTH,\n}: DitherImageProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [status, setStatus] = useState<RenderStatus>(\"loading\");\n  const [isRevealed, setIsRevealed] = useState(false);\n\n  const paletteKey = palette?.join(\"|\") ?? \"\";\n  const resolvedPalette = useMemo(\n    () => (paletteKey ? paletteKey.split(\"|\") : undefined),\n    [paletteKey]\n  );\n\n  const safeLevels = clamp(Math.round(levels), MIN_LEVELS, MAX_LEVELS);\n  const safePixelSize = Math.max(MIN_PIXEL_SIZE, Math.round(pixelSize));\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!(canvas && container)) {\n      return;\n    }\n\n    const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n    const scratchCanvas = document.createElement(\"canvas\");\n    scratchCanvas.width = 1;\n    scratchCanvas.height = 1;\n    const scratch = scratchCanvas.getContext(\"2d\", {\n      willReadFrequently: true,\n    });\n    const grid = document.createElement(\"canvas\");\n    const gridContext = grid.getContext(\"2d\", { willReadFrequently: true });\n\n    if (!(context && scratch && gridContext)) {\n      setStatus(\"fallback\");\n      return;\n    }\n\n    let isCancelled = false;\n    const image = new Image();\n    image.crossOrigin = \"anonymous\";\n    image.decoding = \"async\";\n\n    const render = () => {\n      const gridWidth = Math.max(1, Math.round(width / safePixelSize));\n      const gridHeight = Math.max(1, Math.round(height / safePixelSize));\n      grid.width = gridWidth;\n      grid.height = gridHeight;\n      gridContext.clearRect(0, 0, gridWidth, gridHeight);\n      gridContext.drawImage(image, 0, 0, gridWidth, gridHeight);\n\n      const frame = gridContext.getImageData(0, 0, gridWidth, gridHeight);\n      const values = readLuminance(frame.data);\n      let quantised: Uint8Array;\n      if (algorithm === \"floyd-steinberg\") {\n        quantised = diffuse(\n          values,\n          gridWidth,\n          gridHeight,\n          safeLevels,\n          FLOYD_STEINBERG_KERNEL\n        );\n      } else if (algorithm === \"atkinson\") {\n        quantised = diffuse(\n          values,\n          gridWidth,\n          gridHeight,\n          safeLevels,\n          ATKINSON_KERNEL\n        );\n      } else {\n        quantised = quantiseOrdered(\n          values,\n          gridWidth,\n          safeLevels,\n          algorithm === \"bayer\"\n        );\n      }\n\n      const stops = resolvedPalette\n        ? resolvedPalette.map((entry) => readCssColor(scratch, entry))\n        : readThemeStops(scratch, container);\n      applyRamp(frame.data, quantised, buildRamp(stops, safeLevels));\n      gridContext.putImageData(frame, 0, 0);\n\n      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      context.imageSmoothingEnabled = false;\n      context.clearRect(0, 0, canvas.width, canvas.height);\n      context.drawImage(grid, 0, 0, canvas.width, canvas.height);\n      setStatus(\"ready\");\n    };\n\n    image.onload = () => {\n      if (isCancelled) {\n        return;\n      }\n      try {\n        render();\n      } catch {\n        setStatus(\"fallback\");\n      }\n    };\n    image.onerror = () => {\n      if (!isCancelled) {\n        setStatus(\"fallback\");\n      }\n    };\n    image.src = src;\n\n    return () => {\n      isCancelled = true;\n      image.onload = null;\n      image.onerror = null;\n      image.src = \"\";\n      context.clearRect(0, 0, canvas.width, canvas.height);\n      canvas.width = 0;\n      canvas.height = 0;\n      grid.width = 0;\n      grid.height = 0;\n      scratchCanvas.width = 0;\n      scratchCanvas.height = 0;\n    };\n  }, [\n    algorithm,\n    height,\n    resolvedPalette,\n    safeLevels,\n    safePixelSize,\n    src,\n    width,\n  ]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!progressive || shouldReduceMotion) {\n      setIsRevealed(true);\n      return;\n    }\n    if (!(container && typeof IntersectionObserver !== \"undefined\")) {\n      setIsRevealed(true);\n      return;\n    }\n    setIsRevealed(false);\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            setIsRevealed(true);\n            observer.disconnect();\n          }\n        }\n      },\n      { rootMargin: REVEAL_MARGIN }\n    );\n    observer.observe(container);\n    return () => {\n      observer.disconnect();\n    };\n  }, [progressive, shouldReduceMotion]);\n\n  const isDithered = status === \"ready\";\n  const animatesReveal = progressive && !shouldReduceMotion;\n\n  return (\n    <div\n      className={cn(\n        \"relative isolate overflow-hidden rounded-xl bg-background\",\n        className\n      )}\n      ref={containerRef}\n      style={{ height, width }}\n    >\n      <img\n        alt={alt}\n        className=\"absolute inset-0 h-full w-full object-cover\"\n        height={height}\n        src={src}\n        width={width}\n      />\n      <motion.canvas\n        animate={{\n          clipPath: animatesReveal && !isRevealed ? HIDDEN_CLIP : VISIBLE_CLIP,\n          opacity: isDithered ? 1 : 0,\n        }}\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 h-full w-full\"\n        initial={false}\n        ref={canvasRef}\n        style={{ imageRendering: \"pixelated\" }}\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : { duration: REVEAL_DURATION_S, ease: REVEAL_EASE }\n        }\n      />\n    </div>\n  );\n};\n\nexport default DitherImage;\n","path":"index.tsx","target":"components/smoothui/dither-image/index.tsx","type":"registry:ui"}],"name":"dither-image","registryDependencies":[],"title":"Dither Image","type":"registry:ui"}