<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 /> | |
|---|---|---|
| Output | Single <img> via next/image | <picture> with multiple <source> |
| Responsive | CSS scaling only | True resolution switching per viewport |
| Client JS | None | None |
| Best for | Fixed-size images, hero images with CSS scaling | Grids, cards, responsive layouts |
Props
DitheredPictureProps
| Prop | Type | Default | Description |
|---|---|---|---|
src | StaticImageData | string | required | Imported image or external URL. |
alt | string | required | Alt text for the inner <img>. |
variants | PictureVariant[] | required | Responsive variants, widest-viewport-first. See below. |
algorithm | DitherAlgorithm | 'Floyd-Steinberg' | Default dithering algorithm. |
pixelSize | number | 2 | Default cell size in output pixels. |
threshold | number | 128 | Threshold (0–255) for the Threshold algorithm. |
palette | string | '#000000,#ffffff' | Comma-separated hex colors. |
brightness | number | 0 | Brightness adjustment (-100 to 100). |
contrast | number | 0 | Contrast adjustment (-100 to 100). |
grayscale | boolean | auto | Force grayscale on/off. |
mode | 'crop' | 'cover' | 'crop' | Display mode. See Crop vs Cover. |
objectPosition | string | 'center' | CSS object-position for crop origin. |
priority | boolean | false | Adds fetchPriority="high" and loading="eager". Use for LCP images. |
aspectRatio | number | null | auto | Wrapper div aspect ratio. null disables the wrapper. |
className | string | — | CSS class on the wrapper <div>. |
style | React.CSSProperties | — | Styles on the wrapper <div>. |
imgClassName | string | — | CSS class on the inner <img>. |
imgStyle | React.CSSProperties | — | Styles on the inner <img>. |
imgRef | React.Ref<HTMLImageElement> | — | Ref forwarded to the inner <img>. See Load detection. |
PictureVariant
Each entry in the variants array becomes a <source> element.
| Field | Type | Default | Description |
|---|---|---|---|
media | number | string | required | Breakpoint. Numbers become (min-width: Npx). Strings used as-is (em, calc, compound queries). |
width | number | required | Output width in px. |
height | number | auto | Output height. Derived from source aspect ratio if omitted. |
algorithm | DitherAlgorithm | — | Override the top-level algorithm for this variant. |
pixelSize | number | — | Override cell size. |
threshold | number | — | Override threshold. |
brightness | number | — | Override brightness. |
contrast | number | — | Override contrast. |
palette | string | — | Override palette. |
grayscale | boolean | — | Override 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 breakpointIf 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
| Option | Type | Default | Description |
|---|---|---|---|
containerWidth | (vw: number) => number | required | Maps viewport width to expected container width. |
maxOvershoot | number | 0.5 | Max overshoot ratio (0.3 = 30%). |
pixelSize | number | 2 | Cell size for snapping widths. |
aspectRatio | number | 4/3 | For computing variant heights. |
minViewport | number | 320 | Narrowest viewport to consider. |
maxViewport | number | 1440 | Widest viewport to consider. |
maxDepth | number | 8 | Max 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'soverflow: hidden. Every visible pixel is byte-exact. -
'cover'—object-fit: coverwithimageRendering: 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.