{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"halftone-image","type":"registry:component","title":"Halftone Image","description":"Renders any bitmap as a printed halftone screen on canvas: tone drives dot size on a grid you can rotate (45° reads as print), with circle or square dots and adjustable pitch. Pass a second plate and the grey screen crossfades to colour on hover, each dot inked with the tone beneath it. Transparent between dots, so it composes over any surface.","author":"UI Lab","dependencies":["clsx","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/halftone-image.tsx","type":"registry:component","target":"@components/motion/halftone-image.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/motion/halftone-image\n\nimport { useEffect, useRef } from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type HalftoneDotShape = \"circle\" | \"square\";\n\nexport interface HalftoneImageProps {\n  /** Source bitmap. Any image the browser can decode; it is screened, not displayed. */\n  src: string;\n  /**\n   * Optional second plate revealed on hover. Same framing as `src` — usually the\n   * colour original against a desaturated `src`. Omit for a screen that never\n   * changes; on touch devices it is never rendered, since there is no hover.\n   */\n  colorSrc?: string;\n  /** Describes the picture. The canvases are hidden from AT; the wrapper carries this. */\n  alt: string;\n  /** Grid pitch in CSS px — the screen's coarseness. 4–6 reads as print, 10+ as a poster. */\n  cell?: number;\n  /** Largest dot as a fraction of `cell`. Above ~0.7 the darkest dots touch and the grid closes up. */\n  dotScale?: number;\n  /** Screen rotation in degrees. Print screens sit at 45° so the grid stops reading as rows. */\n  angle?: number;\n  shape?: HalftoneDotShape;\n  /** Screen the inverse: dark dots for a light surface. */\n  invert?: boolean;\n  /**\n   * Dot colour. Defaults to the wrapper's computed `color`, so a `text-*` class\n   * drives it and it follows the theme.\n   */\n  color?: string;\n  className?: string;\n}\n\n/** Rec. 709 luma. Matches how the eye weights the channels, so tone survives screening. */\nfunction luminance(r: number, g: number, b: number): number {\n  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n}\n\n/**\n * `object-fit: cover` in numbers: the scale and offset that fill `w`×`h` with an\n * `iw`×`ih` bitmap without distorting it.\n */\nexport function coverFit(\n  iw: number,\n  ih: number,\n  w: number,\n  h: number,\n): { scale: number; dx: number; dy: number } {\n  const scale = Math.max(w / iw, h / ih);\n  return { scale, dx: (w - iw * scale) / 2, dy: (h - ih * scale) / 2 };\n}\n\n/**\n * Dot radius for a tone. Area — not radius — is proportional to the tone, which\n * is how a real halftone reproduces greys: a 25% dot must cover a quarter of its\n * cell, so the radius goes as the square root.\n */\nexport function dotRadius(tone: number, cell: number, dotScale: number): number {\n  const clamped = tone < 0 ? 0 : tone > 1 ? 1 : tone;\n  return Math.sqrt(clamped) * (cell / 2) * dotScale;\n}\n\n/**\n * Lattice points of a screen rotated `angle` degrees about the centre of a\n * `w`×`h` box, covering it completely. Yields device-space coordinates.\n */\nexport function* screenLattice(\n  w: number,\n  h: number,\n  cell: number,\n  angle: number,\n): Generator<{ x: number; y: number }> {\n  const rad = (angle * Math.PI) / 180;\n  const cos = Math.cos(rad);\n  const sin = Math.sin(rad);\n  const cx = w / 2;\n  const cy = h / 2;\n  // A rotated grid must cover the box's diagonal to leave no bare corner.\n  const reach = Math.hypot(w, h) / 2 + cell;\n  // Snapped to a whole cell so a dot always lands on the exact centre and the\n  // grid's phase does not depend on the box size — otherwise the whole screen\n  // shifts as the container resizes.\n  const start = -Math.ceil(reach / cell) * cell;\n\n  for (let v = start; v <= reach; v += cell) {\n    for (let u = start; u <= reach; 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 > w + cell || y > h + cell) continue;\n      yield { x, y };\n    }\n  }\n}\n\ntype Plate = { kind: \"flat\"; color: string } | { kind: \"sampled\" };\n\n/**\n * Screens one bitmap onto one canvas. `plate: \"sampled\"` colours every dot with\n * the tone it sat on, which is what separates the colour plate from the grey one.\n *\n * Exported so you can screen onto a canvas you own — the React component is a\n * thin wrapper over this.\n */\nexport function paintHalftone(\n  canvas: HTMLCanvasElement,\n  image: HTMLImageElement,\n  options: {\n    cell: number;\n    dotScale: number;\n    angle: number;\n    shape: HalftoneDotShape;\n    invert: boolean;\n    plate: Plate;\n    dpr: number;\n    cssWidth: number;\n    cssHeight: number;\n  },\n): void {\n  const { cell, dotScale, angle, shape, invert, plate, dpr, cssWidth, cssHeight } =\n    options;\n\n  const w = Math.max(1, Math.round(cssWidth * dpr));\n  const h = Math.max(1, Math.round(cssHeight * dpr));\n  canvas.width = w;\n  canvas.height = h;\n  canvas.style.width = `${cssWidth}px`;\n  canvas.style.height = `${cssHeight}px`;\n\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return;\n  ctx.clearRect(0, 0, w, h);\n\n  // The source is rasterised once at output size, then sampled per dot.\n  const source = document.createElement(\"canvas\");\n  source.width = w;\n  source.height = h;\n  const sourceCtx = source.getContext(\"2d\", { willReadFrequently: true });\n  if (!sourceCtx) return;\n\n  const { scale, dx, dy } = coverFit(image.naturalWidth, image.naturalHeight, w, h);\n  sourceCtx.drawImage(\n    image,\n    dx,\n    dy,\n    image.naturalWidth * scale,\n    image.naturalHeight * scale,\n  );\n\n  let pixels: Uint8ClampedArray;\n  try {\n    pixels = sourceCtx.getImageData(0, 0, w, h).data;\n  } catch {\n    // A cross-origin bitmap taints the canvas and blocks reads. Showing the\n    // picture unscreened beats showing nothing.\n    ctx.drawImage(\n      image,\n      dx,\n      dy,\n      image.naturalWidth * scale,\n      image.naturalHeight * scale,\n    );\n    return;\n  }\n\n  const deviceCell = cell * dpr;\n  const maxRadius = deviceCell / 2;\n\n  if (plate.kind === \"flat\") ctx.fillStyle = plate.color;\n\n  for (const { x, y } of screenLattice(w, h, deviceCell, angle)) {\n    const px = Math.min(w - 1, Math.max(0, Math.round(x)));\n    const py = Math.min(h - 1, Math.max(0, Math.round(y)));\n    const i = (py * w + px) * 4;\n    const r = pixels[i];\n    const g = pixels[i + 1];\n    const b = pixels[i + 2];\n    const alpha = pixels[i + 3] / 255;\n    if (alpha === 0) continue;\n\n    const lum = luminance(r, g, b);\n    const tone = (invert ? 1 - lum : lum) * alpha;\n    const radius = dotRadius(tone, deviceCell, dotScale);\n    if (radius < 0.05) continue;\n\n    if (plate.kind === \"sampled\") ctx.fillStyle = `rgb(${r} ${g} ${b})`;\n\n    if (shape === \"square\") {\n      // Match the circle's ink coverage so the two shapes read at the same weight.\n      const side = radius * Math.sqrt(Math.PI);\n      ctx.fillRect(x - side / 2, y - side / 2, side, side);\n    } else {\n      ctx.beginPath();\n      ctx.arc(x, y, Math.min(radius, maxRadius), 0, Math.PI * 2);\n      ctx.fill();\n    }\n  }\n}\n\n/**\n * Renders a bitmap as a printed halftone screen: the image's tone drives dot\n * size on a rotated grid, drawn to canvas. Give it a second plate via\n * `colorSrc` and the grey screen crossfades to a colour one on hover.\n *\n * The canvas is transparent between dots, so it composes over whatever sits\n * behind it — pair it with a `halftone-*` background atom or plain surface.\n * For a generative dithered *wave* rather than a screened picture, see the\n * `dither` variant of `webgl-background`.\n */\nexport function HalftoneImage({\n  src,\n  colorSrc,\n  alt,\n  cell = 6,\n  dotScale = 0.62,\n  angle = 45,\n  shape = \"circle\",\n  invert = false,\n  color,\n  className,\n}: HalftoneImageProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const baseRef = useRef<HTMLCanvasElement>(null);\n  const colorRef = useRef<HTMLCanvasElement>(null);\n  const hoverCapable = useHoverCapable();\n  const showColorPlate = Boolean(colorSrc) && hoverCapable;\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    let disposed = false;\n    let frame = 0;\n    const loaded = new Map<string, HTMLImageElement>();\n\n    function paintAll() {\n      if (disposed) return;\n      const width = container?.clientWidth ?? 0;\n      const height = container?.clientHeight ?? 0;\n      if (width === 0 || height === 0) return;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const resolvedColor =\n        color ?? getComputedStyle(container as Element).color ?? \"#ffffff\";\n\n      const base = baseRef.current;\n      const baseImage = loaded.get(src);\n      if (base && baseImage) {\n        paintHalftone(base, baseImage, {\n          cell,\n          dotScale,\n          angle,\n          shape,\n          invert,\n          plate: { kind: \"flat\", color: resolvedColor },\n          dpr,\n          cssWidth: width,\n          cssHeight: height,\n        });\n      }\n\n      const plate = colorRef.current;\n      const plateImage = colorSrc ? loaded.get(colorSrc) : undefined;\n      if (plate && plateImage) {\n        paintHalftone(plate, plateImage, {\n          cell,\n          dotScale,\n          angle,\n          shape,\n          invert,\n          plate: { kind: \"sampled\" },\n          dpr,\n          cssWidth: width,\n          cssHeight: height,\n        });\n      }\n    }\n\n    function schedule() {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(paintAll);\n    }\n\n    const sources = showColorPlate && colorSrc ? [src, colorSrc] : [src];\n    for (const source of sources) {\n      const image = new Image();\n      image.crossOrigin = \"anonymous\";\n      image.decoding = \"async\";\n      image.onload = () => {\n        loaded.set(source, image);\n        schedule();\n      };\n      image.src = source;\n    }\n\n    const observer = new ResizeObserver(schedule);\n    observer.observe(container);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [src, colorSrc, cell, dotScale, angle, shape, invert, color, showColorPlate]);\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"img\"\n      aria-label={alt}\n      className={cn(\"group relative overflow-hidden\", className)}\n    >\n      <canvas ref={baseRef} aria-hidden className=\"block h-full w-full\" />\n      {showColorPlate ? (\n        <canvas\n          ref={colorRef}\n          aria-hidden\n          className=\"absolute inset-0 block h-full w-full opacity-0 transition-opacity duration-500 ease-out group-hover:opacity-100\"\n        />\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}