"use client";
import * as React from "react";
export type HalftoneShape = "circle" | "square" | "line";
export type HalftoneColorMode = "mono" | "duotone";
export type HalftoneLens = "reveal" | "magnify" | "none";
export interface HalftoneImageProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Image URL. Remote images need CORS headers (pixels are read). Without `src`, a generated landscape is used. */
src?: string;
/** Alternative text. When set, the root becomes role="img" with this label; otherwise it is decorative. */
alt?: string;
/** Screen pitch: distance between dot centres, in px. */
cellSize?: number;
/** Screen angle, in degrees. Print screens traditionally sit at 15°, 45° or 75°. */
angle?: number;
/** Dot shape. `line` draws a line screen whose stroke thickness carries the tone. */
shape?: HalftoneShape;
/** mono: one screen in the theme ink · duotone: an accent screen under an ink screen at another angle. */
colorMode?: HalftoneColorMode;
/** Tone contrast around the midpoint (1 = linear). */
contrast?: number;
/** What the pointer lens does: show the original image, enlarge the dots, or nothing. */
lens?: HalftoneLens;
/** Lens radius, in px. */
lensRadius?: number;
/** Accent colour for the duotone screen and the lens ring (any CSS colour). */
accent?: string;
/** How the image fits the box. */
fit?: "cover" | "contain";
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const rgb = ([r, g, b]: RGB) => `rgb(${r}, ${g}, ${b})`;
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ------------------------------ Generated art ------------------------------ */
/**
* The built-in demo image: an evening landscape painted with gradients (no network request).
* It carries a full tonal range — pale sky, a bright sun, mid-tone ridges, a dark foreground.
*/
function paintLandscape(ctx: CanvasRenderingContext2D, w: number, h: number) {
const sky = ctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#aaa2ad");
sky.addColorStop(0.42, "#dcb3a0");
sky.addColorStop(0.72, "#efa27c");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
const m = Math.min(w, h);
const sx = w * 0.66;
const sy = h * 0.44;
const sr = m * 0.2;
const sun = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr);
sun.addColorStop(0, "#fffaf0");
sun.addColorStop(0.7, "#ffe2b8");
sun.addColorStop(1, "#ffcf9e");
ctx.fillStyle = sun;
ctx.beginPath();
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
ctx.fill();
// Ridges, far to near: each a sum of sines, darker and lower than the last.
const ridges: { base: number; amp: number; color: string; f: number; p: number }[] = [
{ base: 0.6, amp: 0.07, color: "#c9806a", f: 1.7, p: 0.4 },
{ base: 0.68, amp: 0.08, color: "#9a5c56", f: 2.3, p: 2.1 },
{ base: 0.77, amp: 0.07, color: "#5e3d47", f: 3.1, p: 4.2 },
{ base: 0.87, amp: 0.06, color: "#2a2230", f: 2.6, p: 1.3 },
];
for (const r of ridges) {
ctx.fillStyle = r.color;
ctx.beginPath();
ctx.moveTo(0, h);
for (let x = 0; x <= w; x += Math.max(2, w / 160)) {
const t = x / w;
const y =
r.base +
Math.sin(t * Math.PI * r.f + r.p) * r.amp * 0.6 +
Math.sin(t * Math.PI * r.f * 2.7 + r.p * 1.9) * r.amp * 0.3 +
Math.sin(t * Math.PI * r.f * 6.1 + r.p * 0.7) * r.amp * 0.1;
ctx.lineTo(x, y * h);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
}
}
/* -------------------------------- Component -------------------------------- */
interface Loaded {
src: string;
image: HTMLImageElement | null;
}
/**
* An image re-drawn as a print halftone on a canvas: the tone at each cell of a rotated screen
* sets the size of a dot, square or line. Light themes print dark ink for shadows; dark themes
* print light ink for highlights, so the picture stays positive. A pointer lens can reveal the
* original image or magnify the screen locally. Nothing animates while idle — the canvas only
* redraws when a prop, the size, the theme or the lens changes.
*/
export function HalftoneImage({
src,
alt,
cellSize = 10,
angle = 45,
shape = "circle",
colorMode = "mono",
contrast = 1.2,
lens = "reveal",
lensRadius = 110,
accent = "#FF7448",
fit = "cover",
className = "",
...props
}: HalftoneImageProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = React.useState<Loaded | null>(null);
// Load `src` once per URL. A failed or CORS-blocked image falls back to the generated art.
React.useEffect(() => {
if (!src) return;
let cancelled = false;
const img = new Image();
img.crossOrigin = "anonymous";
img.decoding = "async";
img.onload = () => !cancelled && setLoaded({ src, image: img });
img.onerror = () => !cancelled && setLoaded({ src, image: null });
img.src = src;
return () => {
cancelled = true;
};
}, [src]);
/** undefined while a `src` is still loading; null = use the generated art. */
const image: HTMLImageElement | null | undefined = !src ? null : loaded?.src === src ? loaded.image : undefined;
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx || image === undefined) return;
const cell = Math.max(3, cellSize);
const theta = (angle * Math.PI) / 180;
const reach = Math.max(16, lensRadius);
const gain = Math.max(0.1, contrast);
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let ink: RGB = [24, 24, 27];
let accentRGB: RGB = [255, 116, 72];
// Tone samples on a coarse grid (a few per cell), and the full-colour picture for the lens.
let tones = new Float32Array(0);
let sampleW = 0;
let sampleH = 0;
let sampleStep = 1;
const photo = document.createElement("canvas");
const screen = document.createElement("canvas");
const pointer = { x: 0, y: 0, strength: 0, target: 0 };
const readTheme = () => {
ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
accentRGB = resolveColor(accent, [255, 116, 72]);
};
/** Paints the source (image or generated art) into `target`, fitted to the box. */
const paintSource = (target: CanvasRenderingContext2D, w: number, h: number) => {
target.clearRect(0, 0, w, h);
if (!image) {
paintLandscape(target, w, h);
return;
}
const iw = image.naturalWidth || 1;
const ih = image.naturalHeight || 1;
const scale = fit === "cover" ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih);
const dw = iw * scale;
const dh = ih * scale;
target.imageSmoothingQuality = "high";
target.drawImage(image, (w - dw) / 2, (h - dh) / 2, dw, dh);
};
/** Samples luminance into `tones` as ink coverage 0–1 (after contrast), theme-aware. */
const sampleTones = () => {
sampleStep = Math.max(1, cell / 3);
sampleW = Math.max(1, Math.ceil(width / sampleStep));
sampleH = Math.max(1, Math.ceil(height / sampleStep));
const sampler = document.createElement("canvas");
sampler.width = sampleW;
sampler.height = sampleH;
const sctx = sampler.getContext("2d", { willReadFrequently: true });
if (!sctx) return;
// Downscaling while drawing averages pixels, which keeps the screen free of aliasing.
sctx.drawImage(photo, 0, 0, sampleW, sampleH);
let data: Uint8ClampedArray;
try {
data = sctx.getImageData(0, 0, sampleW, sampleH).data;
} catch {
// Tainted canvas (cross-origin image without CORS): fall back to the generated art.
paintLandscape(sctx, sampleW, sampleH);
data = sctx.getImageData(0, 0, sampleW, sampleH).data;
}
const lightInk = luminance(ink) > 0.5;
tones = new Float32Array(sampleW * sampleH);
for (let i = 0; i < tones.length; i++) {
const o = i * 4;
const lum = (0.2126 * data[o] + 0.7152 * data[o + 1] + 0.0722 * data[o + 2]) / 255;
// Dark ink prints shadows; light ink (dark theme) prints highlights.
// Light ink is held back a little so bright areas stay a screen instead of merging into a slab.
let t = lightInk ? lum * 0.88 : 1 - lum;
t = (t - 0.5) * gain + 0.5;
tones[i] = Math.min(1, Math.max(0, t)) * (data[o + 3] / 255);
}
};
const toneAt = (x: number, y: number) => {
const sx = Math.min(sampleW - 1, Math.max(0, Math.floor(x / sampleStep)));
const sy = Math.min(sampleH - 1, Math.max(0, Math.floor(y / sampleStep)));
return tones[sy * sampleW + sx] ?? 0;
};
/**
* Draws one screen. Cells are laid out in the screen's rotated frame (u, v) around the centre;
* each cell's tone is read at its on-canvas position. With `focus`, only cells inside the lens
* are drawn, pushed outward and enlarged by a bulge that peaks at the lens centre.
*/
const drawScreen = (
target: CanvasRenderingContext2D,
rotation: number,
color: string,
curve: (t: number) => number,
focus: { x: number; y: number; r: number; k: number } | null,
) => {
const cx = width / 2;
const cy = height / 2;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const half = Math.hypot(width, height) / 2 + cell;
const n = Math.ceil(half / cell);
// Lens centre in the rotated frame.
const fu = focus ? (focus.x - cx) * cos + (focus.y - cy) * sin : 0;
const fv = focus ? -(focus.x - cx) * sin + (focus.y - cy) * cos : 0;
const path = new Path2D();
for (let j = -n; j <= n; j++) {
const v = j * cell;
if (focus && Math.abs(v - fv) > focus.r + cell) continue;
for (let i = -n; i <= n; i++) {
const u = i * cell;
let scale = 1;
let pu = u;
let pv = v;
if (focus) {
const du = u - fu;
const dv = v - fv;
const d = Math.hypot(du, dv);
if (d > focus.r) continue;
const f = 1 - d / focus.r;
scale = 1 + focus.k * f * f * (3 - 2 * f);
pu = fu + du * scale;
pv = fv + dv * scale;
}
const x = cx + u * cos - v * sin;
const y = cy + u * sin + v * cos;
if (x < -cell || y < -cell || x > width + cell || y > height + cell) continue;
const t = curve(toneAt(x, y));
if (t < 0.02) continue;
const size = cell * scale;
if (shape === "circle") {
const r = size * 0.5 * Math.sqrt(t) * 1.34;
path.moveTo(pu + r, pv);
path.arc(pu, pv, r, 0, Math.PI * 2);
} else if (shape === "square") {
const s = size * Math.sqrt(t);
path.rect(pu - s / 2, pv - s / 2, s, s);
} else {
// Line screen: abutting segments whose thickness carries the tone.
const th = size * t;
path.rect(pu - size / 2 - 0.25, pv - th / 2, size + 0.5, th);
}
}
}
target.save();
target.setTransform(dpr, 0, 0, dpr, 0, 0);
target.translate(cx, cy);
target.rotate(rotation);
target.fillStyle = color;
target.fill(path);
target.restore();
};
/** All screens for the current colour mode. */
const drawScreens = (target: CanvasRenderingContext2D, focus: { x: number; y: number; r: number; k: number } | null) => {
if (colorMode === "duotone") {
// Accent screen carries the overall image at a second angle; the ink screen adds the deep tones.
drawScreen(target, theta + Math.PI / 6, rgb(accentRGB), (t) => Math.pow(t, 0.85) * 0.92, focus);
drawScreen(target, theta, rgb(ink), (t) => Math.max(0, (t - 0.38) / 0.62), focus);
} else {
drawScreen(target, theta, rgb(ink), (t) => t, focus);
}
};
/** Rebuilds the cached screen (after a resize, theme or prop change). */
const rebuild = () => {
if (width < 1 || height < 1) return;
photo.width = Math.max(1, Math.round(width * dpr));
photo.height = Math.max(1, Math.round(height * dpr));
const pctx = photo.getContext("2d");
if (!pctx) return;
pctx.setTransform(dpr, 0, 0, dpr, 0, 0);
paintSource(pctx, width, height);
sampleTones();
screen.width = photo.width;
screen.height = photo.height;
const sctx = screen.getContext("2d");
if (!sctx) return;
sctx.clearRect(0, 0, screen.width, screen.height);
drawScreens(sctx, null);
};
const draw = () => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(screen, 0, 0);
const s = pointer.strength;
if (lens === "none" || s < 0.01) return;
const r = reach * (0.6 + 0.4 * s);
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.beginPath();
ctx.arc(pointer.x, pointer.y, r, 0, Math.PI * 2);
ctx.save();
ctx.clip();
ctx.clearRect(pointer.x - r, pointer.y - r, r * 2, r * 2);
if (lens === "reveal") {
ctx.globalAlpha = s;
ctx.drawImage(photo, 0, 0, width, height);
ctx.globalAlpha = 1;
} else {
drawScreens(ctx, { x: pointer.x, y: pointer.y, r, k: 0.9 * s });
}
ctx.restore();
// Fine ring marking the lens edge.
ctx.lineWidth = 1;
ctx.strokeStyle = `rgba(${accentRGB[0]}, ${accentRGB[1]}, ${accentRGB[2]}, ${0.9 * s})`;
ctx.stroke();
ctx.restore();
};
// Demand-driven: frames run only while the lens eases in or out (or moves).
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
pointer.strength += (pointer.target - pointer.strength) * (1 - Math.pow(1 - 0.25, dt / 16.7));
if (Math.abs(pointer.target - pointer.strength) < 0.01) pointer.strength = pointer.target;
draw();
if (pointer.strength !== pointer.target) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && !document.hidden) raf = requestAnimationFrame(frame);
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 0.5 && Math.abs(rect.height - height) < 0.5 && width > 0) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
rebuild();
draw();
};
const onThemeChange = () => {
readTheme();
rebuild();
draw();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
};
const show = (on: boolean) => {
pointer.target = on ? 1 : 0;
if (reduceMotion) pointer.strength = pointer.target;
schedule();
};
let pressed = false;
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && !pressed) return;
locate(e);
if (pointer.target === 0) show(true);
else schedule();
};
// Touch and pen: the lens follows the finger while pressed (until the browser starts a scroll).
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === "mouse") return;
pressed = true;
locate(e);
show(true);
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType === "mouse") return;
pressed = false;
show(false);
};
const onPointerLeave = (e: PointerEvent) => {
if (e.pointerType === "mouse") show(false);
};
const onVisibility = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
}
};
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
const passive = { passive: true } as const;
if (lens !== "none") {
root.addEventListener("pointermove", onPointerMove, passive);
root.addEventListener("pointerdown", onPointerDown, passive);
root.addEventListener("pointerup", onPointerUp, passive);
root.addEventListener("pointercancel", onPointerUp, passive);
root.addEventListener("pointerleave", onPointerLeave, passive);
}
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
unobserveTheme();
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("pointercancel", onPointerUp);
root.removeEventListener("pointerleave", onPointerLeave);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [image, cellSize, angle, shape, colorMode, contrast, lens, lensRadius, accent, fit, reduceMotion]);
const labelled = Boolean(alt);
return (
<div
ref={rootRef}
role={labelled ? "img" : undefined}
aria-label={labelled ? alt : undefined}
aria-hidden={labelled ? undefined : true}
className={`absolute inset-0 overflow-hidden ${lens === "none" ? "" : "cursor-crosshair"} ${className}`}
{...props}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
</div>
);
}