{
  "name": "halftone-background",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Halftone Background\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { type ReactNode, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface HalftoneBackgroundProps {\n  className?: string;\n  /** Content shown over the dots. Pass `null` for a bare background. */\n  children?: ReactNode;\n  /**\n   * An image to print as dots. It must be same-origin or served with CORS\n   * headers. Without one, a lit sphere is drawn.\n   */\n  src?: string;\n  /** Distance between dots, in px. */\n  cell?: number;\n  /** Screen angle in degrees; 45 is the classic print angle. */\n  angle?: number;\n  /** Show a loupe under the cursor that prints at twice the resolution. */\n  loupe?: boolean;\n}\n\n// The loupe's radius in px, and how much finer its screen is.\nconst LOUPE = 110;\nconst LOUPE_DETAIL = 2.2;\n// The loupe trails the cursor with this time constant, in seconds.\nconst LOUPE_LAG = 0.08;\n// Paper texture: the faintest tone printed everywhere.\nconst PAPER = 0.025;\nconst INTRO_MS = 900;\n\n/** How much ink (0 to 1) a point takes, in px. */\ntype Field = (x: number, y: number) => number;\n\n/**\n * A sphere lit from the upper left, with a soft shadow on the ground. With\n * `light` ink (a dark page) the lit side is printed instead of the shade.\n */\nfunction sphereField(width: number, height: number, light: boolean): Field {\n  // Matches the @3xl breakpoint: beside the copy when wide, above it when not.\n  const wide = width >= 768;\n  const r = wide\n    ? Math.min(width, height) * 0.34\n    : Math.min(width * 0.34, height * 0.2);\n  const cx = wide ? width * 0.7 : width * 0.54;\n  const cy = height * (wide ? 0.48 : 0.27);\n  const sun = { x: -0.55, y: -0.6, z: 0.58 };\n  const shadow = {\n    x: cx + r * 0.35,\n    y: cy + r * 1.02,\n    rx: r * 1.15,\n    ry: r * 0.24,\n  };\n  return (x, y) => {\n    const nx = (x - cx) / r;\n    const ny = (y - cy) / r;\n    const d2 = nx * nx + ny * ny;\n    if (d2 < 1) {\n      const nz = Math.sqrt(1 - d2);\n      const lambert = Math.max(nx * sun.x + ny * sun.y + nz * sun.z, 0);\n      const brightness = 0.12 + 0.88 * lambert;\n      return Math.max(light ? brightness : 1 - brightness, PAPER);\n    }\n    if (light) {\n      return PAPER;\n    }\n    const sx = (x - shadow.x) / shadow.rx;\n    const sy = (y - shadow.y) / shadow.ry;\n    const s = 1 - Math.sqrt(sx * sx + sy * sy);\n    return s > 0 ? Math.max(PAPER, 0.55 * s * s) : PAPER;\n  };\n}\n\n/**\n * An image drawn to cover the box, sampled for its darkness, or for its\n * brightness when printing light ink on a dark page.\n */\nfunction imageField(\n  image: HTMLImageElement,\n  width: number,\n  height: number,\n  light: boolean\n): Field | null {\n  const scale = 0.5;\n  const w = Math.max(1, Math.round(width * scale));\n  const h = Math.max(1, Math.round(height * scale));\n  const ctx = document\n    .createElement(\"canvas\")\n    .getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) {\n    return null;\n  }\n  ctx.canvas.width = w;\n  ctx.canvas.height = h;\n  const fit = Math.max(w / image.naturalWidth, h / image.naturalHeight);\n  const dw = image.naturalWidth * fit;\n  const dh = image.naturalHeight * fit;\n  ctx.drawImage(image, (w - dw) / 2, (h - dh) / 2, dw, dh);\n  let data: Uint8ClampedArray;\n  try {\n    data = ctx.getImageData(0, 0, w, h).data;\n  } catch {\n    // A cross-origin image without CORS can't be read.\n    return null;\n  }\n  return (x, y) => {\n    const i = Math.min(Math.max(Math.round(x * scale), 0), w - 1);\n    const j = Math.min(Math.max(Math.round(y * scale), 0), h - 1);\n    const k = (j * w + i) * 4;\n    const lum =\n      (0.2126 * data[k] + 0.7152 * data[k + 1] + 0.0722 * data[k + 2]) / 255;\n    return Math.max(light ? lum : 1 - lum, PAPER);\n  };\n}\n\n/**\n * Add a rotated grid of dots to the current path, sized by `field`.\n * `grow` scales each dot (0 to 1) for the intro.\n */\nfunction screen(\n  ctx: CanvasRenderingContext2D,\n  field: Field,\n  options: {\n    width: number;\n    height: number;\n    cell: number;\n    angle: number;\n    grow?: (x: number, y: number) => number;\n  }\n) {\n  const { width, height, cell, angle, grow } = options;\n  const c = Math.cos(angle);\n  const s = Math.sin(angle);\n  const reach = Math.hypot(width, height) / 2 + cell;\n  const n = Math.ceil(reach / cell);\n  const ox = width / 2;\n  const oy = height / 2;\n  const max = cell * 0.5 * 1.18;\n  for (let j = -n; j <= n; j++) {\n    for (let i = -n; i <= n; i++) {\n      const x = ox + (i * c - j * s) * cell;\n      const y = oy + (i * s + j * c) * cell;\n      if (x < -cell || y < -cell || x > width + cell || y > height + cell) {\n        continue;\n      }\n      const r = max * Math.sqrt(field(x, y)) * (grow ? grow(x, y) : 1);\n      if (r > 0.25) {\n        ctx.moveTo(x + r, y);\n        ctx.arc(x, y, r, 0, Math.PI * 2);\n      }\n    }\n  }\n}\n\nexport default function HalftoneBackground({\n  className,\n  children,\n  src,\n  cell = 9,\n  angle = 45,\n  loupe = true,\n}: HalftoneBackgroundProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const inkRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!(root && canvas && ctx && inkRef.current)) {\n      return;\n    }\n    const inkEl = inkRef.current;\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const pitch = Math.max(cell, 4);\n    const theta = (angle * Math.PI) / 180;\n\n    // The full print, drawn once per size or theme, then copied each frame.\n    const base = document.createElement(\"canvas\");\n    const baseCtx = base.getContext(\"2d\");\n    const probe = document\n      .createElement(\"canvas\")\n      .getContext(\"2d\", { willReadFrequently: true });\n    if (!(baseCtx && probe)) {\n      return;\n    }\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let field: Field | null = null;\n    let image: HTMLImageElement | null = null;\n    let ink = \"\";\n    let invert = false;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let introStart = reduceMotion ? -1 : 0;\n    const lens = { x: 0, y: 0, tx: 0, ty: 0, r: 0, target: 0, placed: false };\n\n    const readTheme = () => {\n      const color = getComputedStyle(inkEl).color;\n      // Light ink means a dark page: print the light, not the shadow, so\n      // the picture still reads the right way round. Read through a 1px\n      // canvas, which understands any CSS color, oklch included.\n      probe.clearRect(0, 0, 1, 1);\n      probe.fillStyle = color;\n      probe.fillRect(0, 0, 1, 1);\n      const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;\n      const light = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 > 0.5;\n      const key = `${color}|${light}`;\n      if (key === ink) {\n        return false;\n      }\n      ink = key;\n      invert = light;\n      return true;\n    };\n\n    const introGrow = (now: number) => {\n      if (introStart === 0) {\n        // Waiting to scroll into view: nothing printed yet.\n        return () => 0;\n      }\n      if (introStart < 0) {\n        return undefined;\n      }\n      const t = (now - introStart) / INTRO_MS;\n      // A diagonal sweep, each dot easing out as the front passes it.\n      return (x: number, y: number) => {\n        const front = t * 1.5 - (x + y) / (width + height);\n        const k = Math.min(Math.max(front * 3, 0), 1);\n        return 1 - (1 - k) ** 3;\n      };\n    };\n\n    const renderBase = (now = performance.now()) => {\n      if (!field) {\n        return;\n      }\n      baseCtx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      baseCtx.clearRect(0, 0, width, height);\n      baseCtx.fillStyle = getComputedStyle(inkEl).color;\n      baseCtx.beginPath();\n      screen(baseCtx, field, {\n        width,\n        height,\n        cell: pitch,\n        angle: theta,\n        grow: introGrow(now),\n      });\n      baseCtx.fill();\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(base, 0, 0);\n      if (!field || lens.r < 1) {\n        return;\n      }\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      // Under the loupe: clear the coarse print, then print finer.\n      ctx.save();\n      ctx.beginPath();\n      ctx.arc(lens.x, lens.y, lens.r, 0, Math.PI * 2);\n      ctx.clip();\n      ctx.clearRect(lens.x - lens.r, lens.y - lens.r, lens.r * 2, lens.r * 2);\n      ctx.fillStyle = getComputedStyle(inkEl).color;\n      ctx.beginPath();\n      const size = lens.r * 2 + pitch;\n      const fine = pitch / LOUPE_DETAIL;\n      ctx.translate(lens.x - size / 2, lens.y - size / 2);\n      const local: Field = (x, y) =>\n        (field as Field)(x + lens.x - size / 2, y + lens.y - size / 2);\n      screen(ctx, local, {\n        width: size,\n        height: size,\n        cell: fine,\n        angle: theta,\n      });\n      ctx.fill();\n      ctx.restore();\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.beginPath();\n      ctx.arc(lens.x, lens.y, lens.r - 0.5, 0, Math.PI * 2);\n      ctx.strokeStyle = getComputedStyle(inkEl).color;\n      ctx.globalAlpha = 0.25;\n      ctx.lineWidth = 1;\n      ctx.stroke();\n      ctx.globalAlpha = 1;\n    };\n\n    const build = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        return;\n      }\n      width = rect.width;\n      height = 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      base.width = canvas.width;\n      base.height = canvas.height;\n      field =\n        (image?.complete && image.naturalWidth > 0\n          ? imageField(image, width, height, invert)\n          : null) ?? sphereField(width, height, invert);\n      renderBase();\n      draw();\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!visible) {\n        return;\n      }\n      const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60;\n      last = now;\n      let busy = false;\n      if (introStart > 0) {\n        renderBase(now);\n        if (now - introStart > INTRO_MS * 1.6) {\n          introStart = -1;\n          renderBase(now);\n        } else {\n          busy = true;\n        }\n      }\n      const k = 1 - Math.exp(-dt / LOUPE_LAG);\n      lens.x += (lens.tx - lens.x) * k;\n      lens.y += (lens.ty - lens.y) * k;\n      // The loupe opens and closes quickly, and never from nothing.\n      lens.r += (lens.target - lens.r) * (1 - Math.exp(-dt / 0.06));\n      if (lens.target === 0 && lens.r < LOUPE * 0.4) {\n        lens.r = 0;\n      }\n      busy ||=\n        Math.abs(lens.tx - lens.x) + Math.abs(lens.ty - lens.y) > 0.3 ||\n        Math.abs(lens.target - lens.r) > 0.3;\n      draw();\n      if (busy) {\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const start = () => {\n      if (!raf && visible) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    readTheme();\n    canvas.style.opacity = \"1\";\n\n    if (src) {\n      image = new Image();\n      image.crossOrigin = \"anonymous\";\n      image.decoding = \"async\";\n      image.onload = () => {\n        build();\n        start();\n      };\n      image.src = src;\n    }\n    build();\n\n    let size = \"\";\n    const resizeObserver = new ResizeObserver(() => {\n      const next = `${canvas.clientWidth}x${canvas.clientHeight}`;\n      if (next !== size) {\n        size = next;\n        build();\n      }\n    });\n    resizeObserver.observe(canvas);\n\n    const intersection = new IntersectionObserver(\n      ([entry]) => {\n        visible =\n          entry.isIntersecting && document.visibilityState === \"visible\";\n        if (visible && introStart === 0 && entry.intersectionRatio > 0.3) {\n          introStart = performance.now();\n        }\n        start();\n      },\n      { threshold: [0, 0.3] }\n    );\n    intersection.observe(root);\n    const onVisibility = () => {\n      visible = document.visibilityState === \"visible\";\n      start();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    // Follow light and dark mode without asking the app for a theme.\n    const themeTimer = window.setInterval(() => {\n      if (readTheme()) {\n        build();\n      }\n    }, 500);\n\n    const place = (event: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      lens.tx = event.clientX - rect.left;\n      lens.ty = event.clientY - rect.top;\n      if (!lens.placed || reduceMotion) {\n        lens.x = lens.tx;\n        lens.y = lens.ty;\n        lens.placed = true;\n      }\n      lens.target = LOUPE;\n      if (reduceMotion) {\n        lens.r = LOUPE;\n      }\n      start();\n    };\n    const onMove = (event: PointerEvent) => {\n      if (event.pointerType === \"mouse\" || event.buttons) {\n        place(event);\n      }\n    };\n    const onHide = () => {\n      lens.target = 0;\n      lens.placed = false;\n      if (reduceMotion) {\n        lens.r = 0;\n      }\n      start();\n    };\n    const onUp = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\") {\n        onHide();\n      }\n    };\n    if (loupe) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerdown\", place);\n      root.addEventListener(\"pointerup\", onUp);\n      root.addEventListener(\"pointercancel\", onHide);\n      root.addEventListener(\"pointerleave\", onHide);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      resizeObserver.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.clearInterval(themeTimer);\n      if (image) {\n        image.onload = null;\n      }\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", place);\n      root.removeEventListener(\"pointerup\", onUp);\n      root.removeEventListener(\"pointercancel\", onHide);\n      root.removeEventListener(\"pointerleave\", onHide);\n    };\n  }, [src, cell, angle, loupe]);\n\n  return (\n    <div\n      className={cn(\n        \"@container relative flex min-h-[560px] w-full touch-pan-y items-center overflow-hidden bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n      ref={rootRef}\n    >\n      {/* Ink color for the canvas, so the dots follow the theme. */}\n      <span className=\"hidden text-zinc-900 dark:text-zinc-300\" ref={inkRef} />\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        <canvas\n          className=\"h-full w-full opacity-0 transition-opacity duration-300 ease-out\"\n          ref={canvasRef}\n        />\n      </div>\n      {children === undefined ? <DefaultContent /> : children}\n    </div>\n  );\n}\n\nfunction DefaultContent() {\n  return (\n    <div className=\"pointer-events-none relative @3xl:mx-0 mx-auto flex max-w-md flex-col @3xl:items-start items-center gap-4 self-end @3xl:self-center px-6 @3xl:pb-0 pb-16 @3xl:pl-16 @3xl:text-left text-center\">\n      <h1 className=\"font-semibold @3xl:text-5xl text-4xl tracking-tight\">\n        Printed in dots\n      </h1>\n      <p className=\"max-w-sm text-base text-zinc-600 dark:text-zinc-400\">\n        Any picture as a halftone screen. Hover or drag across it to look closer\n        through the loupe.\n      </p>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/halftone-background.tsx",
      "target": "components/kokonutui/halftone-background.tsx"
    }
  ]
}