Recipes
Safari filters, cross-origin images, dynamic palettes, and other practical patterns.
Short, copy-pasteable patterns for common <DitheredImage />
situations.
Cross-origin images
The component sets crossOrigin="Anonymous" on the internal <img>
element, so cross-origin images work as long as the image server
sends the right CORS headers:
Access-Control-Allow-Origin: *(Or a specific origin allowlist including your site.)
If the server doesn't send CORS headers, the image will load visually
but ctx.getImageData() will throw a SecurityError when the
component tries to extract pixels — dithering fails and you'll see
the "error" fallback.
Workaround for stubborn image hosts: proxy the image through your
own server. If you're on Next.js, @ditherkit/next does
this automatically via the /dithered/url/* route — you get the CORS
proxy and the dithering in one step.
Dynamic palettes
Palettes are a comma-separated string of hex colors, so you can build them at runtime:
'use client'
import { DitheredImage } from '@ditherkit/react'
import { useState } from 'react'
const PRESETS = {
'Black & white': '#000000,#ffffff',
'Game Boy': '#0f380f,#306230,#8bac0f,#9bbc0f',
CGA: '#000000,#55ffff,#ff55ff,#ffffff',
} as const
export function PalettePicker() {
const [preset, setPreset] = useState<keyof typeof PRESETS>('Game Boy')
return (
<div>
<DitheredImage
src="/portrait.jpg"
alt={`Portrait with ${preset} palette`}
width={400}
height={600}
palette={PRESETS[preset]}
/>
<select value={preset} onChange={(e) => setPreset(e.target.value as keyof typeof PRESETS)}>
{Object.keys(PRESETS).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</div>
)
}Palette changes use the same worker request path as any other control change — no image refetch, no worker restart.
Building a palette programmatically
Palettes accept arbitrary lengths. Generate one from user input:
function buildPalette(colors: string[]): string {
return colors.join(',')
}
const palette = buildPalette(['#0a0a0a', '#4b4b4b', '#a0a0a0', '#f0f0f0'])
<DitheredImage src="..." alt="..." palette={palette} />You can also sample palettes from an image, use @ditherkit/core's
findClosestColor to visualise them,
or load them from a preset file — the component doesn't care where
the string comes from.
Reacting to brightness/contrast without layout shift
Dragging brightness and contrast sliders should feel instant. Because the image data is cached in the component's state, the per-tick work is just a worker post and a canvas paint — no fetch, no decode.
The only thing you need to worry about is not thrashing React with
every mousemove. On modern browsers the built-in slider change
event already fires at a reasonable rate; if you find yourself on a
slow machine or processing 4K images, debounce in your parent
component:
import { useDeferredValue } from 'react'
function SlowMachineDemo() {
const [rawBrightness, setRawBrightness] = useState(0)
const brightness = useDeferredValue(rawBrightness)
return (
<>
<DitheredImage src="..." alt="..." brightness={brightness} />
<input
type="range"
value={rawBrightness}
onChange={(e) => setRawBrightness(Number(e.target.value))}
/>
</>
)
}useDeferredValue lets React render the slider instantly while the
heavy component re-render waits for a quieter moment. The worker
queue drops any superseded requests via AbortSignal, so the worker
does no wasted work once the user stops dragging.
User-uploaded images
Accept a file, turn it into a blob URL, pass it to the component:
'use client'
import { DitheredImage } from '@ditherkit/react'
import { useState } from 'react'
export function Upload() {
const [src, setSrc] = useState<string | null>(null)
const [dims, setDims] = useState<{ w: number; h: number } | null>(null)
function handleFile(file: File) {
const url = URL.createObjectURL(file)
// Measure first so we can pass width/height to avoid layout shift.
const img = new Image()
img.onload = () => {
setDims({ w: img.width, h: img.height })
setSrc(url)
}
img.src = url
}
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
{src && dims && (
<DitheredImage
src={src}
alt="Uploaded image"
width={dims.w}
height={dims.h}
algorithm="Floyd-Steinberg"
/>
)}
</div>
)
}Two things to notice:
- We measure the image before mounting
<DitheredImage />. This is so we can passwidthandheightfor zero layout shift. If you don't care about shift, you can skip the measurement and render immediately. - Remember to
URL.revokeObjectURL(url)when you're done with the blob — not shown above for brevity, but important in a real app to avoid leaking memory.
Error handling
The component shows a small red "error" label on the canvas and logs
to the console with the prefix [DitheredImage]. You don't need to
write any error boundary code to handle normal failures (bad URL,
CORS miss, worker crash) — they're all absorbed silently with the
fallback render.
If you want to take custom action on error (hide the component,
show your own fallback, log to your analytics), wrap
<DitheredImage /> in an error boundary. The example below uses
react-error-boundary
— install it first if you don't already have it:
pnpm add react-error-boundary'use client'
import { ErrorBoundary } from 'react-error-boundary'
import { DitheredImage } from '@ditherkit/react'
<ErrorBoundary fallback={<div>Image unavailable</div>}>
<DitheredImage src="/p.jpg" alt="..." width={400} height={300} />
</ErrorBoundary>Note: the current implementation catches most errors internally and shows the canvas fallback, so an error boundary only catches genuinely unexpected React-level failures (e.g. invalid props throwing during render). That's the intended behaviour — most users don't want a broken image to crash their component tree.
Safari brightness/contrast fallback
ctx.filter doesn't work in OffscreenCanvas workers in Safari. The
component detects Safari at runtime (via user-agent) and uses
applyBrightness and applyContrast from @ditherkit/core instead.
You don't need to do anything — this is automatic. The output is
pixel-identical to the ctx.filter path; it's just slightly slower
because the pixel loop runs in JavaScript instead of the native
canvas implementation.