ditherkit
@ditherkit/next

<DitheredPicture />

Zero-JS responsive dithered images via <picture> with <source media> — multiple server-dithered variants, no client JavaScript.

A React Server Component that renders a <picture> element with <source media> tags, each pointing to a separately server-dithered variant at a different resolution. No client JavaScript is shipped.

Uses 1x descriptors to suppress device-pixel-ratio upselection and renders at native bitmap resolution — every visible pixel is exactly as the dithering algorithm produced it.

import { DitheredPicture } from '@ditherkit/next'
import hero from '@/images/hero.jpg'

<DitheredPicture
  src={hero}
  alt="Responsive dithered hero"
  algorithm="Floyd-Steinberg"
  pixelSize={2}
  variants={[
    { media: 1024, width: 800 },
    { media: 640,  width: 480 },
    { media: 0,    width: 280 },
  ]}
/>

See the live showcase in the example app for interactive demos of every feature.

When to use DitheredPicture vs DitheredImageSSR

<DitheredImageSSR /><DitheredPicture />
OutputSingle <img> via next/image<picture> with multiple <source>
ResponsiveCSS scaling onlyTrue resolution switching per viewport
Client JSNoneNone
Best forFixed-size images, hero images with CSS scalingGrids, cards, responsive layouts

Props

DitheredPictureProps

PropTypeDefaultDescription
srcStaticImageData | stringrequiredImported image or external URL.
altstringrequiredAlt text for the inner <img>.
variantsPictureVariant[]requiredResponsive variants, widest-viewport-first. See below.
algorithmDitherAlgorithm'Floyd-Steinberg'Default dithering algorithm.
pixelSizenumber2Default cell size in output pixels.
thresholdnumber128Threshold (0–255) for the Threshold algorithm.
palettestring'#000000,#ffffff'Comma-separated hex colors.
brightnessnumber0Brightness adjustment (-100 to 100).
contrastnumber0Contrast adjustment (-100 to 100).
grayscalebooleanautoForce grayscale on/off.
mode'crop' | 'cover''crop'Display mode. See Crop vs Cover.
objectPositionstring'center'CSS object-position for crop origin.
prioritybooleanfalseAdds fetchPriority="high" and loading="eager". Use for LCP images.
aspectRationumber | nullautoWrapper div aspect ratio. null disables the wrapper.
classNamestringCSS class on the wrapper <div>.
styleReact.CSSPropertiesStyles on the wrapper <div>.
imgClassNamestringCSS class on the inner <img>.
imgStyleReact.CSSPropertiesStyles on the inner <img>.
imgRefReact.Ref<HTMLImageElement>Ref forwarded to the inner <img>. See Load detection.

PictureVariant

Each entry in the variants array becomes a <source> element.

FieldTypeDefaultDescription
medianumber | stringrequiredBreakpoint. Numbers become (min-width: Npx). Strings used as-is (em, calc, compound queries).
widthnumberrequiredOutput width in px.
heightnumberautoOutput height. Derived from source aspect ratio if omitted.
algorithmDitherAlgorithmOverride the top-level algorithm for this variant.
pixelSizenumberOverride cell size.
thresholdnumberOverride threshold.
brightnessnumberOverride brightness.
contrastnumberOverride contrast.
palettestringOverride palette.
grayscalebooleanOverride grayscale.

Per-variant overrides let you art-direct each breakpoint — for example, heavy pixelation with boosted contrast on phones, fine detail with Atkinson on desktop:

<DitheredPicture
  src={hero}
  alt="Art-directed"
  algorithm="Floyd-Steinberg"
  pixelSize={2}
  variants={[
    { media: 640, width: 400, pixelSize: 1, algorithm: 'Atkinson' },
    { media: 400, width: 300 },
    { media: 0,   width: 200, pixelSize: 4, contrast: 20 },
  ]}
/>

Sizing guidance

Each variant is active from its media breakpoint up to the next one. Size each variant for the widest viewport where it is active, not the breakpoint value itself:

// ❌ Wrong: sized for the breakpoint value
{ media: 640, width: 200 }  // 200px is too narrow at 900px viewport

