ditherkit
@ditherkit/react

<DitheredImage />

Component reference for the client-side dithering component.

The single component exported by @ditherkit/react. Renders a dithered image to a <canvas> using a shared Web Worker for processing.

import { DitheredImage, type DitherAlgorithm } from '@ditherkit/react'

Props

PropTypeDefaultDescription
srcstringrequiredThe image URL to dither. Can be any same-origin or CORS-enabled URL.
altstringrequiredPassed through as aria-label on the <canvas>.
widthnumbersource intrinsicOutput bitmap width. The canvas renders at exactly this width. See Sizing model.
heightnumbersource intrinsicOutput bitmap height.
pixelSizenumber1Cell size in output pixels. Each dither cell becomes a pixelSize × pixelSize square.
algorithmDitherAlgorithm'Floyd-Steinberg'Which algorithm to use.
thresholdnumber128Threshold (0–255), only used when algorithm="Threshold".
palettestring'#000000,#FFFFFF'Comma-separated hex colors. Any palette length supported.
brightnessnumber0Brightness adjustment (-100100), applied before grayscale.
contrastnumber0Contrast adjustment (-100100), applied before grayscale.
grayscalebooleanautoForce grayscale on/off. undefined (default) auto-decides based on palette size.
onLoad(info) => voidFired once per src after the source decodes. Receives { naturalWidth, naturalHeight }.

The component does not add any CSS to the <canvas>. The element renders at its bitmap size — wrap it in your own layout container if the bitmap is larger than the surrounding column and you want clipping, scrolling, or scale-to-fit.

Sizing model

<DitheredImage /> operates on a single principle:

The user declares the output bitmap size (width × height) and the cell size in output pixels (pixelSize). Everything else is derived. The canvas renders at exactly the output size, so what you see on screen is what the bitmap actually contains — no CSS interpolation, no surprises.

From width, height, and pixelSize the component computes:

  • processWidth = width / pixelSize
  • processHeight = height / pixelSize

The dither algorithm runs at the (smaller) process resolution, and the result is nearest-neighbour upscaled back to width × height on the display canvas. So pixelSize=4 produces an output where every dither cell is a crisp 4×4 square — no blur, no fudge.

This means three things in practice:

  1. Bigger pixelSize is faster. The dither runs on 1/N² as many pixels.
  2. The output is always 1:1 with the bitmap. No style="max-width: 100%; height: auto" — the canvas attribute IS the rendered size.
  3. You're responsible for the layout. If your output is bigger than the surrounding column, the canvas overflows. Wrap it.

Width and height rules

  • Both passed: used directly. The canvas is pre-sized from first paint, no layout shift.
  • Only one passed: the missing dimension is derived from the source's intrinsic aspect ratio after the image loads.
  • Neither passed: the output defaults to the source's intrinsic dimensions, also after the image loads.

The "after the image loads" cases mean the canvas starts empty until the source decodes — pass both dimensions if you need a stable layout on first paint.

Cell-size snapping

If width × height aren't exact multiples of pixelSize, the dimensions are snapped down to the nearest multiple. A request for width=1000, height=750, pixelSize=3 becomes a 999×750 output (333×250 process resolution × 3). The snapped values are what the canvas actually renders at — they match the cache key, the readout, and any other place that displays a dimension.

In dev builds, a console warning fires when snapping changes a value you supplied explicitly:

[DitheredImage] requested 1000×750 with pixelSize=3 → snapped to 999×750
(process 333×250). Output dimensions are always rounded down to a
multiple of pixelSize.

Examples

// Render at the source's natural size, full fidelity (one cell per pixel).
<DitheredImage src="/p.jpg" alt="..." />

// Render at exactly 1200×900, regardless of source size.
<DitheredImage src="/p.jpg" alt="..." width={1200} height={900} />

// Render at 1200 wide, height auto from source aspect.
<DitheredImage src="/p.jpg" alt="..." width={1200} />

// Pixel-art look: 1200×900 with 4×4 cells = a 300×225 dither painted
// onto a 1200×900 bitmap with crisp nearest-neighbour squares.
<DitheredImage src="/p.jpg" alt="..." width={1200} height={900} pixelSize={4} />

Behaviour

The component does three things:

  1. Loads and decodes the source image once per src. Fetches with crossOrigin="Anonymous", keeps the decoded HTMLImageElement in a ref.
  2. Resizes the source to process resolution on the main thread. A single drawImage call into a processWidth × processHeight offscreen canvas. The small ImageData that comes out is what crosses the worker boundary — never the full source bitmap.
  3. Sends the small buffer to the shared Web Worker. The worker applies brightness/contrast, optionally grayscales, runs the dither algorithm, and posts back a same-sized ImageData.
  4. Paints to the display canvas. The result is putImageData'd onto a temp canvas, then drawImage'd onto the display canvas with imageSmoothingEnabled = false so the upscale is exact nearest-neighbour.

This split means dragging a slider doesn't trigger a network request or re-instantiate the worker — and because the worker only ever sees the small process buffer, it's also significantly faster on large sources than the previous "send the full bitmap, downsample inside the worker" path.

Accessibility

  • alt is passed as aria-label on the <canvas> — screen readers will announce it when the element receives focus.
  • No focus handling or keyboard interaction is added — the canvas is a static output, not an interactive control. If you build interactive wrappers (e.g. zoom, pan), handle keyboard/focus in the wrapper.

Error states

If the image fails to load or the worker throws, the component renders a small red "error" label centred in the canvas. The real error is also logged to the console with the prefix [DitheredImage]. In production builds the user-facing label stays generic to avoid leaking details.

Error recovery is automatic — once src or the control props change, the component retries from scratch.

Example

'use client'

import { DitheredImage, type DitherAlgorithm } from '@ditherkit/react'
import { useState } from 'react'

export function DitherPlayground() {
  const [algorithm, setAlgorithm] = useState<DitherAlgorithm>('Atkinson')
  const [brightness, setBrightness] = useState(0)
  const [pixelSize, setPixelSize] = useState(2)

  return (
    <div>
      <DitheredImage
        src="/sample.jpg"
        alt="Interactive dither example"
        width={1200}
        height={900}
        algorithm={algorithm}
        brightness={brightness}
        pixelSize={pixelSize}
      />

      <select
        value={algorithm}
        onChange={(e) => setAlgorithm(e.target.value as DitherAlgorithm)}
      >
        <option value="Floyd-Steinberg">Floyd-Steinberg</option>
        <option value="Atkinson">Atkinson</option>
        <option value="Threshold">Threshold</option>
      </select>

      <input
        type="range"
        min={-100}
        max={100}
        value={brightness}
        onChange={(e) => setBrightness(Number(e.target.value))}
      />

      <input
        type="range"
        min={1}
        max={16}
        value={pixelSize}
        onChange={(e) => setPixelSize(Number(e.target.value))}
      />
    </div>
  )
}

Dragging the slider does not refetch the image or recreate the worker — see Web Workers for why.

On this page