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 for React: a native version, not a wrapper around the HTML
* script. It shares `pixelate-image.css` and `blockSizeAt` with the other
* versions, and not their script. It does its own listening and drawing.
*
* <PixelateImage pixels={32}>
* <img src="/photo.jpg" alt="..." width={1200} height={800} loading="lazy" decoding="async" />
* </PixelateImage>
*
* How it stays fast: nothing re-renders while the effect runs. There is no
* React state for hover or progress. The effect listens through refs and
* draws on a canvas in animation frames.
*/
import { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";
import { blockSizeAt } from "../scripts/pixelate-block-size";
import "../styles/pixelate-image.css";
interface PixelateImageProps extends HTMLAttributes<HTMLElement> {
/** Element used for the wrapper. Use `span` inside a paragraph or a link. */
as?: "div" | "span";
/** 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;
/** One <img> with width, height and alt set. */
children: ReactNode;
}
export function PixelateImage({
as: Tag = "div",
pixels,
duration,
steps,
children,
...rest
}: PixelateImageProps) {
const rootRef = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// The latest props, read when an animation starts. Keeping them in a ref
// means the listeners effect below runs once and never needs to re-run when
// a prop changes. A change takes effect on the next hover.
const settings = useRef({ pixels, duration, steps });
useEffect(() => {
settings.current = { pixels, duration, steps };
}, [pixels, duration, steps]);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const controller = new AbortController();
const { signal } = controller;
let frame = 0;
let engaged = false;
/** The custom property wins, then the prop, then the default. */
const durationNow = () => {
const fromVar = parseFloat(
getComputedStyle(root).getPropertyValue("--pixelate-image-duration"),
);
if (Number.isFinite(fromVar)) return fromVar;
return settings.current.duration || 600;
};
/** True when focus is on this element, inside it, or on an ancestor around it. */
const focusEngaged = () => {
const active = document.activeElement;
// Unfocused documents report body (or <html>) as activeElement, which
// contains everything and would read as "always engaged".
if (!active || active === document.body || active === document.documentElement) {
return false;
}
return root.contains(active) || active.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 image = root.querySelector<HTMLImageElement>("img");
if (!image?.complete || image.naturalWidth === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
cancelAnimationFrame(frame);
const context = canvas.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 } = image.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const length = durationNow();
const peak = settings.current.pixels || 24;
const count = Math.max(1, Math.round(settings.current.steps || 6));
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(image, 0, 0, buffer.width, buffer.height);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(buffer, 0, 0, canvas.width, canvas.height);
canvas.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 });
// Strict Mode runs this effect, cleans up and runs it again, so the
// clean-up must leave everything ready for a fresh start.
return () => {
controller.abort();
cancelAnimationFrame(frame);
canvas.hidden = true;
};
}, []);
return (
<Tag ref={rootRef as never} data-pixelate-image="" {...rest}>
{children}
<canvas ref={canvasRef} data-pixelate-image-canvas="" aria-hidden="true" hidden />
</Tag>
);
}

Goes in src/components/PixelateImage.tsx

/**
* 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

/*
* 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

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.