// ✅ Right: sized for the top of the active range
{ media: 640, width: 300 }  // covers 640px–next breakpoint

If the variant is narrower than the container, blank space appears (with mode="crop") because object-fit: none won't scale up. The rule: always overshoot — the image should be at least as wide as the container. Excess is cropped.

computeVariants — auto-generate from a layout function

Instead of manually measuring cell widths, pass a containerWidth(viewportPx) function and a maxOvershoot budget:

import { computeVariants, DitheredPicture } from '@ditherkit/next'

// 3-column grid: cell = (viewport - padding - gaps) / 3
const variants = computeVariants({
  containerWidth: (vw) => (Math.min(vw, 860) - 48 - 32) / 3,
  maxOvershoot: 0.3,   // never >30% wider than container
  pixelSize: 2,
  aspectRatio: 4 / 3,
  minViewport: 320,
  maxViewport: 1200,
})

<DitheredPicture src={hero} variants={variants} />

The helper subdivides the viewport range until no variant overshoots the container by more than the threshold. More variants = less cropping between breakpoints = more ISR cache entries.

ComputeVariantsOptions

OptionTypeDefaultDescription
containerWidth(vw: number) => numberrequiredMaps viewport width to expected container width.
maxOvershootnumber0.5Max overshoot ratio (0.3 = 30%).
pixelSizenumber2Cell size for snapping widths.
aspectRationumber4/3For computing variant heights.
minViewportnumber320Narrowest viewport to consider.
maxViewportnumber1440Widest viewport to consider.
maxDepthnumber8Max subdivision depth.

Crop vs Cover

mode controls how the image fills its container:

  • 'crop' (default) — object-fit: none. The image renders at native 1:1 bitmap size. Excess is cropped by the wrapper's overflow: hidden. Every visible pixel is byte-exact.

  • 'cover'object-fit: cover with imageRendering: pixelated. The image scales to fill the container, preserving aspect ratio. Dither cells stay sharp through nearest-neighbour scaling. Good for hero sections where the image must always fill the box.

String media queries

PictureVariant.media accepts number | string:

// px — simple, works at all zoom levels (safe but wasteful when zoomed)
{ media: 800, width: 264 }

// em — responds to browser zoom (precise at all zoom levels)
{ media: '(min-width: 50em)', width: 264 }

// calc — for layouts with rem-based spacing
{ media: '(min-width: calc(800px + 4.5rem))', width: 264 }

With mode="crop", px breakpoints are always safe — zooming narrows the container, so you over-serve (more cropping, never blank space). But they are wasteful: at 200% zoom, a 400px bitmap serves a ~200px container. Em-based breakpoints track zoom precisely.

Load detection with imgRef

imgRef forwards a ref to the inner <img>. Since load events don't bubble, this is the only clean way to detect when the image is ready:

<DitheredPicture
  imgRef={(el) => {
    if (!el) return
    // SSR: image may already be decoded during hydration
    if (el.complete) { setLoaded(true); return }
    el.addEventListener('load', () => setLoaded(true), { once: true })
  }}
  // ...
/>

This is useful for tint overlays (visibility hidden until loaded), fade-in animations, or loading indicators.

Wrapper div

By default, DitheredPicture wraps the <picture> in a <div> with overflow: hidden and aspect-ratio set from the source's intrinsic ratio. This is load-bearing for crop mode — without it, object-fit: none would show the full uncropped image.

  • Pass aspectRatio={3/4} to override the ratio
  • Pass aspectRatio={null} to disable the wrapper entirely

Why not next/image?

DitheredPicture renders a plain <img>, not next/image. This is intentional: next/image's optimizer resamples and re-encodes images, destroying the pixel-exact dither output. The same reason DitheredImageSSR sets unoptimized: true.

For preloading, use the priority prop — it adds fetchPriority="high" and loading="eager" to the <img> element.

App Router vs Pages Router

Like DitheredImageSSR, the component is identical between routers:

import { DitheredPicture } from '@ditherkit/next'

Works in both App Router server components and Pages Router pages. The env-bridge values are inlined at build time, so the component is client-safe — it can even be imported inside a 'use client' component for patterns like the tinting demo.

On this page