# Halftone Image

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.

React component from Creative UI. Docs: https://ui.krutikraut.com/docs/halftone-image

## Installation

```bash
npx shadcn@latest add @creative-ui/halftone-image
```

On an older shadcn CLI, use the URL: `npx shadcn@latest add https://ui.krutikraut.com/r/halftone-image.json`

## Dependencies

- `utils` (shadcn)

## Usage

```tsx
import { HalftoneImage } from "@/components/halftone-image"

export function Hero() {
  return (
    <HalftoneImage
      src="https://images.unsplash.com/photo-1566054757965-8c4085344c96?w=900&q=80&auto=format&fit=crop"
      alt="Marble statue"
      dotSize={6}
      angle={45}
      color="#1d3fb0"
      className="aspect-[4/5] w-80 rounded-xl"
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` (required) | `string` | - | Image URL. Cross-origin images must be served with CORS headers, or the canvas cannot read them. |
| `alt` | `string` | `""` | Accessible description. Leave empty for decorative images. |
| `dotSize` | `number` | `6` | Screen pitch in CSS pixels. Smaller is finer. |
| `angle` | `number` | `45` | Screen angle in degrees. 45 is the classic print angle. |
| `shape` | `"dot" \| "line" \| "square"` | `"dot"` | Mark used for the screen. |
| `color` | `string` | `"currentColor"` | Ink colour. Any CSS colour. `currentColor` follows the text colour and re-inks when the theme changes. |
| `background` | `string` | `"transparent"` | Paper colour behind the marks. |
| `invert` | `boolean` | `false` | Screen the highlights instead of the shadows, for light ink on dark paper. |
| `contrast` | `number` | `1` | Tone curve steepness, roughly 0.5 to 2. |
| `gain` | `number` | `1.15` | Largest mark relative to the cell. 1 means marks just touch; above 1 lets shadows close up. |
| `fit` | `"cover" \| "contain"` | `"cover"` | How the source fills the canvas. |
| `crossOrigin` | `"anonymous" \| "use-credentials"` | `"anonymous"` | Pass `undefined` for same-origin images that must not send a CORS request. |
| `onRendered` | `() => void` | - | Called after each draw. |
| `children` | `React.ReactNode` | - | Content layered over the halftone, such as captions or badges. |

Also accepts all native `<div>` props.

## Also exported

- `drawHalftone(canvas, image, width, height, options?)`: The renderer on its own, with no React. Draws an image, canvas, video or ImageBitmap onto a canvas, so you can export a PNG with `canvas.toBlob()`. Takes the same options as the component; `color` defaults to `#111` here.

## Notes

- Size it like an image: give it a width with `className`. Without a height or aspect ratio it adopts the source image's aspect ratio once loaded.
- Redraws on resize and caps the device pixel ratio at 2 to keep large canvases fast.
- The canvas gets `role="img"` and your `alt` as its label, or is hidden from assistive tech when `alt` is empty.

## Source

### registry/new-york/blocks/halftone-image/halftone-image.tsx

```tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type HalftoneShape = "dot" | "line" | "square"

export interface HalftoneOptions {
  /** Screen pitch in CSS pixels. Smaller is finer. Default 6. */
  dotSize?: number
  /** Screen angle in degrees. 45 is the classic print angle. Default 45. */
  angle?: number
  /** Ink colour. Any CSS colour. Default "#111". */
  color?: string
  /** Paper colour behind the dots. Default "transparent". */
  background?: string
  /** Dot, line or square screen. Default "dot". */
  shape?: HalftoneShape
  /** Screen the highlights instead of the shadows (for light ink on dark paper). Default false. */
  invert?: boolean
  /** Tone curve steepness, roughly 0.5 to 2. Default 1. */
  contrast?: number
  /** Largest mark relative to the cell. 1 means marks just touch; above 1 lets shadows close up. Default 1.15. */
  gain?: number
  /** How the source fills the canvas. Default "cover". */
  fit?: "cover" | "contain"
}

type Source = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap

function sourceSize(image: Source): [number, number] {
  if ("naturalWidth" in image && image.naturalWidth) return [image.naturalWidth, image.naturalHeight]
  if ("videoWidth" in image && image.videoWidth) return [image.videoWidth, image.videoHeight]
  return [image.width, image.height]
}

/**
 * Draws `image` onto `canvas` as a halftone screen at `width` x `height` CSS pixels.
 * Pure and framework-free, so it can also be used to export a PNG via canvas.toBlob().
 */
export function drawHalftone(
  canvas: HTMLCanvasElement,
  image: Source,
  width: number,
  height: number,
  options: HalftoneOptions = {}
) {
  const steps = halftoneSteps(canvas, image, width, height, options)
  while (!steps.next().done) {
    // All at once. HalftoneImage spreads the same steps over several short tasks.
  }
}

/** drawHalftone as a generator that pauses after each row of the screen. */
function* halftoneSteps(
  canvas: HTMLCanvasElement,
  image: Source,
  width: number,
  height: number,
  options: HalftoneOptions
): Generator<void, void, void> {
  const {
    dotSize = 6,
    angle = 45,
    color = "#111",
    background = "transparent",
    shape = "dot",
    invert = false,
    contrast = 1,
    gain = 1.15,
    fit = "cover",
  } = options

  const cell = Math.max(2, dotSize)
  const dpr = typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 2) : 1
  canvas.width = Math.max(1, Math.round(width * dpr))
  canvas.height = Math.max(1, Math.round(height * dpr))
  const ctx = canvas.getContext("2d")
  if (!ctx) return
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  ctx.clearRect(0, 0, width, height)
  if (background !== "transparent") {
    ctx.fillStyle = background
    ctx.fillRect(0, 0, width, height)
  }

  // Sample buffer at roughly two samples per cell. drawImage averages the pixels for us.
  const k = Math.max(1, cell / 2)
  const sw = Math.max(1, Math.ceil(width / k))
  const sh = Math.max(1, Math.ceil(height / k))
  const off = document.createElement("canvas")
  off.width = sw
  off.height = sh
  const octx = off.getContext("2d", { willReadFrequently: true })
  if (!octx) return
  const [iw, ih] = sourceSize(image)
  if (!iw || !ih) return
  const scale = fit === "cover" ? Math.max(sw / iw, sh / ih) : Math.min(sw / iw, sh / ih)
  const dw = iw * scale
  const dh = ih * scale
  octx.drawImage(image, (sw - dw) / 2, (sh - dh) / 2, dw, dh)
  const data = octx.getImageData(0, 0, sw, sh).data

  const rad = (angle * Math.PI) / 180
  const cos = Math.cos(rad)
  const sin = Math.sin(rad)
  const cx = width / 2
  const cy = height / 2
  const half = Math.hypot(width, height) / 2 + cell

  ctx.fillStyle = color
  for (let v = -half; v <= half; v += cell) {
    for (let u = -half; u <= half; u += cell) {
      const x = cx + u * cos - v * sin
      const y = cy + u * sin + v * cos
      if (x < -cell || y < -cell || x > width + cell || y > height + cell) continue

      const sx = Math.min(sw - 1, Math.max(0, Math.floor(x / k)))
      const sy = Math.min(sh - 1, Math.max(0, Math.floor(y / k)))
      const i = (sy * sw + sx) * 4
      const alpha = data[i + 3] / 255
      const lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255
      let d = invert ? lum : 1 - lum
      d = Math.min(1, Math.max(0, (d - 0.5) * contrast + 0.5)) * alpha
      if (d < 0.02) continue

      if (shape === "dot") {
        // Area, not radius, should track darkness.
        const r = (Math.sqrt(d) * cell * gain) / 2
        ctx.beginPath()
        ctx.arc(x, y, r, 0, Math.PI * 2)
        ctx.fill()
      } else {
        ctx.save()
        ctx.translate(x, y)
        ctx.rotate(rad)
        if (shape === "square") {
          const s = Math.sqrt(d) * cell * gain
          ctx.fillRect(-s / 2, -s / 2, s, s)
        } else {
          const t = d * cell * gain
          ctx.fillRect(-cell / 2 - 0.3, -t / 2, cell + 0.6, t)
        }
        ctx.restore()
      }
    }
    yield
  }
}

export interface HalftoneImageProps
  extends HalftoneOptions,
    Omit<React.ComponentProps<"div">, "color" | "children"> {
  /** Image URL. Cross-origin images must be served with CORS headers, or the canvas cannot read them. */
  src: string
  /** Accessible description. Leave empty for decorative images. */
  alt?: string
  /** Default "anonymous". Pass undefined for same-origin images that must not send a CORS request. */
  crossOrigin?: "anonymous" | "use-credentials"
  /** Called after each draw. */
  onRendered?: () => void
  /** Content layered over the halftone (captions, badges). */
  children?: React.ReactNode
}

/**
 * Renders any image as a print-style halftone screen on a canvas. Size it like an
 * image: give it a width (and optionally a height or aspect ratio) with className.
 * Without an explicit height it adopts the source image's aspect ratio.
 */
export function HalftoneImage({
  src,
  alt = "",
  crossOrigin = "anonymous",
  dotSize = 6,
  angle = 45,
  color = "currentColor",
  background = "transparent",
  shape = "dot",
  invert = false,
  contrast = 1,
  gain = 1.15,
  fit = "cover",
  onRendered,
  className,
  style,
  children,
  ...props
}: HalftoneImageProps) {
  const ref = React.useRef<HTMLDivElement>(null)
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const renderedRef = React.useRef(onRendered)
  renderedRef.current = onRendered

  const [image, setImage] = React.useState<HTMLImageElement | null>(null)
  const [ratio, setRatio] = React.useState<number | undefined>(undefined)

  React.useEffect(() => {
    let cancelled = false
    const img = new Image()
    if (crossOrigin) img.crossOrigin = crossOrigin
    img.decoding = "async"
    img.onload = () => {
      if (cancelled) return
      setImage(img)
      setRatio(img.naturalWidth / img.naturalHeight)
    }
    img.onerror = () => {
      if (!cancelled) setImage(null)
    }
    img.src = src
    return () => {
      cancelled = true
    }
  }, [src, crossOrigin])

  React.useEffect(() => {
    const el = ref.current
    const canvas = canvasRef.current
    if (!el || !canvas || !image) return

    let frame = 0
    let slice = 0
    const render = () => {
      cancelAnimationFrame(frame)
      clearTimeout(slice)
      frame = requestAnimationFrame(() => {
        const { width, height } = el.getBoundingClientRect()
        if (!width || !height) return
        const ink = color === "currentColor" ? getComputedStyle(el).color : color
        const paper = background === "currentColor" ? getComputedStyle(el).color : background
        // A large screen is tens of thousands of dots. Draw them off screen in ~8ms slices so the
        // page can paint and respond in between, then swap the finished image in at once.
        const off = document.createElement("canvas")
        const steps = halftoneSteps(off, image, width, height, {
          dotSize,
          angle,
          color: ink,
          background: paper,
          shape,
          invert,
          contrast,
          gain,
          fit,
        })
        const pump = () => {
          const end = performance.now() + 8
          try {
            while (performance.now() < end) {
              if (!steps.next().done) continue
              canvas.width = off.width
              canvas.height = off.height
              canvas.getContext("2d")?.drawImage(off, 0, 0)
              renderedRef.current?.()
              return
            }
          } catch (error) {
            console.warn(
              "HalftoneImage: could not read the image pixels. Serve it from the same origin or with CORS headers.",
              error
            )
            return
          }
          slice = window.setTimeout(pump)
        }
        pump()
      })
    }

    render()
    const ro = new ResizeObserver(render)
    ro.observe(el)
    // Re-ink when the theme flips and the colour is inherited.
    const inherits = color === "currentColor" || background === "currentColor"
    const mo = inherits ? new MutationObserver(render) : null
    mo?.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    })

    return () => {
      ro.disconnect()
      mo?.disconnect()
      cancelAnimationFrame(frame)
      clearTimeout(slice)
    }
  }, [image, dotSize, angle, color, background, shape, invert, contrast, gain, fit])

  return (
    <div
      ref={ref}
      data-slot="halftone-image"
      className={cn("relative overflow-hidden", className)}
      style={{ aspectRatio: ratio, ...style }}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="absolute inset-0 size-full"
        role={alt ? "img" : undefined}
        aria-label={alt || undefined}
        aria-hidden={alt ? undefined : true}
      />
      {children}
    </div>
  )
}
```
