My portfolio is set like a newspaper, so my photo couldn't just be a photo. I wanted it to look like a printed halftone: a grid of dots, bigger where the picture is dark and smaller where it's light. And I wanted it to be alive, so the dots swell and get pushed away when you move the cursor over them.
It turned out to be about 150 lines of React and plain Canvas 2D, with no dependencies. Here's how it works.
Step 1: turn the image into a grid of samples
A halftone is just a low-resolution version of the picture drawn with dots. So the first job is to shrink the image to roughly one pixel per dot and read the pixels back.
I draw the image onto a tiny offscreen canvas (84 columns wide by default, with the rows worked out from the aspect ratio), and then call getImageData:
const cols = columns;
const rows = Math.max(1, Math.round(cols * (height / width)));
const off = document.createElement('canvas');
off.width = cols;
off.height = rows;
const octx = off.getContext('2d', { willReadFrequently: true });
// cover-fit the image into the sample grid
const scale = Math.max(cols / image.width, rows / image.height);
octx.drawImage(image, (cols - image.width * scale) / 2, (rows - image.height * scale) / 2,
image.width * scale, image.height * scale);
const data = octx.getImageData(0, 0, cols, rows).data;
Letting the browser do the downscaling is the trick: drawImage averages the source pixels for me, so each sample is already the average colour of its patch of the photo.
For every pixel I store its position (normalised 0 to 1), its colour, and its darkness, which I get from luminance:
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
samples.push({ nx: (x + 0.5) / cols, ny: (y + 0.5) / rows, darkness: 1 - lum, color: `rgb(${r},${g},${b})` });
I only build this once per resize, not every frame.
Step 2: draw a dot per sample
Each frame I clear the canvas and draw one circle per sample. The radius is what makes it look like a halftone: darker samples get a bigger dot.
const cell = width / columns;
let r = Math.pow(s.darkness, 0.85) * cell * 0.62;
The 0.85 exponent is a small tone curve. Without it the midtones came out too light. cell * 0.62 stops neighbouring dots from touching in the darkest areas.
To make the portrait fade into the page instead of ending in a hard rectangle, I multiply the radius by an elliptical vignette and use smoothstep so the edge falls off gently:
const smoothstep = (t: number) => t * t * (3 - 2 * t);
const mask = smoothstep(Math.min(1, Math.max(0, (1 - eDist) / 0.42)));
Here eDist is the sample's distance from the centre of an ellipse, where 1 is the edge.
Step 3: make it breathe
Even with no cursor I wanted a little life in it. A tiny sine wave, offset by each dot's position, nudges the radius by about 5%, so it ripples slowly:
r *= 1 + 0.05 * Math.sin(t + s.nx * 7 + s.ny * 9);
Step 4: react to the cursor
This is the part people notice. I keep a target position for the pointer and ease the "real" position toward it, so the effect trails the cursor with a bit of weight:
mouse.x += (mouse.tx - mouse.x) * 0.14;
mouse.y += (mouse.ty - mouse.y) * 0.14;
For each dot inside an influence radius, two things happen: the dot grows, and it gets pushed away from the cursor, both scaled by a smooth falloff:
if (d < influenceRadius) {
const f = smoothstep(1 - d / influenceRadius);
r *= 1 + 1.05 * f; // swell
const push = (f * cell * 1.4) / d; // shove away from the cursor
ox = dx * push;
oy = dy * push;
}
Using smoothstep rather than a linear falloff is what makes it feel like ink spreading rather than a hard circle.
Making it behave
A canvas animation that runs forever is rude. Three small guards keep this one polite:
-
Pause when off-screen. An
IntersectionObserverstops the animation loop when the canvas isn't visible and restarts it when it is. -
Respect reduced motion. If
prefers-reduced-motionis set, it draws a single static frame and never starts the loop. -
Cap the pixel ratio.
devicePixelRatiois clamped to 2, so very dense screens don't draw four times the pixels for no visible gain.
I also resize with a ResizeObserver and rebuild the samples then, and I decode the image with img.decode() before drawing so there's no half-loaded flash.
Finally, the canvas is aria-hidden, since it's decoration. The page's real heading carries the meaning.
What I'd improve
-
It draws one
arcper dot. For about 7,000 dots that's fine, but a much denser grid would be better as a single batched path or a WebGL shader. I draw dots individually because each one has its own colour. - Large photos cost something at load. The sampling step reads back image data once, which is quick for a small grid, but it's worth resizing the source photo to something sensible first.
Want the whole thing?
The halftone portrait is one part of a bigger project: a newspaper-style portfolio with a typewriter intro, a command palette, case studies and a blog. I turned it into a template, with a live demo you can poke at:
- Demo: https://newspaper-template-zeta.vercel.app
- Template (paid): https://surajnandan.gumroad.com/l/press-portfolio
The halftone component above is self-contained, so you're welcome to copy the ideas straight into your own project.
Top comments (0)