ditherkit
@ditherkit/react

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:

  1. We measure the image before mounting <DitheredImage />. This is so we can pass width and height for zero layout shift. If you don't care about shift, you can skip the measurement and render immediately.
  2. 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.

On this page