<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
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | required | The image URL to dither. Can be any same-origin or CORS-enabled URL. |
alt | string | required | Passed through as aria-label on the <canvas>. |
width | number | source intrinsic | Output bitmap width. The canvas renders at exactly this width. See Sizing model. |
height | number | source intrinsic | Output bitmap height. |
pixelSize | number | 1 | Cell size in output pixels. Each dither cell becomes a pixelSize × pixelSize square. |
algorithm | DitherAlgorithm | 'Floyd-Steinberg' | Which algorithm to use. |
threshold | number | 128 | Threshold (0–255), only used when algorithm="Threshold". |
palette | string | '#000000,#FFFFFF' | Comma-separated hex colors. Any palette length supported. |
brightness | number | 0 | Brightness adjustment (-100–100), applied before grayscale. |
contrast | number | 0 | Contrast adjustment (-100–100), applied before grayscale. |
grayscale | boolean | auto | Force grayscale on/off. undefined (default) auto-decides based on palette size. |
onLoad | (info) => void | — | Fired 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 / pixelSizeprocessHeight = 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:
- Bigger
pixelSizeis faster. The dither runs on1/N²as many pixels. - The output is always 1:1 with the bitmap. No
style="max-width: 100%; height: auto"— the canvas attribute IS the rendered size. - 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:
- Loads and decodes the source image once per
src. Fetches withcrossOrigin="Anonymous", keeps the decodedHTMLImageElementin a ref. - Resizes the source to process resolution on the main thread.
A single
drawImagecall into aprocessWidth × processHeightoffscreen canvas. The smallImageDatathat comes out is what crosses the worker boundary — never the full source bitmap. - 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. - Paints to the display canvas. The result is
putImageData'd onto a temp canvas, thendrawImage'd onto the display canvas withimageSmoothingEnabled = falseso 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
altis passed asaria-labelon 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.