{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halftone-image",
  "title": "Halftone Image",
  "description": "Renders any image as a print-style dot, line or square halftone on a canvas, with controls for dot size, screen angle, ink colour, contrast and inversion. Exports drawHalftone for PNG export.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/halftone-image/halftone-image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type HalftoneShape = \"dot\" | \"line\" | \"square\"\n\nexport interface HalftoneOptions {\n  /** Screen pitch in CSS pixels. Smaller is finer. Default 6. */\n  dotSize?: number\n  /** Screen angle in degrees. 45 is the classic print angle. Default 45. */\n  angle?: number\n  /** Ink colour. Any CSS colour. Default \"#111\". */\n  color?: string\n  /** Paper colour behind the dots. Default \"transparent\". */\n  background?: string\n  /** Dot, line or square screen. Default \"dot\". */\n  shape?: HalftoneShape\n  /** Screen the highlights instead of the shadows (for light ink on dark paper). Default false. */\n  invert?: boolean\n  /** Tone curve steepness, roughly 0.5 to 2. Default 1. */\n  contrast?: number\n  /** Largest mark relative to the cell. 1 means marks just touch; above 1 lets shadows close up. Default 1.15. */\n  gain?: number\n  /** How the source fills the canvas. Default \"cover\". */\n  fit?: \"cover\" | \"contain\"\n}\n\ntype Source = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap\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\n/**\n * Draws `image` onto `canvas` as a halftone screen at `width` x `height` CSS pixels.\n * Pure and framework-free, so it can also be used to export a PNG via canvas.toBlob().\n */\nexport function drawHalftone(\n  canvas: HTMLCanvasElement,\n  image: Source,\n  width: number,\n  height: number,\n  options: HalftoneOptions = {}\n) {\n  const steps = halftoneSteps(canvas, image, width, height, options)\n  while (!steps.next().done) {\n    // All at once. HalftoneImage spreads the same steps over several short tasks.\n  }\n}\n\n/** drawHalftone as a generator that pauses after each row of the screen. */\nfunction* halftoneSteps(\n  canvas: HTMLCanvasElement,\n  image: Source,\n  width: number,\n  height: number,\n  options: HalftoneOptions\n): Generator<void, void, void> {\n  const {\n    dotSize = 6,\n    angle = 45,\n    color = \"#111\",\n    background = \"transparent\",\n    shape = \"dot\",\n    invert = false,\n    contrast = 1,\n    gain = 1.15,\n    fit = \"cover\",\n  } = options\n\n  const cell = Math.max(2, dotSize)\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  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n  ctx.clearRect(0, 0, width, height)\n  if (background !== \"transparent\") {\n    ctx.fillStyle = background\n    ctx.fillRect(0, 0, width, height)\n  }\n\n  // Sample buffer at roughly two samples per cell. drawImage averages the pixels for us.\n  const k = Math.max(1, cell / 2)\n  const sw = Math.max(1, Math.ceil(width / k))\n  const sh = Math.max(1, Math.ceil(height / k))\n  const off = document.createElement(\"canvas\")\n  off.width = sw\n  off.height = sh\n  const octx = off.getContext(\"2d\", { willReadFrequently: true })\n  if (!octx) return\n  const [iw, ih] = sourceSize(image)\n  if (!iw || !ih) return\n  const scale = fit === \"cover\" ? Math.max(sw / iw, sh / ih) : Math.min(sw / iw, sh / ih)\n  const dw = iw * scale\n  const dh = ih * scale\n  octx.drawImage(image, (sw - dw) / 2, (sh - dh) / 2, dw, dh)\n  const data = octx.getImageData(0, 0, sw, sh).data\n\n  const rad = (angle * Math.PI) / 180\n  const cos = Math.cos(rad)\n  const sin = Math.sin(rad)\n  const cx = width / 2\n  const cy = height / 2\n  const half = Math.hypot(width, height) / 2 + cell\n\n  ctx.fillStyle = color\n  for (let v = -half; v <= half; v += cell) {\n    for (let u = -half; u <= half; u += cell) {\n      const x = cx + u * cos - v * sin\n      const y = cy + u * sin + v * cos\n      if (x < -cell || y < -cell || x > width + cell || y > height + cell) continue\n\n      const sx = Math.min(sw - 1, Math.max(0, Math.floor(x / k)))\n      const sy = Math.min(sh - 1, Math.max(0, Math.floor(y / k)))\n      const i = (sy * sw + sx) * 4\n      const alpha = data[i + 3] / 255\n      const lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255\n      let d = invert ? lum : 1 - lum\n      d = Math.min(1, Math.max(0, (d - 0.5) * contrast + 0.5)) * alpha\n      if (d < 0.02) continue\n\n      if (shape === \"dot\") {\n        // Area, not radius, should track darkness.\n        const r = (Math.sqrt(d) * cell * gain) / 2\n        ctx.beginPath()\n        ctx.arc(x, y, r, 0, Math.PI * 2)\n        ctx.fill()\n      } else {\n        ctx.save()\n        ctx.translate(x, y)\n        ctx.rotate(rad)\n        if (shape === \"square\") {\n          const s = Math.sqrt(d) * cell * gain\n          ctx.fillRect(-s / 2, -s / 2, s, s)\n        } else {\n          const t = d * cell * gain\n          ctx.fillRect(-cell / 2 - 0.3, -t / 2, cell + 0.6, t)\n        }\n        ctx.restore()\n      }\n    }\n    yield\n  }\n}\n\nexport interface HalftoneImageProps\n  extends HalftoneOptions,\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  /** Default \"anonymous\". Pass undefined for same-origin images that must not send a CORS request. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\n  /** Called after each draw. */\n  onRendered?: () => void\n  /** Content layered over the halftone (captions, badges). */\n  children?: React.ReactNode\n}\n\n/**\n * Renders any image as a print-style halftone screen on a canvas. Size it like an\n * image: give it a width (and optionally a height or aspect ratio) with className.\n * Without an explicit height it adopts the source image's aspect ratio.\n */\nexport function HalftoneImage({\n  src,\n  alt = \"\",\n  crossOrigin = \"anonymous\",\n  dotSize = 6,\n  angle = 45,\n  color = \"currentColor\",\n  background = \"transparent\",\n  shape = \"dot\",\n  invert = false,\n  contrast = 1,\n  gain = 1.15,\n  fit = \"cover\",\n  onRendered,\n  className,\n  style,\n  children,\n  ...props\n}: HalftoneImageProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const renderedRef = React.useRef(onRendered)\n  renderedRef.current = onRendered\n\n  const [image, setImage] = React.useState<HTMLImageElement | null>(null)\n  const [ratio, setRatio] = React.useState<number | undefined>(undefined)\n\n  React.useEffect(() => {\n    let cancelled = false\n    const img = new Image()\n    if (crossOrigin) img.crossOrigin = crossOrigin\n    img.decoding = \"async\"\n    img.onload = () => {\n      if (cancelled) return\n      setImage(img)\n      setRatio(img.naturalWidth / img.naturalHeight)\n    }\n    img.onerror = () => {\n      if (!cancelled) setImage(null)\n    }\n    img.src = src\n    return () => {\n      cancelled = true\n    }\n  }, [src, crossOrigin])\n\n  React.useEffect(() => {\n    const el = ref.current\n    const canvas = canvasRef.current\n    if (!el || !canvas || !image) return\n\n    let frame = 0\n    let slice = 0\n    const render = () => {\n      cancelAnimationFrame(frame)\n      clearTimeout(slice)\n      frame = requestAnimationFrame(() => {\n        const { width, height } = el.getBoundingClientRect()\n        if (!width || !height) return\n        const ink = color === \"currentColor\" ? getComputedStyle(el).color : color\n        const paper = background === \"currentColor\" ? getComputedStyle(el).color : background\n        // A large screen is tens of thousands of dots. Draw them off screen in ~8ms slices so the\n        // page can paint and respond in between, then swap the finished image in at once.\n        const off = document.createElement(\"canvas\")\n        const steps = halftoneSteps(off, image, width, height, {\n          dotSize,\n          angle,\n          color: ink,\n          background: paper,\n          shape,\n          invert,\n          contrast,\n          gain,\n          fit,\n        })\n        const pump = () => {\n          const end = performance.now() + 8\n          try {\n            while (performance.now() < end) {\n              if (!steps.next().done) continue\n              canvas.width = off.width\n              canvas.height = off.height\n              canvas.getContext(\"2d\")?.drawImage(off, 0, 0)\n              renderedRef.current?.()\n              return\n            }\n          } catch (error) {\n            console.warn(\n              \"HalftoneImage: could not read the image pixels. Serve it from the same origin or with CORS headers.\",\n              error\n            )\n            return\n          }\n          slice = window.setTimeout(pump)\n        }\n        pump()\n      })\n    }\n\n    render()\n    const ro = new ResizeObserver(render)\n    ro.observe(el)\n    // Re-ink when the theme flips and the colour is inherited.\n    const inherits = color === \"currentColor\" || background === \"currentColor\"\n    const mo = inherits ? new MutationObserver(render) : null\n    mo?.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    return () => {\n      ro.disconnect()\n      mo?.disconnect()\n      cancelAnimationFrame(frame)\n      clearTimeout(slice)\n    }\n  }, [image, dotSize, angle, color, background, shape, invert, contrast, gain, fit])\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"halftone-image\"\n      className={cn(\"relative overflow-hidden\", className)}\n      style={{ aspectRatio: ratio, ...style }}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"absolute inset-0 size-full\"\n        role={alt ? \"img\" : undefined}\n        aria-label={alt || undefined}\n        aria-hidden={alt ? undefined : true}\n      />\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}