# Pixelate image

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

Framework: HTML (variant `html`)

In the Digital Design monorepo, copy the files; don't retype them. From the repo root, with <project> as the target project's folder:

```sh
mkdir -p <project>/src/scripts <project>/src/styles
cp library/src/content/snippets/pixelate-image/files/src/styles/pixelate-image.css <project>/src/styles/pixelate-image.css
cp library/src/content/snippets/pixelate-image/files/src/scripts/pixelate-image.ts <project>/src/scripts/pixelate-image.ts
cp library/src/content/snippets/pixelate-image/files/src/scripts/pixelate-block-size.ts <project>/src/scripts/pixelate-block-size.ts
```

## Dependencies

None.

## 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.


## Usage

```astro
---
// Plain markup, the way a page without a framework would use it: the CSS in
// the head, one <img> in the wrapper, and the script to set it up.
import "/src/styles/pixelate-image.css";
---

<a href="#demo" class="card">
  <div data-pixelate-image data-pixelate-image-pixels="32">
    <img
      src="/demo-assets/sample.svg"
      alt="Stylised mountains under a setting sun"
      width="1200"
      height="800"
      decoding="async"
    />
  </div>
  <span>Hover or focus the image</span>
</a>

<script>
  import { initPixelateImages } from "/src/scripts/pixelate-image";
  initPixelateImages();
</script>

```

## API

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

### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data-pixelate-image` |  |  | Marks the element that wraps one `<img>`. The Astro and React versions render it for you. |
| `data-pixelate-image-pixels` | `number` | `24` | Largest block size in CSS pixels, at the peak of the effect. The Astro and React versions take a `pixels` prop. |
| `data-pixelate-image-duration` | `milliseconds` | `600` | Time to reach peak pixelation, and to resolve back again. The Astro and React versions take a `duration` prop. |
| `data-pixelate-image-steps` | `number` | `6` | Distinct resolutions on the way up and back down. Fewer steps reads as chunkier. The Astro and React versions take a `steps` prop. |

### Custom properties

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `--pixelate-image-duration` | `milliseconds` | `600` | Wins over the `data-pixelate-image-duration` attribute and the `duration` prop. |

## 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.

## Files

### src/styles/pixelate-image.css

```css
/*
 * 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;
}

```

### src/scripts/pixelate-image.ts

```ts
/**
 * 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());
}

```

### src/scripts/pixelate-block-size.ts

```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));
}

```
