Image Effects
HalftoneReveal
The picture sits on the page as a black ink newspaper print. Under the pointer the dots swell open onto the real colour picture. Press to open all of it, press again to go back to print. Canvas, no WebGL.
Download
- React
- Tailwind CSS v4
- motion
components/ui/halftone-reveal.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type HalftoneRevealProps = {
src: string;
alt: string;
/** Printed under the picture. */
caption?: string;
paper?: string;
ink?: string;
/** Dots across the picture. They never get smaller than 4.5 pixels apart. */
dots?: number;
/** Size of the colour patch under the pointer, as a part of the width. */
lens?: number;
/** Tilt of the dot rows, in degrees. */
angle?: number;
className?: string;
};
/* HalftoneReveal: a newspaper print made of ink dots. Under the pointer the dots swell open onto the real colour picture. Press to open all of it. */
export function HalftoneReveal({ src, alt, caption, paper = "#ece4d2", ink = "#191713", dots = 84, lens = 0.3, angle = 45, className = "" }: HalftoneRevealProps) {
const reduce = useReducedMotion();
const pic = useRef<HTMLImageElement>(null);
const sheet = useRef<HTMLCanvasElement>(null);
const [full, setFull] = useState(false);
const live = useRef({ x: 0, y: 0, toX: 0, toY: 0, open: 0, toOpen: 0, all: 0, toAll: 0 });
const wake = useRef(() => {});
useEffect(() => {
const canvas = sheet.current!;
const image = pic.current!;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const s = live.current;
let grid: { x: number; y: number; r: number }[] = [];
let w = 0;
let h = 0;
let cell = 6;
let raf = 0;
function shades() {
const cols = Math.ceil((w / cell) * 2);
const rows = Math.ceil((h / cell) * 2);
const off = document.createElement("canvas");
off.width = cols;
off.height = rows;
const o = off.getContext("2d", { willReadFrequently: true })!;
const fit = Math.max(cols / image.naturalWidth, rows / image.naturalHeight);
const dw = image.naturalWidth * fit;
const dh = image.naturalHeight * fit;
o.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh);
let data: Uint8ClampedArray;
try {
data = o.getImageData(0, 0, cols, rows).data;
} catch {
return () => 0.5; // Picture from another site without CORS: an even tone.
}
const lum = new Float32Array(cols * rows);
const count = new Float32Array(256);
for (let i = 0; i < lum.length; i++) {
lum[i] = (0.3 * data[i * 4] + 0.59 * data[i * 4 + 1] + 0.11 * data[i * 4 + 2]) / 255;
count[Math.round(lum[i] * 255)]++;
}
// Spread the tones evenly, half way, so a dark photo still prints its hills.
for (let i = 1; i < 256; i++) count[i] += count[i - 1];
return (x: number, y: number) => {
const cx = Math.min(cols - 1, Math.max(0, Math.round((x / w) * cols)));
const cy = Math.min(rows - 1, Math.max(0, Math.round((y / h) * rows)));
const v = lum[cy * cols + cx];
return (v + count[Math.round(v * 255)] / lum.length) / 2;
};
}
function build() {
if (!image.naturalWidth) return;
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
cell = Math.max(4.5, w / dots);
const tone = shades();
const a = (angle * Math.PI) / 180;
const reach = Math.ceil(Math.hypot(w, h) / cell);
grid = [];
for (let u = -reach; u <= reach; u++) {
for (let v = -reach; v <= reach; v++) {
const x = w / 2 + (u * Math.cos(a) - v * Math.sin(a)) * cell;
const y = h / 2 + (u * Math.sin(a) + v * Math.cos(a)) * cell;
if (x < -cell || y < -cell || x > w + cell || y > h + cell) continue;
const dark = 1 - tone(x, y);
grid.push({ x, y, r: cell * 0.72 * Math.sqrt(dark) });
}
}
paint();
}
function paint() {
const c = ctx!;
c.globalCompositeOperation = "source-over";
c.fillStyle = paper;
c.fillRect(0, 0, w, h);
c.fillStyle = ink;
c.beginPath();
for (const g of grid) {
if (g.r < 0.35) continue;
c.moveTo(g.x + g.r, g.y);
c.arc(g.x, g.y, g.r, 0, Math.PI * 2);
}
c.fill();
const reach = lens * w * s.open + s.all * Math.hypot(w, h) * 1.5;
if (reach < 1) return;
const band = reach * 0.5 + cell;
c.globalCompositeOperation = "destination-out";
c.beginPath();
for (const g of grid) {
const t = Math.min(1, (reach - Math.hypot(g.x - s.x, g.y - s.y)) / band);
if (t <= 0) continue;
const r = t * cell * 0.76;
c.moveTo(g.x + r, g.y);
c.arc(g.x, g.y, r, 0, Math.PI * 2);
}
c.fill();
}
function tick() {
const k = reduce ? 1 : 0.16;
s.x += (s.toX - s.x) * (reduce ? 1 : 0.3);
s.y += (s.toY - s.y) * (reduce ? 1 : 0.3);
s.open += (s.toOpen - s.open) * k;
s.all += (s.toAll - s.all) * (reduce ? 1 : 0.075);
paint();
const moving = Math.abs(s.toX - s.x) + Math.abs(s.toY - s.y) > 0.3 || Math.abs(s.toOpen - s.open) > 0.004 || Math.abs(s.toAll - s.all) > 0.004;
raf = moving ? requestAnimationFrame(tick) : 0;
if (!moving) {
s.open = s.toOpen;
s.all = s.toAll;
paint();
}
}
wake.current = () => {
if (!raf) raf = requestAnimationFrame(tick);
};
image.addEventListener("load", build);
const watch = new ResizeObserver(build);
watch.observe(canvas);
build();
return () => {
cancelAnimationFrame(raf);
image.removeEventListener("load", build);
watch.disconnect();
};
}, [paper, ink, dots, lens, angle, reduce]);
function aim(e: { clientX: number; clientY: number }, jump = false) {
const box = sheet.current!.getBoundingClientRect();
const s = live.current;
s.toX = e.clientX - box.left;
s.toY = e.clientY - box.top;
if (jump) {
s.x = s.toX;
s.y = s.toY;
}
wake.current();
}
function setOpen(on: boolean) {
live.current.toOpen = on ? 1 : 0;
wake.current();
}
function press(e: { clientX: number; clientY: number; detail: number }) {
const box = sheet.current!.getBoundingClientRect();
aim(e.detail ? e : { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }, !e.detail);
live.current.toAll = full ? 0 : 1;
setFull(!full);
wake.current();
}
return (
<div className={`@container w-[28rem] ${className}`}>
<button
type="button"
aria-pressed={full}
aria-label={`${alt}. ${full ? "Back to the newspaper print" : "Show the full colour picture"}`}
onPointerEnter={(e) => {
aim(e, true);
setOpen(true);
}}
onPointerMove={aim}
onPointerLeave={() => setOpen(false)}
onClick={press}
className="block w-full cursor-crosshair touch-pan-y p-[0.6em] text-left text-[2.8cqw] leading-tight outline-none shadow-[0_0.8em_1.6em_-0.8em_rgba(0,0,0,0.7)] focus-visible:ring-[0.2em] focus-visible:ring-[#d13a2a]"
style={{ background: paper, color: ink }}
>
<span className="relative block aspect-[16/10] overflow-hidden">
<img ref={pic} src={src} alt="" draggable={false} className="absolute inset-0 size-full object-cover" />
<canvas ref={sheet} aria-hidden className="absolute inset-0 size-full" />
</span>
{caption && <span className="mt-[0.5em] block border-t border-current pt-[0.4em] font-serif text-[1.1em] italic">{caption}</span>}
</button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/halftone-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/halftone-reveal-demo.tsx
import { HalftoneReveal } from "@/components/ui/halftone-reveal"; export default function HalftoneRevealExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#26231f] p-2"> <HalftoneReveal src="/demo/ember.jpg" alt="Red hills under a burning sky" caption="Fig. 3: the ridge at last light" className="w-[min(118cqh,92cqw,520px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | โ | Picture address. From another site it needs CORS headers, or the dots come out one even size. |
| alt | string | โ | Text for screen readers. |
| caption | string | โ | Printed under the picture. |
| paper | string | "#ece4d2" | Colour of the newsprint. |
| ink | string | "#191713" | Colour of the dots. |
| dots | number | 84 | Dots across the picture. Never closer than 4.5 pixels. |
| lens | number | 0.3 | Size of the colour patch under the pointer, as a part of the width. |
| angle | number | 45 | Tilt of the dot rows, in degrees. |
| className | string | "w-[28rem]" | Set the width here. |