Pixelate image

On hover or focus, an image breaks into large pixels and stays pixelated until the pointer or focus leaves.

Status
draft
Tags
  • motion
  • image
  • canvas

Setup

  1. Load src/styles/pixelate-image.css in the head.
  2. Wrap one <img> in <div data-pixelate-image>.
  3. Call initPixelateImages() from src/scripts/pixelate-image.ts once the page has loaded. It sets up every [data-pixelate-image], and returns a clean-up function.

Nothing is hidden before the script runs, so the image always shows.

Wraps the HTML core, so the logic is shared.

Setup

The component imports the CSS and the script, and renders the wrapper. Put one <img> in the slot. Use as="span" inside a paragraph or a link.

Parity with the default: full

Install

Expects in your project: react >=19.

Setup

A native React version: it shares pixelate-image.css and blockSizeAt with the other versions, and not their script. Pass one <img> as the child. It listens and draws in an effect, so nothing re-renders while the effect runs.

Demo

Files

/*
* Pixelate image: the structural styles for every version. Load this in the
* head. Nothing here hides content, so the image shows whether or not the
* script runs. The rules are unlayered and scoped to attributes.
*/
[data-pixelate-image] {
display: block;
position: relative;
overflow: clip;
}
/* The img may sit inside a <picture> or other wrapper, so select any
descendant rather than a direct child. */
[data-pixelate-image] img {
display: block;
inline-size: 100%;
block-size: auto;
}
[data-pixelate-image-canvas] {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
pointer-events: none;
image-rendering: pixelated;
}
[data-pixelate-image-canvas][hidden] {
display: none;
}

Goes in src/styles/pixelate-image.css

/**
* Pixelate image: the script for the HTML and Astro versions. Load
* `pixelate-image.css` in the head as well.
*
* import { initPixelateImages } from "./pixelate-image";
* initPixelateImages(); // every [data-pixelate-image] on the page
*
* <div data-pixelate-image data-pixelate-image-pixels="32">
* <img src="/photo.jpg" alt="..." width="1200" height="800" loading="lazy" decoding="async">
* </div>
*
* On hover or keyboard focus, the image breaks into large pixels and holds
* there. When the pointer and focus both leave, it resolves back to sharp. It
* is drawn on a canvas over the real image.
*
* Attributes (an option passed to `initPixelateImage` wins over an attribute):
* data-pixelate-image-pixels Largest block size in CSS pixels at the peak. Default 24.
* data-pixelate-image-duration Milliseconds for the break in, and for the resolve out. Default 600.
* data-pixelate-image-steps Distinct resolutions on the way up and down. Default 6.
*
* Custom property: --pixelate-image-duration, in milliseconds. It wins over
* the option and the attribute.
*
* The <img> is authored markup and stays the real content. The canvas is
* decorative (aria-hidden), shown only while pixelated, and out of layout
* otherwise. The image is only drawn to the canvas, never read back, so
* cross-origin images work without CORS headers.
*
* Honours prefers-reduced-motion: the effect never runs.
*
* Calling init twice on the same element does nothing. Each call returns a
* clean-up function that undoes it, for pages that swap content in and out.
*/
import { blockSizeAt } from "./pixelate-block-size";
export { blockSizeAt } from "./pixelate-block-size";
export interface PixelateImageOptions {
/** Largest block size in CSS pixels at the peak. Default 24. */
pixels?: number;
/** Milliseconds for the break in, and for the resolve out. Default 600. */
duration?: number;
/** Distinct resolutions on the way up and down. Default 6. */
steps?: number;
}
const active = new WeakMap<HTMLElement, () => void>();
/** Sets up one pixelate image. Returns a function that undoes it. */
export function initPixelateImage(
root: HTMLElement,
options: PixelateImageOptions = {},
): () => void {
const existing = active.get(root);
if (existing) return existing;
const controller = new AbortController();
const { signal } = controller;
let canvas: HTMLCanvasElement | null = null;
let createdCanvas = false;
let frame = 0;
let engaged = false;
/** Option, then attribute, then default. Read when an animation starts. */
const numberFrom = (option: number | undefined, name: string) =>
option || parseFloat(root.getAttribute(`data-pixelate-image-${name}`) ?? "") || undefined;
const maxPixels = () => numberFrom(options.pixels, "pixels") ?? 24;
const steps = () => Math.max(1, Math.round(numberFrom(options.steps, "steps") ?? 6));
/** Public custom property, then option, then attribute, then default. */
const duration = () => {
const fromVar = parseFloat(
getComputedStyle(root).getPropertyValue("--pixelate-image-duration"),
);
if (Number.isFinite(fromVar)) return fromVar;
return numberFrom(options.duration, "duration") ?? 600;
};
const image = () => root.querySelector<HTMLImageElement>("img");
/** Reuses a canvas the markup already has, or makes one on first use. */
const ensureCanvas = () => {
if (!canvas) {
const found = root.querySelector<HTMLCanvasElement>(
":scope > canvas[data-pixelate-image-canvas]",
);
if (found) {
canvas = found;
} else {
const created = document.createElement("canvas");
created.setAttribute("aria-hidden", "true");
created.setAttribute("data-pixelate-image-canvas", "");
created.hidden = true;
root.append(created);
canvas = created;
createdCanvas = true;
}
}
return canvas;
};
/** True when focus is on this element, inside it, or on an ancestor around it. */
const focusEngaged = () => {
const activeElement = document.activeElement;
// Unfocused documents report body (or <html>) as activeElement, which
// contains everything and would read as "always engaged".
if (
!activeElement ||
activeElement === document.body ||
activeElement === document.documentElement
) {
return false;
}
return root.contains(activeElement) || activeElement.contains(root);
};
/** Runs one half of the effect: break in (to 0.5) or resolve out (to 1). */
const animate = (from: number, to: number) => {
const source = image();
if (!source?.complete || source.naturalWidth === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
cancelAnimationFrame(frame);
const target = ensureCanvas();
const context = target.getContext("2d");
// A small offscreen canvas holds the downsampled image; scaling it up
// with smoothing off is what produces hard-edged blocks.
const buffer = document.createElement("canvas");
const bufferContext = buffer.getContext("2d");
if (!context || !bufferContext) return;
const { width, height } = source.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
target.width = Math.round(width * ratio);
target.height = Math.round(height * ratio);
const length = duration();
const peak = maxPixels();
const count = steps();
const start = performance.now();
let lastSize = 0;
const draw = (now: number) => {
const elapsed = Math.min((now - start) / length, 1);
const progress = from + (to - from) * elapsed;
const size = blockSizeAt(progress, peak, count);
// Only redraw when the quantised block size changes: most frames cost nothing.
if (size !== lastSize) {
lastSize = size;
buffer.width = Math.max(1, Math.round(width / size));
buffer.height = Math.max(1, Math.round(height / size));
bufferContext.drawImage(source, 0, 0, buffer.width, buffer.height);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, target.width, target.height);
context.drawImage(buffer, 0, 0, target.width, target.height);
target.hidden = size === 1;
}
frame = elapsed < 1 ? requestAnimationFrame(draw) : 0;
};
frame = requestAnimationFrame(draw);
};
const update = () => {
if (!root.isConnected) return;
const next = root.matches(":hover") || focusEngaged();
if (next === engaged) return;
engaged = next;
if (engaged) animate(0, 0.5);
else animate(0.5, 1);
};
root.addEventListener("pointerenter", update, { passive: true, signal });
root.addEventListener("pointerleave", update, { passive: true, signal });
// Focus bubbles to document, so listening there catches both a link
// inside the element and a link wrapped around it.
document.addEventListener("focusin", update, { signal });
// Defer so focus moving between children of this element reads its final
// state instead of a transient false on the way through.
document.addEventListener("focusout", () => queueMicrotask(update), { signal });
const cleanUp = () => {
controller.abort();
cancelAnimationFrame(frame);
frame = 0;
engaged = false;
// Remove a canvas this script made. Hide one the markup supplied.
if (canvas) {
if (createdCanvas) canvas.remove();
else canvas.hidden = true;
}
canvas = null;
createdCanvas = false;
active.delete(root);
};
active.set(root, cleanUp);
return cleanUp;
}
/** Sets up every `[data-pixelate-image]` inside `scope`. Returns one function that undoes them all. */
export function initPixelateImages(scope: ParentNode = document): () => void {
const cleanUps = Array.from(
scope.querySelectorAll<HTMLElement>("[data-pixelate-image]"),
(root) => initPixelateImage(root),
);
return () => cleanUps.forEach((cleanUp) => cleanUp());
}

