{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halftone-image",
  "type": "registry:ui",
  "title": "Halftone Image",
  "description": "Any image re-printed as a rotated halftone screen of dots, squares or lines, with a pointer lens that reveals the original.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/halftone-image.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type HalftoneShape = \"circle\" | \"square\" | \"line\";\nexport type HalftoneColorMode = \"mono\" | \"duotone\";\nexport type HalftoneLens = \"reveal\" | \"magnify\" | \"none\";\n\nexport interface HalftoneImageProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Image URL. Remote images need CORS headers (pixels are read). Without `src`, a generated landscape is used. */\n  src?: string;\n  /** Alternative text. When set, the root becomes role=\"img\" with this label; otherwise it is decorative. */\n  alt?: string;\n  /** Screen pitch: distance between dot centres, in px. */\n  cellSize?: number;\n  /** Screen angle, in degrees. Print screens traditionally sit at 15°, 45° or 75°. */\n  angle?: number;\n  /** Dot shape. `line` draws a line screen whose stroke thickness carries the tone. */\n  shape?: HalftoneShape;\n  /** mono: one screen in the theme ink · duotone: an accent screen under an ink screen at another angle. */\n  colorMode?: HalftoneColorMode;\n  /** Tone contrast around the midpoint (1 = linear). */\n  contrast?: number;\n  /** What the pointer lens does: show the original image, enlarge the dots, or nothing. */\n  lens?: HalftoneLens;\n  /** Lens radius, in px. */\n  lensRadius?: number;\n  /** Accent colour for the duotone screen and the lens ring (any CSS colour). */\n  accent?: string;\n  /** How the image fits the box. */\n  fit?: \"cover\" | \"contain\";\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\nconst rgb = ([r, g, b]: RGB) => `rgb(${r}, ${g}, ${b})`;\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ------------------------------ Generated art ------------------------------ */\n\n/**\n * The built-in demo image: an evening landscape painted with gradients (no network request).\n * It carries a full tonal range — pale sky, a bright sun, mid-tone ridges, a dark foreground.\n */\nfunction paintLandscape(ctx: CanvasRenderingContext2D, w: number, h: number) {\n  const sky = ctx.createLinearGradient(0, 0, 0, h);\n  sky.addColorStop(0, \"#aaa2ad\");\n  sky.addColorStop(0.42, \"#dcb3a0\");\n  sky.addColorStop(0.72, \"#efa27c\");\n  ctx.fillStyle = sky;\n  ctx.fillRect(0, 0, w, h);\n\n  const m = Math.min(w, h);\n  const sx = w * 0.66;\n  const sy = h * 0.44;\n  const sr = m * 0.2;\n  const sun = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr);\n  sun.addColorStop(0, \"#fffaf0\");\n  sun.addColorStop(0.7, \"#ffe2b8\");\n  sun.addColorStop(1, \"#ffcf9e\");\n  ctx.fillStyle = sun;\n  ctx.beginPath();\n  ctx.arc(sx, sy, sr, 0, Math.PI * 2);\n  ctx.fill();\n\n  // Ridges, far to near: each a sum of sines, darker and lower than the last.\n  const ridges: { base: number; amp: number; color: string; f: number; p: number }[] = [\n    { base: 0.6, amp: 0.07, color: \"#c9806a\", f: 1.7, p: 0.4 },\n    { base: 0.68, amp: 0.08, color: \"#9a5c56\", f: 2.3, p: 2.1 },\n    { base: 0.77, amp: 0.07, color: \"#5e3d47\", f: 3.1, p: 4.2 },\n    { base: 0.87, amp: 0.06, color: \"#2a2230\", f: 2.6, p: 1.3 },\n  ];\n  for (const r of ridges) {\n    ctx.fillStyle = r.color;\n    ctx.beginPath();\n    ctx.moveTo(0, h);\n    for (let x = 0; x <= w; x += Math.max(2, w / 160)) {\n      const t = x / w;\n      const y =\n        r.base +\n        Math.sin(t * Math.PI * r.f + r.p) * r.amp * 0.6 +\n        Math.sin(t * Math.PI * r.f * 2.7 + r.p * 1.9) * r.amp * 0.3 +\n        Math.sin(t * Math.PI * r.f * 6.1 + r.p * 0.7) * r.amp * 0.1;\n      ctx.lineTo(x, y * h);\n    }\n    ctx.lineTo(w, h);\n    ctx.closePath();\n    ctx.fill();\n  }\n}\n\n/* -------------------------------- Component -------------------------------- */\n\ninterface Loaded {\n  src: string;\n  image: HTMLImageElement | null;\n}\n\n/**\n * An image re-drawn as a print halftone on a canvas: the tone at each cell of a rotated screen\n * sets the size of a dot, square or line. Light themes print dark ink for shadows; dark themes\n * print light ink for highlights, so the picture stays positive. A pointer lens can reveal the\n * original image or magnify the screen locally. Nothing animates while idle — the canvas only\n * redraws when a prop, the size, the theme or the lens changes.\n */\nexport function HalftoneImage({\n  src,\n  alt,\n  cellSize = 10,\n  angle = 45,\n  shape = \"circle\",\n  colorMode = \"mono\",\n  contrast = 1.2,\n  lens = \"reveal\",\n  lensRadius = 110,\n  accent = \"#FF7448\",\n  fit = \"cover\",\n  className = \"\",\n  ...props\n}: HalftoneImageProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const [loaded, setLoaded] = React.useState<Loaded | null>(null);\n\n  // Load `src` once per URL. A failed or CORS-blocked image falls back to the generated art.\n  React.useEffect(() => {\n    if (!src) return;\n    let cancelled = false;\n    const img = new Image();\n    img.crossOrigin = \"anonymous\";\n    img.decoding = \"async\";\n    img.onload = () => !cancelled && setLoaded({ src, image: img });\n    img.onerror = () => !cancelled && setLoaded({ src, image: null });\n    img.src = src;\n    return () => {\n      cancelled = true;\n    };\n  }, [src]);\n\n  /** undefined while a `src` is still loading; null = use the generated art. */\n  const image: HTMLImageElement | null | undefined = !src ? null : loaded?.src === src ? loaded.image : undefined;\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!root || !canvas || !ctx || image === undefined) return;\n\n    const cell = Math.max(3, cellSize);\n    const theta = (angle * Math.PI) / 180;\n    const reach = Math.max(16, lensRadius);\n    const gain = Math.max(0.1, contrast);\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let ink: RGB = [24, 24, 27];\n    let accentRGB: RGB = [255, 116, 72];\n    // Tone samples on a coarse grid (a few per cell), and the full-colour picture for the lens.\n    let tones = new Float32Array(0);\n    let sampleW = 0;\n    let sampleH = 0;\n    let sampleStep = 1;\n    const photo = document.createElement(\"canvas\");\n    const screen = document.createElement(\"canvas\");\n    const pointer = { x: 0, y: 0, strength: 0, target: 0 };\n\n    const readTheme = () => {\n      ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      accentRGB = resolveColor(accent, [255, 116, 72]);\n    };\n\n    /** Paints the source (image or generated art) into `target`, fitted to the box. */\n    const paintSource = (target: CanvasRenderingContext2D, w: number, h: number) => {\n      target.clearRect(0, 0, w, h);\n      if (!image) {\n        paintLandscape(target, w, h);\n        return;\n      }\n      const iw = image.naturalWidth || 1;\n      const ih = image.naturalHeight || 1;\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      target.imageSmoothingQuality = \"high\";\n      target.drawImage(image, (w - dw) / 2, (h - dh) / 2, dw, dh);\n    };\n\n    /** Samples luminance into `tones` as ink coverage 0–1 (after contrast), theme-aware. */\n    const sampleTones = () => {\n      sampleStep = Math.max(1, cell / 3);\n      sampleW = Math.max(1, Math.ceil(width / sampleStep));\n      sampleH = Math.max(1, Math.ceil(height / sampleStep));\n      const sampler = document.createElement(\"canvas\");\n      sampler.width = sampleW;\n      sampler.height = sampleH;\n      const sctx = sampler.getContext(\"2d\", { willReadFrequently: true });\n      if (!sctx) return;\n      // Downscaling while drawing averages pixels, which keeps the screen free of aliasing.\n      sctx.drawImage(photo, 0, 0, sampleW, sampleH);\n      let data: Uint8ClampedArray;\n      try {\n        data = sctx.getImageData(0, 0, sampleW, sampleH).data;\n      } catch {\n        // Tainted canvas (cross-origin image without CORS): fall back to the generated art.\n        paintLandscape(sctx, sampleW, sampleH);\n        data = sctx.getImageData(0, 0, sampleW, sampleH).data;\n      }\n      const lightInk = luminance(ink) > 0.5;\n      tones = new Float32Array(sampleW * sampleH);\n      for (let i = 0; i < tones.length; i++) {\n        const o = i * 4;\n        const lum = (0.2126 * data[o] + 0.7152 * data[o + 1] + 0.0722 * data[o + 2]) / 255;\n        // Dark ink prints shadows; light ink (dark theme) prints highlights.\n        // Light ink is held back a little so bright areas stay a screen instead of merging into a slab.\n        let t = lightInk ? lum * 0.88 : 1 - lum;\n        t = (t - 0.5) * gain + 0.5;\n        tones[i] = Math.min(1, Math.max(0, t)) * (data[o + 3] / 255);\n      }\n    };\n\n    const toneAt = (x: number, y: number) => {\n      const sx = Math.min(sampleW - 1, Math.max(0, Math.floor(x / sampleStep)));\n      const sy = Math.min(sampleH - 1, Math.max(0, Math.floor(y / sampleStep)));\n      return tones[sy * sampleW + sx] ?? 0;\n    };\n\n    /**\n     * Draws one screen. Cells are laid out in the screen's rotated frame (u, v) around the centre;\n     * each cell's tone is read at its on-canvas position. With `focus`, only cells inside the lens\n     * are drawn, pushed outward and enlarged by a bulge that peaks at the lens centre.\n     */\n    const drawScreen = (\n      target: CanvasRenderingContext2D,\n      rotation: number,\n      color: string,\n      curve: (t: number) => number,\n      focus: { x: number; y: number; r: number; k: number } | null,\n    ) => {\n      const cx = width / 2;\n      const cy = height / 2;\n      const cos = Math.cos(rotation);\n      const sin = Math.sin(rotation);\n      const half = Math.hypot(width, height) / 2 + cell;\n      const n = Math.ceil(half / cell);\n      // Lens centre in the rotated frame.\n      const fu = focus ? (focus.x - cx) * cos + (focus.y - cy) * sin : 0;\n      const fv = focus ? -(focus.x - cx) * sin + (focus.y - cy) * cos : 0;\n      const path = new Path2D();\n\n      for (let j = -n; j <= n; j++) {\n        const v = j * cell;\n        if (focus && Math.abs(v - fv) > focus.r + cell) continue;\n        for (let i = -n; i <= n; i++) {\n          const u = i * cell;\n          let scale = 1;\n          let pu = u;\n          let pv = v;\n          if (focus) {\n            const du = u - fu;\n            const dv = v - fv;\n            const d = Math.hypot(du, dv);\n            if (d > focus.r) continue;\n            const f = 1 - d / focus.r;\n            scale = 1 + focus.k * f * f * (3 - 2 * f);\n            pu = fu + du * scale;\n            pv = fv + dv * scale;\n          }\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          const t = curve(toneAt(x, y));\n          if (t < 0.02) continue;\n          const size = cell * scale;\n          if (shape === \"circle\") {\n            const r = size * 0.5 * Math.sqrt(t) * 1.34;\n            path.moveTo(pu + r, pv);\n            path.arc(pu, pv, r, 0, Math.PI * 2);\n          } else if (shape === \"square\") {\n            const s = size * Math.sqrt(t);\n            path.rect(pu - s / 2, pv - s / 2, s, s);\n          } else {\n            // Line screen: abutting segments whose thickness carries the tone.\n            const th = size * t;\n            path.rect(pu - size / 2 - 0.25, pv - th / 2, size + 0.5, th);\n          }\n        }\n      }\n      target.save();\n      target.setTransform(dpr, 0, 0, dpr, 0, 0);\n      target.translate(cx, cy);\n      target.rotate(rotation);\n      target.fillStyle = color;\n      target.fill(path);\n      target.restore();\n    };\n\n    /** All screens for the current colour mode. */\n    const drawScreens = (target: CanvasRenderingContext2D, focus: { x: number; y: number; r: number; k: number } | null) => {\n      if (colorMode === \"duotone\") {\n        // Accent screen carries the overall image at a second angle; the ink screen adds the deep tones.\n        drawScreen(target, theta + Math.PI / 6, rgb(accentRGB), (t) => Math.pow(t, 0.85) * 0.92, focus);\n        drawScreen(target, theta, rgb(ink), (t) => Math.max(0, (t - 0.38) / 0.62), focus);\n      } else {\n        drawScreen(target, theta, rgb(ink), (t) => t, focus);\n      }\n    };\n\n    /** Rebuilds the cached screen (after a resize, theme or prop change). */\n    const rebuild = () => {\n      if (width < 1 || height < 1) return;\n      photo.width = Math.max(1, Math.round(width * dpr));\n      photo.height = Math.max(1, Math.round(height * dpr));\n      const pctx = photo.getContext(\"2d\");\n      if (!pctx) return;\n      pctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      paintSource(pctx, width, height);\n      sampleTones();\n      screen.width = photo.width;\n      screen.height = photo.height;\n      const sctx = screen.getContext(\"2d\");\n      if (!sctx) return;\n      sctx.clearRect(0, 0, screen.width, screen.height);\n      drawScreens(sctx, null);\n    };\n\n    const draw = () => {\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.drawImage(screen, 0, 0);\n      const s = pointer.strength;\n      if (lens === \"none\" || s < 0.01) return;\n\n      const r = reach * (0.6 + 0.4 * s);\n      ctx.save();\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.beginPath();\n      ctx.arc(pointer.x, pointer.y, r, 0, Math.PI * 2);\n      ctx.save();\n      ctx.clip();\n      ctx.clearRect(pointer.x - r, pointer.y - r, r * 2, r * 2);\n      if (lens === \"reveal\") {\n        ctx.globalAlpha = s;\n        ctx.drawImage(photo, 0, 0, width, height);\n        ctx.globalAlpha = 1;\n      } else {\n        drawScreens(ctx, { x: pointer.x, y: pointer.y, r, k: 0.9 * s });\n      }\n      ctx.restore();\n      // Fine ring marking the lens edge.\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = `rgba(${accentRGB[0]}, ${accentRGB[1]}, ${accentRGB[2]}, ${0.9 * s})`;\n      ctx.stroke();\n      ctx.restore();\n    };\n\n    // Demand-driven: frames run only while the lens eases in or out (or moves).\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      pointer.strength += (pointer.target - pointer.strength) * (1 - Math.pow(1 - 0.25, dt / 16.7));\n      if (Math.abs(pointer.target - pointer.strength) < 0.01) pointer.strength = pointer.target;\n      draw();\n      if (pointer.strength !== pointer.target) schedule();\n      else last = 0;\n    };\n    const schedule = () => {\n      if (!raf && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 0.5 && Math.abs(rect.height - height) < 0.5 && width > 0) return;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      rebuild();\n      draw();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      rebuild();\n      draw();\n    };\n\n    const locate = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      pointer.x = e.clientX - rect.left;\n      pointer.y = e.clientY - rect.top;\n    };\n    const show = (on: boolean) => {\n      pointer.target = on ? 1 : 0;\n      if (reduceMotion) pointer.strength = pointer.target;\n      schedule();\n    };\n    let pressed = false;\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && !pressed) return;\n      locate(e);\n      if (pointer.target === 0) show(true);\n      else schedule();\n    };\n    // Touch and pen: the lens follows the finger while pressed (until the browser starts a scroll).\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\") return;\n      pressed = true;\n      locate(e);\n      show(true);\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\") return;\n      pressed = false;\n      show(false);\n    };\n    const onPointerLeave = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\") show(false);\n    };\n    const onVisibility = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        last = 0;\n      }\n    };\n\n    readTheme();\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    const passive = { passive: true } as const;\n    if (lens !== \"none\") {\n      root.addEventListener(\"pointermove\", onPointerMove, passive);\n      root.addEventListener(\"pointerdown\", onPointerDown, passive);\n      root.addEventListener(\"pointerup\", onPointerUp, passive);\n      root.addEventListener(\"pointercancel\", onPointerUp, passive);\n      root.addEventListener(\"pointerleave\", onPointerLeave, passive);\n    }\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      unobserveTheme();\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n      root.removeEventListener(\"pointerup\", onPointerUp);\n      root.removeEventListener(\"pointercancel\", onPointerUp);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [image, cellSize, angle, shape, colorMode, contrast, lens, lensRadius, accent, fit, reduceMotion]);\n\n  const labelled = Boolean(alt);\n  return (\n    <div\n      ref={rootRef}\n      role={labelled ? \"img\" : undefined}\n      aria-label={labelled ? alt : undefined}\n      aria-hidden={labelled ? undefined : true}\n      className={`absolute inset-0 overflow-hidden ${lens === \"none\" ? \"\" : \"cursor-crosshair\"} ${className}`}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/halftone-image"
}
