{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dithering-hover",
  "title": "Dithering Hover",
  "description": "Any image turns into a black-and-white Bayer dither under the cursor. The dithered zone follows the pointer with a ragged rim and radial streaks that break up and fade, rendered live on a canvas. Controls for zone size, dot size, matrix, inks, roughness and an inverted mode. Exports drawDither for static renders.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/dithering-hover/dithering-hover.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type DitherMatrix = 2 | 4 | 8\n\nexport interface DitherOptions {\n  /** Size of one dither pixel in CSS pixels, 1 to 16. Default 4. */\n  dotSize?: number\n  /** Bayer matrix order. 8 gives the smoothest tones, 2 the harshest. Default 8. */\n  matrix?: DitherMatrix\n  /** Ink for dark pixels. Any CSS colour, including `currentColor`. Default \"#000\". */\n  dark?: string\n  /** Ink for light pixels. Any CSS colour, or \"transparent\" to let the layer below show. Default \"#fff\". */\n  light?: string\n  /** Tone curve steepness, roughly 0.5 to 2. Default 1. */\n  contrast?: number\n  /** Shift the tones lighter (positive) or darker (negative), -1 to 1. Default 0. */\n  brightness?: number\n  /** How the source fills the canvas. Default \"cover\". */\n  fit?: \"cover\" | \"contain\"\n}\n\ntype Source = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap\n\nconst BAYER_2 = [0, 2, 3, 1]\nconst BAYER_4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]\nconst BAYER_8 = [\n  0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54,\n  22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29,\n  53, 21,\n]\n\nfunction bayer(order: DitherMatrix) {\n  const m = order === 2 ? BAYER_2 : order === 4 ? BAYER_4 : BAYER_8\n  const n = order * order\n  return { m: m.map((v) => (v + 0.5) / n), size: order }\n}\n\nfunction sourceSize(image: Source): [number, number] {\n  if (\"naturalWidth\" in image && image.naturalWidth) return [image.naturalWidth, image.naturalHeight]\n  if (\"videoWidth\" in image && image.videoWidth) return [image.videoWidth, image.videoHeight]\n  return [image.width, image.height]\n}\n\nfunction parseColor(color: string, el?: Element | null): [number, number, number, number] {\n  if (color === \"transparent\") return [0, 0, 0, 0]\n  if (typeof document === \"undefined\") return [0, 0, 0, 255]\n  const probe = document.createElement(\"canvas\")\n  probe.width = probe.height = 1\n  const ctx = probe.getContext(\"2d\")\n  if (!ctx) return [0, 0, 0, 255]\n  let value = color\n  if (color === \"currentColor\") value = el ? getComputedStyle(el).color : \"#000\"\n  ctx.fillStyle = value\n  ctx.fillRect(0, 0, 1, 1)\n  const d = ctx.getImageData(0, 0, 1, 1).data\n  return [d[0], d[1], d[2], d[3]]\n}\n\nfunction drawCover(ctx: CanvasRenderingContext2D, image: Source, w: number, h: number, fit: \"cover\" | \"contain\") {\n  const [iw, ih] = sourceSize(image)\n  if (!iw || !ih) return false\n  const scale = fit === \"cover\" ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih)\n  const dw = iw * scale\n  const dh = ih * scale\n  ctx.drawImage(image, (w - dw) / 2, (h - dh) / 2, dw, dh)\n  return true\n}\n\n/**\n * Ordered (Bayer) dither of `image` at one sample per dither pixel.\n * Returns a bitmap of 0 (dark) / 1 (light) and its size in dither pixels.\n */\nexport function ditherBitmap(image: Source, width: number, height: number, options: DitherOptions = {}) {\n  const { dotSize = 4, matrix = 8, contrast = 1, brightness = 0, fit = \"cover\" } = options\n  const cell = Math.max(1, dotSize)\n  const bw = Math.max(1, Math.ceil(width / cell))\n  const bh = Math.max(1, Math.ceil(height / cell))\n  const off = document.createElement(\"canvas\")\n  off.width = bw\n  off.height = bh\n  const octx = off.getContext(\"2d\", { willReadFrequently: true })\n  const bits = new Uint8Array(bw * bh)\n  if (!octx || !drawCover(octx, image, bw, bh, fit)) return { bits, bw, bh, cell }\n  const data = octx.getImageData(0, 0, bw, bh).data\n  const { m, size } = bayer(matrix)\n  for (let y = 0; y < bh; y++) {\n    for (let x = 0; x < bw; x++) {\n      const i = (y * bw + x) * 4\n      const a = data[i + 3] / 255\n      let lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255\n      lum = lum * a + (1 - a) // transparent reads as paper\n      lum = (lum - 0.5) * contrast + 0.5 + brightness\n      bits[y * bw + x] = lum > m[(y % size) * size + (x % size)] ? 1 : 0\n    }\n  }\n  return { bits, bw, bh, cell }\n}\n\n/**\n * Draws `image` onto `canvas` as a full-frame Bayer dither at `width` x `height` CSS pixels.\n * Pure and framework-free, so it also works for exporting a PNG with canvas.toBlob().\n */\nexport function drawDither(\n  canvas: HTMLCanvasElement,\n  image: Source,\n  width: number,\n  height: number,\n  options: DitherOptions & { colorElement?: Element | null } = {}\n) {\n  const { dark = \"#000\", light = \"#fff\", colorElement } = options\n  const { bits, bw, bh, cell } = ditherBitmap(image, width, height, options)\n  const dpr = typeof window !== \"undefined\" ? Math.min(window.devicePixelRatio || 1, 2) : 1\n  canvas.width = Math.max(1, Math.round(width * dpr))\n  canvas.height = Math.max(1, Math.round(height * dpr))\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) return\n  const block = document.createElement(\"canvas\")\n  block.width = bw\n  block.height = bh\n  const bctx = block.getContext(\"2d\")\n  if (!bctx) return\n  const img = bctx.createImageData(bw, bh)\n  const d = parseColor(dark, colorElement)\n  const l = parseColor(light, colorElement)\n  for (let i = 0; i < bits.length; i++) {\n    const c = bits[i] ? l : d\n    img.data[i * 4] = c[0]\n    img.data[i * 4 + 1] = c[1]\n    img.data[i * 4 + 2] = c[2]\n    img.data[i * 4 + 3] = c[3]\n  }\n  bctx.putImageData(img, 0, 0)\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n  ctx.clearRect(0, 0, width, height)\n  ctx.imageSmoothingEnabled = false\n  ctx.drawImage(block, 0, 0, bw * cell, bh * cell)\n}\n\n/** Deterministic hash of integer coordinates to [0, 1). */\nfunction hash(x: number, y: number, seed: number) {\n  let h = (x * 374761393 + y * 668265263 + seed * 2246822519) | 0\n  h = Math.imul(h ^ (h >>> 13), 1274126177)\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296\n}\n\nexport interface DitheringHoverProps\n  extends DitherOptions,\n    Omit<React.ComponentProps<\"div\">, \"color\" | \"children\"> {\n  /** Image URL. Cross-origin images must be served with CORS headers, or the canvas cannot read them. */\n  src: string\n  /** Accessible description. Leave empty for decorative images. */\n  alt?: string\n  /** Diameter of the dithered zone in CSS pixels, 60 to 500. Default 240. */\n  zoneSize?: number\n  /** Number of streaks radiating from the zone. 0 for a clean circle. Default 28. */\n  streaks?: number\n  /** Streak reach as a multiple of the zone radius. Default 1. */\n  streakLength?: number\n  /** How ragged the rim and streaks are, 0 to 1. Default 0.6. */\n  roughness?: number\n  /** \"inside\" dithers under the cursor; \"outside\" dithers everything except the cursor zone. Default \"inside\". */\n  mode?: \"inside\" | \"outside\"\n  /** Pointer follow easing per frame, 0 to 1. 1 snaps, smaller values lag behind. Default 0.2. */\n  ease?: number\n  /** Seed for the streak layout. Change it to get a different pattern. Default 7. */\n  seed?: number\n  /** Default \"anonymous\". Pass undefined for same-origin images that must not send a CORS request. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\n  /** Called once the image is loaded and first drawn. */\n  onRendered?: () => void\n  /** Content layered over the canvas (captions, badges). */\n  children?: React.ReactNode\n}\n\nexport function DitheringHover({\n  src,\n  alt = \"\",\n  zoneSize = 240,\n  dotSize = 4,\n  matrix = 8,\n  dark = \"#000\",\n  light = \"#fff\",\n  contrast = 1,\n  brightness = 0,\n  fit = \"cover\",\n  streaks = 28,\n  streakLength = 1,\n  roughness = 0.6,\n  mode = \"inside\",\n  ease = 0.2,\n  seed = 7,\n  crossOrigin = \"anonymous\",\n  onRendered,\n  children,\n  className,\n  style,\n  ...props\n}: DitheringHoverProps) {\n  const wrapRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const [image, setImage] = React.useState<HTMLImageElement | null>(null)\n  const [aspect, setAspect] = React.useState<number | null>(null)\n  const [themeTick, setThemeTick] = React.useState(0)\n  const onRenderedRef = React.useRef(onRendered)\n  onRenderedRef.current = onRendered\n\n  // Pointer state lives in refs so moves never re-render React.\n  const pointer = React.useRef({ x: 0, y: 0, tx: 0, ty: 0, scale: 0, target: 0 })\n  const rafRef = React.useRef(0)\n\n  React.useEffect(() => {\n    if (!src) return\n    let cancelled = false\n    const img = new Image()\n    if (crossOrigin) img.crossOrigin = crossOrigin\n    img.onload = () => {\n      if (cancelled) return\n      setAspect(img.naturalWidth / img.naturalHeight)\n      setImage(img)\n    }\n    img.src = src\n    return () => {\n      cancelled = true\n    }\n  }, [src, crossOrigin])\n\n  // `currentColor` must re-ink when the theme changes.\n  React.useEffect(() => {\n    if (dark !== \"currentColor\" && light !== \"currentColor\") return\n    const root = document.documentElement\n    const observer = new MutationObserver(() => setThemeTick((t) => t + 1))\n    observer.observe(root, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] })\n    return () => observer.disconnect()\n  }, [dark, light])\n\n  React.useEffect(() => {\n    const wrap = wrapRef.current\n    const canvas = canvasRef.current\n    if (!wrap || !canvas || !image) return\n\n    const reduceMotion = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false\n    const follow = reduceMotion ? 1 : Math.min(1, Math.max(0.02, ease))\n\n    // Cached layers, rebuilt on resize or option change.\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let cell = Math.max(1, dotSize)\n    let bw = 0\n    let bh = 0\n    let bits = new Uint8Array(0)\n    let darkRGBA: [number, number, number, number] = [0, 0, 0, 255]\n    let lightRGBA: [number, number, number, number] = [255, 255, 255, 255]\n    const photo = document.createElement(\"canvas\")\n    const ink = document.createElement(\"canvas\")\n    const mask = document.createElement(\"canvas\")\n    const patch = document.createElement(\"canvas\")\n    let maskData: ImageData | null = null\n    let rendered = false\n\n    // Streak layout, fixed per seed.\n    const rays: { angle: number; len: number; width: number }[] = []\n    for (let i = 0; i < streaks; i++) {\n      const jitter = (hash(i, 1, seed) - 0.5) * ((Math.PI * 2) / Math.max(1, streaks)) * 0.9\n      rays.push({\n        angle: (i / Math.max(1, streaks)) * Math.PI * 2 + jitter,\n        len: 0.35 + hash(i, 2, seed) * 0.65,\n        width: 0.7 + hash(i, 3, seed) * 1.3,\n      })\n    }\n\n    const build = () => {\n      const rect = wrap.getBoundingClientRect()\n      width = Math.max(1, Math.round(rect.width))\n      height = Math.max(1, Math.round(rect.height))\n      dpr = Math.min(window.devicePixelRatio || 1, 2)\n      canvas.width = Math.round(width * dpr)\n      canvas.height = Math.round(height * dpr)\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx) return\n\n      const result = ditherBitmap(image, width, height, { dotSize, matrix, contrast, brightness, fit })\n      bits = result.bits\n      bw = result.bw\n      bh = result.bh\n      cell = result.cell\n      darkRGBA = parseColor(dark, wrap)\n      lightRGBA = parseColor(light, wrap)\n\n      photo.width = ink.width = canvas.width\n      photo.height = ink.height = canvas.height\n      const pctx = photo.getContext(\"2d\")\n      const ictx = ink.getContext(\"2d\")\n      if (!pctx || !ictx) return\n      pctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      drawCover(pctx, image, width, height, fit)\n\n      const block = document.createElement(\"canvas\")\n      block.width = bw\n      block.height = bh\n      const bctx = block.getContext(\"2d\")\n      if (!bctx) return\n      const img = bctx.createImageData(bw, bh)\n      for (let i = 0; i < bits.length; i++) {\n        const c = bits[i] ? lightRGBA : darkRGBA\n        img.data[i * 4] = c[0]\n        img.data[i * 4 + 1] = c[1]\n        img.data[i * 4 + 2] = c[2]\n        img.data[i * 4 + 3] = c[3]\n      }\n      bctx.putImageData(img, 0, 0)\n      ictx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      ictx.imageSmoothingEnabled = false\n      ictx.drawImage(block, 0, 0, bw * cell, bh * cell)\n\n      maskData = null\n      draw()\n    }\n\n    const draw = () => {\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx || !bw) return\n      const p = pointer.current\n      ctx.setTransform(1, 0, 0, 1, 0, 0)\n      ctx.clearRect(0, 0, canvas.width, canvas.height)\n      const base = mode === \"inside\" ? photo : ink\n      const top = mode === \"inside\" ? ink : photo\n      ctx.drawImage(base, 0, 0)\n\n      const radius = (zoneSize / 2) * p.scale\n      if (radius >= 1) {\n        const reach = radius * (1 + Math.max(0, streakLength) * (streaks > 0 ? 1 : 0)) + cell\n        const x0 = Math.max(0, Math.floor((p.x - reach) / cell))\n        const y0 = Math.max(0, Math.floor((p.y - reach) / cell))\n        const x1 = Math.min(bw - 1, Math.ceil((p.x + reach) / cell))\n        const y1 = Math.min(bh - 1, Math.ceil((p.y + reach) / cell))\n        const rw = x1 - x0 + 1\n        const rh = y1 - y0 + 1\n        if (rw > 0 && rh > 0) {\n          const mctx = mask.getContext(\"2d\")\n          if (!mctx) return\n          if (mask.width !== rw || mask.height !== rh || !maskData) {\n            mask.width = rw\n            mask.height = rh\n            maskData = mctx.createImageData(rw, rh)\n          }\n          const data = maskData.data\n          const core = radius * (1 - 0.3 * roughness)\n          const rim = Math.max(1e-3, radius - core)\n          const rayWidth = cell * 1.2\n          const fuzz = 0.25 + 0.75 * roughness\n          for (let by = 0; by < rh; by++) {\n            const cy = (y0 + by + 0.5) * cell - p.y\n            for (let bx = 0; bx < rw; bx++) {\n              const cx = (x0 + bx + 0.5) * cell - p.x\n              const d = Math.hypot(cx, cy)\n              let on = false\n              if (d <= core) on = true\n              else if (d <= radius) {\n                on = hash(x0 + bx, y0 + by, seed) > ((d - core) / rim) * fuzz\n              } else if (streaks > 0) {\n                const a = Math.atan2(cy, cx)\n                for (let i = 0; i < rays.length; i++) {\n                  const r = rays[i]\n                  let da = a - r.angle\n                  da = Math.atan2(Math.sin(da), Math.cos(da))\n                  const perp = Math.abs(d * Math.sin(da))\n                  if (perp > rayWidth * r.width || Math.cos(da) < 0) continue\n                  const len = radius * streakLength * r.len\n                  const t = (d - radius) / Math.max(1e-3, len)\n                  if (t > 1) continue\n                  // Gaps along the streak, denser towards the tip.\n                  const seg = Math.floor(d / (cell * 2))\n                  if (hash(i, seg, seed + 11) < t * fuzz) continue\n                  if (hash(x0 + bx, y0 + by, seed + 5) < t * t) continue\n                  on = true\n                  break\n                }\n              }\n              const k = (by * rw + bx) * 4\n              data[k] = 255\n              data[k + 1] = 255\n              data[k + 2] = 255\n              data[k + 3] = on ? 255 : 0\n            }\n          }\n          mctx.putImageData(maskData, 0, 0)\n\n          // Cut the top layer to the mask and lay it over the base.\n          const px = Math.round(x0 * cell * dpr)\n          const py = Math.round(y0 * cell * dpr)\n          const pw = Math.round(rw * cell * dpr)\n          const ph = Math.round(rh * cell * dpr)\n          if (patch.width !== pw || patch.height !== ph) {\n            patch.width = pw\n            patch.height = ph\n          }\n          const tctx = patch.getContext(\"2d\")\n          if (!tctx) return\n          tctx.globalCompositeOperation = \"source-over\"\n          tctx.clearRect(0, 0, pw, ph)\n          tctx.drawImage(top, px, py, pw, ph, 0, 0, pw, ph)\n          tctx.globalCompositeOperation = \"destination-in\"\n          tctx.imageSmoothingEnabled = false\n          tctx.drawImage(mask, 0, 0, pw, ph)\n          ctx.drawImage(patch, px, py)\n        }\n      }\n      if (!rendered) {\n        rendered = true\n        onRenderedRef.current?.()\n      }\n    }\n\n    const tick = () => {\n      rafRef.current = 0\n      const p = pointer.current\n      p.x += (p.tx - p.x) * follow\n      p.y += (p.ty - p.y) * follow\n      p.scale += (p.target - p.scale) * (reduceMotion ? 1 : 0.12)\n      const settled =\n        Math.abs(p.tx - p.x) < 0.3 && Math.abs(p.ty - p.y) < 0.3 && Math.abs(p.target - p.scale) < 0.005\n      if (settled) {\n        p.x = p.tx\n        p.y = p.ty\n        p.scale = p.target\n      }\n      draw()\n      if (!settled) rafRef.current = requestAnimationFrame(tick)\n    }\n    const wake = () => {\n      if (!rafRef.current) rafRef.current = requestAnimationFrame(tick)\n    }\n\n    const onMove = (e: PointerEvent) => {\n      const rect = wrap.getBoundingClientRect()\n      const p = pointer.current\n      p.tx = e.clientX - rect.left\n      p.ty = e.clientY - rect.top\n      if (p.target === 0) {\n        // Arrive at the cursor instead of sliding in from the last position.\n        p.x = p.tx\n        p.y = p.ty\n      }\n      p.target = 1\n      wake()\n    }\n    const onLeave = () => {\n      pointer.current.target = 0\n      wake()\n    }\n\n    wrap.addEventListener(\"pointermove\", onMove)\n    wrap.addEventListener(\"pointerdown\", onMove)\n    wrap.addEventListener(\"pointerleave\", onLeave)\n    wrap.addEventListener(\"pointercancel\", onLeave)\n\n    const ro = new ResizeObserver(build)\n    ro.observe(wrap)\n    build()\n\n    return () => {\n      wrap.removeEventListener(\"pointermove\", onMove)\n      wrap.removeEventListener(\"pointerdown\", onMove)\n      wrap.removeEventListener(\"pointerleave\", onLeave)\n      wrap.removeEventListener(\"pointercancel\", onLeave)\n      ro.disconnect()\n      if (rafRef.current) cancelAnimationFrame(rafRef.current)\n      rafRef.current = 0\n    }\n  }, [\n    image,\n    zoneSize,\n    dotSize,\n    matrix,\n    dark,\n    light,\n    contrast,\n    brightness,\n    fit,\n    streaks,\n    streakLength,\n    roughness,\n    mode,\n    ease,\n    seed,\n    themeTick,\n  ])\n\n  return (\n    <div\n      ref={wrapRef}\n      data-slot=\"dithering-hover\"\n      className={cn(\"relative overflow-hidden select-none\", className)}\n      style={{ aspectRatio: aspect ?? undefined, ...style }}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        role={alt ? \"img\" : undefined}\n        aria-label={alt || undefined}\n        aria-hidden={alt ? undefined : true}\n        className=\"absolute inset-0 block size-full\"\n      />\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}