Goes in src/scripts/pixelate-image.ts

/**
* Block size for progress through the full rise and fall (0 to 1), quantised
* to steps. A single transition uses one half: break in is 0 to 0.5, resolve
* out is 0.5 to 1.
*
* Pure maths with no DOM access, in its own file so the React version can
* share it without importing the script that changes the page.
*/
export function blockSizeAt(progress: number, maxPixels: number, steps: number): number {
const clamped = Math.min(Math.max(progress, 0), 1);
const triangle = 1 - Math.abs(clamped * 2 - 1); // 0, up to 1, back to 0
const quantised = Math.round(triangle * steps) / steps;
return Math.max(1, Math.round(quantised * maxPixels));
}

Goes in src/scripts/pixelate-block-size.ts

API

Shared by every variant. Wrapper props use the same names in camelCase.

Attributes

NameTypeDefaultDescription
data-pixelate-imageMarks the element that wraps one <img>. The Astro and React versions render it for you.
data-pixelate-image-pixelsnumber24Largest block size in CSS pixels, at the peak of the effect. The Astro and React versions take a pixels prop.
data-pixelate-image-durationmilliseconds600Time to reach peak pixelation, and to resolve back again. The Astro and React versions take a duration prop.
data-pixelate-image-stepsnumber6Distinct resolutions on the way up and back down. Fewer steps reads as chunkier. The Astro and React versions take a steps prop.

Custom properties

NameTypeDefaultDescription
--pixelate-image-durationmilliseconds600Wins over the data-pixelate-image-duration attribute and the duration prop.

Accessibility and SEO

Accessibility

The <img> and its alt text are untouched. The canvas is aria-hidden and shown only while pixelated. The effect starts on hover or on focus within the element, holds while either lasts, and resolves when both end. Reduced motion turns it off. The page still provides the alt text, and any link or button around the image.

SEO

The image is plain server-rendered markup that needs no JS, so it is safe above the fold and as the LCP element. The page still sets width, height, loading and decoding on the <img>, and turns off loading="lazy" above the fold.

Notes

Draft until it has been used on a production page. Unlike most snippets here, it was written for the library and not taken from a shipped project.

The canvas redraws only when the block size changes, which happens once per step.

The HTML and Astro versions share one small script, initPixelateImages(), found by the data-pixelate-image attribute. The React version is its own implementation, so it follows React’s rules rather than wrapping the script. All three share pixelate-image.css, and the React version also shares the blockSizeAt function.