Catalog / Recipe
Dither lift
A picture arrives dithered or screened and the treatment lifts off it, with no GPU.
- Family
- treatment
- Status
- lab
- Used for
- FigureGalleryListingCard gridHero
- Size
- 10,204 of 10,500 bytes, gzipped
mount(element)Ordered dither, blue noise, a dot screen and a line screen are one operation: compare the picture’s luminance with a threshold that depends only on position, and put ink where it is darker. Here the comparison is done by CSS. A copy of the picture sits under a map of thresholds at half opacity, so each pixel is the average of the two, and a contrast of sixty pivots that average into black or white. An SVG table then maps black to the shade and white to the tint, the skin’s own colours. The real picture is never touched; the layer over it lifts and is removed.
Use
import { mount } from './dither-lift';
mount(document.querySelector('.gallery'));
How it ends
The layer lifts over duration the first time the picture is on
screen (or at once, with trigger on load), then it and every style it
needed are removed. Under reduced motion nothing is added. In print,
nothing is shown. With rest on, a picture the page calls decorative
(alt="") stays treated; any other picture lifts anyway and says so in
data-dither="refused".
Options
| Option | Default | Meaning |
|---|---|---|
pattern |
0 | Bayer, noise, dots or lines |
cell |
1.5 | One dither cell, in CSS px, snapped to whole device pixels |
period |
6 | One halftone cell, in CSS px |
angle |
45 | Angle of a screen |
bias |
0 | Lighter or darker overall |
duration, hold |
700, 0 | How long the lift takes, and how long the treated state is held first |
transition |
0 | Fade, or develop |
trigger |
0 | As the picture comes into view, or on load |
rest |
false |
Stay treated, on decorative pictures only |
tones |
0 | Ink on ground, accent on ground, or ink on accent |
Versions
| Version | What it is |
|---|---|
| Bayer | The defaults: the cross-hatched grid everyone knows |
| Dots | A dot screen at 45 degrees |
| Lines | A line screen at 30 degrees |
| Grain | Blue noise: no grid, an even grain |
| Developed | Bayer, held a moment, then developing into the picture |
Where it must not go
- A picture the reader is already looking at. It lifts on arrival only.
- Every figure of a long page. One trick, once.
- Small text, with a resting treatment. A 1-bit dither eats type.
- Lines on a screenshot. Strokes at the screen’s spacing shred.
Learned while building it
- Snap the cell to device pixels. At 125 percent an 8 px tile came out as cells alternately one and two pixels wide: a plaid.
- A dark screenshot comes out as ink. It needs some bias.
- The noise tile costs 7 kB. A Bayer map is 64 numbers from the bits of the coordinates; blue noise does not compress. Bayer is the default for that reason.
- Treat a copy, not the picture. A filter on the picture’s parent reaches the caption; wrapping the picture moves it. A copy in an overlay touches neither.
/**
* dither-lift
*
* A picture arrives dithered, or screened, and the treatment lifts off
* it. No GPU: a copy of the image under a filter, with a threshold map
* laid over it at half opacity, so that a steep contrast curve turns
* the pair into a threshold test, and an SVG table maps black and white
* to the skin's shade and tint. The real `img` is never touched; the
* layer over it is removed when it has lifted.
*
* Copy this file. It imports nothing.
*
* import { mount } from './dither-lift';
* mount(document.querySelector('.gallery'));
*
* Every `img` inside whose parent is positioned is treated. A resting
* treatment is allowed only on an image the page marks decorative with
* alt="".
*/
export interface Options {
/** Which map: 0 Bayer, 1 noise, 2 dots, 3 lines. */
pattern: number;
/** One dither cell, in CSS px, snapped to whole device pixels. Under 1.5 reads as grey; over 3 reads as an old console. */
cell: number;
/** One halftone cell, in CSS px, for dots and lines. Under 6 moires. */
period: number;
/** Angle of the screen, in degrees. 45 hides the grid best. */
angle: number;
/** Lightness bias, -0.2 to 0.2. A dark screenshot comes out as ink without some. */
bias: number;
/** How long the lift takes, in ms. Over about 500 reads as delay. */
duration: number;
/** How long the treated state is held before lifting, in ms. */
hold: number;
/** How it lifts: 0 fade, 1 develop (the contrast ramps down and the map fades, like a print developing). */
transition: number;
/** When: 0 as the image comes into view, 1 as soon as it has loaded. */
trigger: number;
/** Stay treated instead of lifting. Allowed only on an image with alt="", and refused otherwise. */
rest: boolean;
/** The two tones: 0 ink and ground, 1 accent and ground, 2 ink and accent. */
tones: number;
}
export const DEFAULTS: Options = {
pattern: 0,
cell: 1.5,
period: 6,
angle: 45,
bias: 0,
duration: 700,
hold: 0,
transition: 0,
trigger: 0,
rest: false,
tones: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
/** The 8 by 8 Bayer thresholds from the bits of the coordinates, centred so pure black and white survive. */
export function bayerTile(): number[][] {
return Array.from({ length: 8 }, (_, y) =>
Array.from({ length: 8 }, (_, x) => {
let v = 0;
for (let i = 0; i < 3; i += 1) {
const bx = (x >> i) & 1;
const by = (y >> i) & 1;
v = (v << 2) | ((bx ^ by) << 1) | by;
}
return (v + 0.5) / 64;
}),
);
}
/** A cell chosen in CSS px, snapped to whole device pixels and given back in CSS px: otherwise cells come out alternately one and two pixels wide. */
export function snapCell(cell: number, ratio: number): number {
const device = Math.max(1, Math.round(cell * Math.max(0.5, ratio)));
return device / Math.max(0.5, ratio);
}
/** A resting treatment removes information, so only an image the page calls decorative may keep one. */
export function mayRest(img: HTMLImageElement): boolean {
return img.getAttribute('alt') === '';
}
/** The feComponentTransfer tables that map black to the shade and white to the tint. */
export function tableFor(shade: [number, number, number], tint: [number, number, number]): [string, string][] {
return [0, 1, 2].map((i) => [String(shade[i]), String(tint[i])]) as [string, string][];
}
export function parseSrgb(value: string): [number, number, number] | null {
const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
if (!match) return null;
const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
return [channel(match[1]), channel(match[2]), channel(match[3])];
}
export function mergeOptions(base: Options, next: Partial<Options>): Options {
const merged: Record<string, unknown> = { ...base };
for (const [key, standard] of Object.entries(DEFAULTS)) {
const value: unknown = (next as Record<string, unknown>)[key];
if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
}
return merged as unknown as Options;
}
/** A 64 by 64 blue-noise threshold tile, made once by void-and-cluster at build (catalog/tool/blue-noise.mjs). The price of the noise pattern: about 7.4 kB gzipped, since noise does not compress. */
const NOISE_TILE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAaRklEQVRo3iXad/zVUxgH8BNaWiQ0SNpDVstqJ0mhRcmqUFokSlvRtMtqKRqikpVKymijqDRo0g4V7e39ez1//u4933Oe57Oec+/vplmzZlWrVu3gwYNZs2b99NNPr7rqqqeffvquu+5KKc2YMePJJ5985513tmzZ8vnnn5cqVapu3bqXXnrpsWPHRo4cOX/+fOvr1KlzwQUXLFu27N57792zZ88rr7ySO3fuQ4cOPfjggy+88ELXrl1PnDhxzz331KhR47///jvnnHNWr149aNCgmjVr5siRY8mSJePHjx82bNi0adNsW65cuebNmxcqVOizzz579913ixcvvnnz5rlz51apUsXit99+u3r16h6/6KKLvDJp0qSCBQu2a9cujR49ulu3bnffffeqVateffXV++6778MPPxwzZkzJkiWPHDmycOFC66pWrdqrVy/nvfHGG88///zy5csbNmxYuHDhRx555Ndff9Vz9+7dr7zyyttvv/3666+3zLO2vfjii+vXr1+2bFntDR8+XEsaHjVqlAd12KJFi06dOnlq//79NqxVq9ZXX32l244dO95www0vvvhipkyZ7N+zZ8/LL78cNNB0EKBbtWoFF1Du3LmzRIkS6cILL3Tqt99+269fvyJFipw9e1aLP/74Y5s2bcB52223we+DDz44derUeeed17Jly48//hhUGzZs6NGjB1DBCcUvv/xy/fr1lvXt2xdXnTt3tgBFDv7oo480v3HjRv3ceOONvXv3vuaaa3766SevQPq5555z+mOPPeZPb7333nvKWrx48bp162699daTJ09OmDABrHfccYfGvA5ZNM6bNw+UEMdY2rdv34oVK6zzwOOPP/77779DAllad/z555+vlCFDhlSoUMExf/755y233PLEE0/kypVLuZUrV86WLVujRo2IyrO2Bie6mjZtOmLECA3jeujQoRa3b9+eGHbs2NG4ceNHH32UAAgPiqdPn7Y4X758KMqSJQtisUeE0AQcXBCoPDXALn/+/F7funWrEw8fPgys6dOnJxJEyrnnnrtt2zY6u/baa4FqC2qD9BdffIGvokWLrly5UmNO0vB3332HnO3btz/00EO4om/HA/iBBx5A/c8/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';
let bayerUrl: string | null = null;
const bayerMap = (): string => {
if (bayerUrl) return bayerUrl;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 8;
const ctx = canvas.getContext('2d');
if (!ctx) return '';
const image = ctx.createImageData(8, 8);
bayerTile().forEach((row, y) =>
row.forEach((t, x) => {
const i = (y * 8 + x) * 4;
const v = Math.round(t * 255);
image.data[i] = image.data[i + 1] = image.data[i + 2] = v;
image.data[i + 3] = 255;
}),
);
ctx.putImageData(image, 0, 0);
bayerUrl = canvas.toDataURL();
return bayerUrl;
};
/** The map layer's background for a pattern, at a cell snapped to device pixels. */
const mapStyle = (o: Options, ratio: number): { background: string; size: string; contrast: number; rotate: number } => {
const pattern = clamp(Math.round(o.pattern), 0, 3);
const cell = snapCell(clamp(o.cell, 0.5, 8), ratio);
const period = snapCell(clamp(o.period, 3, 24), ratio);
if (pattern === 0) return { background: `url(${bayerMap()})`, size: `${cell * 8}px ${cell * 8}px`, contrast: 60, rotate: 0 };
if (pattern === 1) return { background: `url(${NOISE_TILE})`, size: `${cell * 64}px ${cell * 64}px`, contrast: 60, rotate: 0 };
if (pattern === 2) return { background: 'radial-gradient(closest-side, #000, #fff)', size: `${period}px ${period}px`, contrast: 12, rotate: o.angle };
return { background: 'repeating-linear-gradient(0deg, #000 0, #fff 50%, #000 100%)', size: `${period}px ${period}px`, contrast: 12, rotate: o.angle };
};
interface Treated {
img: HTMLImageElement;
layer: HTMLElement;
map: HTMLElement;
done: boolean;
lifting: boolean;
}
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
try {
return start(element, options);
} catch {
return NOOP;
}
}
function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
if (!element || typeof IntersectionObserver === 'undefined' || typeof CSS === 'undefined') return NOOP;
if (!CSS.supports('filter', 'contrast(60)') || !CSS.supports('mix-blend-mode', 'lighten')) return NOOP;
const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;
mounted.get(element)?.destroy();
let options = mergeOptions(DEFAULTS, explicit);
const images = (element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img'))).filter(
(img) => img.parentElement && !['static', ''].includes(getComputedStyle(img.parentElement).position),
);
const treated = new Map<HTMLImageElement, Treated>();
const id = `dl-${Math.random().toString(36).slice(2, 8)}`;
const ratio = (): number => devicePixelRatio || 1;
// The colour mapping lives in one SVG filter per mount. Its tables are
// the shade and the tint, read from the page's tokens.
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
const tones = (): [string, string] => {
const pairs: [string, string][] = [
['--ink', '--ground'],
['--accent', '--ground'],
['--ink', '--accent'],
];
return pairs[clamp(Math.round(options.tones), 0, 2)] ?? ['--ink', '--ground'];
};
const colour = (property: string, fallback: string): [number, number, number] => {
svg.style.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
const out = parseSrgb(getComputedStyle(svg).color) ?? [0, 0, 0];
svg.style.color = '';
return out;
};
const writeFilter = (): void => {
const [shadeToken, tintToken] = tones();
const table = tableFor(colour(shadeToken, '#000'), colour(tintToken, '#fff'));
svg.replaceChildren();
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
filter.setAttribute('id', id);
filter.setAttribute('color-interpolation-filters', 'sRGB');
const transfer = document.createElementNS('http://www.w3.org/2000/svg', 'feComponentTransfer');
['R', 'G', 'B'].forEach((channel, i) => {
const fn = document.createElementNS('http://www.w3.org/2000/svg', `feFunc${channel}`);
fn.setAttribute('type', 'table');
fn.setAttribute('tableValues', table[i]?.join(' ') ?? '0 1');
transfer.append(fn);
});
filter.append(transfer);
svg.append(filter);
};
element.append(svg);
writeFilter();
// The layer: a copy of the picture and the map, under the filter. The
// real img is beneath it, untouched.
const lay = (img: HTMLImageElement): void => {
if (treated.has(img) || !img.complete || img.naturalWidth === 0) return;
const style = mapStyle(options, ratio());
const imgStyle = getComputedStyle(img);
const layer = document.createElement('div');
layer.dataset.lift = '';
layer.setAttribute('aria-hidden', 'true');
layer.style.cssText = `position:absolute;left:${img.offsetLeft}px;top:${img.offsetTop}px;width:${img.offsetWidth}px;height:${img.offsetHeight}px;overflow:clip;pointer-events:none;border-radius:${imgStyle.borderRadius};filter:grayscale(1) contrast(${style.contrast}) brightness(${1 + clamp(options.bias, -0.2, 0.2)}) url(#${id})`;
const copy = document.createElement('img');
copy.src = img.currentSrc || img.src;
copy.alt = '';
copy.style.cssText = `display:block;width:100%;height:100%;object-fit:${imgStyle.objectFit};object-position:${imgStyle.objectPosition}`;
const map = document.createElement('div');
map.style.cssText = `position:absolute;inset:-50%;opacity:.5;background:${style.background} 0 0 / ${style.size};image-rendering:pixelated;rotate:${style.rotate}deg`;
layer.append(copy, map);
img.after(layer);
const item: Treated = { img, layer, map, done: false, lifting: false };
treated.set(img, item);
if (options.rest && mayRest(img)) {
img.dataset.dither = 'rest';
return;
}
if (options.rest) img.dataset.dither = 'refused';
if (Math.round(options.trigger) === 1) lift(item);
else seen.observe(img);
};
const lift = (item: Treated): void => {
if (item.lifting || item.done) return;
item.lifting = true;
seen.unobserve(item.img);
const duration = clamp(options.duration, 100, 8000);
const delay = clamp(options.hold, 0, 4000);
const develop = Math.round(options.transition) === 1;
const animation = develop
? item.layer.animate([{ filter: item.layer.style.filter, opacity: 1 }, { filter: `grayscale(0) contrast(1) brightness(1) url(#${id})`, opacity: 1, offset: 0.85 }, { opacity: 0 }], { duration, delay, fill: 'forwards', easing: 'ease-out' })
: item.layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration, delay, fill: 'forwards', easing: 'ease-out' });
if (develop) item.map.animate([{ opacity: 0.5 }, { opacity: 0 }], { duration: duration * 0.85, delay, fill: 'forwards' });
animation.onfinish = () => {
item.done = true;
item.layer.remove();
treated.delete(item.img);
};
};
const seen = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const item = treated.get(entry.target as HTMLImageElement);
if (item && entry.intersectionRatio >= 0.12) lift(item);
}
},
{ threshold: [0.12] },
);
const near = new IntersectionObserver(
(entries) => {
for (const entry of entries) if (entry.isIntersecting) lay(entry.target as HTMLImageElement);
},
{ rootMargin: '60%' },
);
const onLoad = (event: Event): void => lay(event.target as HTMLImageElement);
for (const img of images) {
near.observe(img);
img.addEventListener('load', onLoad);
}
// A print must show the plain image; so must a change of pixel ratio,
// which would leave a map at the wrong size.
const dropAll = (): void => {
for (const item of treated.values()) item.layer.remove();
treated.clear();
};
addEventListener('beforeprint', dropAll);
const dpr = typeof matchMedia === 'function' ? matchMedia(`(resolution: ${ratio()}dppx)`) : null;
dpr?.addEventListener?.('change', dropAll);
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
writeFilter();
},
destroy() {
near.disconnect();
seen.disconnect();
removeEventListener('beforeprint', dropAll);
dpr?.removeEventListener?.('change', dropAll);
for (const img of images) {
img.removeEventListener('load', onLoad);
delete img.dataset.dither;
}
dropAll();
svg.remove();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
return handle;
}
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Dots)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Bayer: {},
Dots: { pattern: 2, period: 7 },
Lines: { pattern: 3, period: 5, angle: 30 },
Grain: { pattern: 1, cell: 1 },
Developed: { transition: 1, hold: 250, duration: 900 },
};