# Mosaic poster

> A poster whose picture is a grid of tiny cells that dissolve in drifting blotches: brush the view back with the pointer, or click to send a ripple.

A portrait poster whose picture is drawn on a canvas as a fine grid of cells, each the colour of the picture under it. Slow, organic blotches dissolve into the poster and come back as a noise field drifts over the grid, with a few sparkles. Move the pointer over it to bring the view back, or click to send a ripple that re-forms the whole picture from that point out.

A React component by Bruno Delorence: https://brunodelorence.com/components/mosaic-poster. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Media.

## Anatomy

1. **Ground**: The stage around the poster: a lit radial gradient on light-dark() tokens, so it follows the page or `colorScheme`.
2. **Poster**: A 3 by 4 sheet of paper with a hairline edge and a long soft shadow. The paper shows between the cells and where they dissolve.
3. **Picture**: The mosaic: a canvas of square cells, each the average colour of the picture under it, laid over the real `<img>` that screen readers hear. The pointer paints it back here.
4. **Headline**: A serif line sized in container units, so it keeps its proportion to the poster at any width; `\n` starts a new line.
5. **Menu mark**: Three bars in the accent colour, a graphic detail of the poster. Decorative, so it is hidden from screen readers.
6. **Hint**: Says what the pointer does, and on a touch screen what a finger does instead. It describes the group for screen readers.
7. **Restore**: Sends a ripple from the middle that re-forms the whole picture, then announces “Picture restored.” The keyboard way to see it whole.

## When to use

- A hero or a campaign page where one photograph carries the mood and the reader is invited to play with it.
- An editorial poster: one picture, a short headline of two or three lines, and room around it.
- Moments that reward lingering: the picture comes back under the pointer and dissolves again once it has gone.
- A brand or event page that wants motion without video: the canvas draws only while something changes, and only near the screen.

## When not to use

- When the picture itself is the information (a product shot, a map, a chart): a third of it is always missing. Show a plain `<img>`.
- Pictures from another origin without CORS headers: their pixels can’t be read, so the poster stays a still grid.
- Several on one screen, or inside a scrolling feed: each runs its own canvas. Keep it to one hero.
- Long or functional copy: the headline is a poster line in large serif type. Put real text beside it.
- Pages for focused work, where steady motion in the corner of the eye distracts. Use a still image or `still`.

## Installation

Install it with the shadcn CLI, into components/mosaic-poster of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/mosaic-poster.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/mosaic-poster/stone-arch.webp

## Usage

```tsx
import { MosaicPoster } from './mosaic-poster';

// The picture is read on a canvas: use an image from your own origin.
export function ViewPoster() {
  return (
    <MosaicPoster
      src="/images/valley.webp"
      alt="A valley at dusk, under low cloud."
      headline={'Slow down.\nThe view\ncomes back.'}
    />
  );
}
```

## States

- **Whole**: Before the canvas takes over, or when the picture’s pixels can’t be read: the `<img>` behind a CSS mask of square holes, lined up with the cells.
- **Dissolved, still**: With `still` (and under reduced motion) the field holds: about 30% of the cells gone in soft blotches, no drift, no sparkles.
- **Drifting**: The default: the blotches slide and breathe and a few sparkles come and go. This one keeps moving; it can’t be held.
- **Light**: With `colorScheme="light"`: a warm paper on a pale ground, whatever the page’s scheme.
- **Dark**: With `colorScheme="dark"`: an olive paper on a dark ground, light type. The cells keep the picture’s colours.
- **Coarse cells**: `cellSize={12}`: fewer, larger cells, a more graphic picture; at `4` it reads almost as a photograph. Cells are always 1 px apart.

## How it works

- The picture is sampled into a grid of 6 px cells, 1 px apart, each taking the average colour of the picture under it, fitted like object-fit: cover. The whole cells are painted once on a canvas of their own; each frame copies it in one go, then redraws only the cells that are not whole.
- Every frame, each cell reads a 2D value noise of two layers drifting in different directions, so the blotches slide and change shape. The threshold is taken from the sorted noise, so a set share of cells dissolves, breathing between 20 and 40%, and a soft band around it makes cells shrink and fade instead of popping.
- Bringing the picture back lowers the threshold, locally and for a while: under the pointer the trail lingers, then fades; behind a ripple’s front the cells swell and catch the light. As the threshold rises back, the blotches grow again from their hearts.
- Before JavaScript runs, the picture is a plain <img> behind a CSS mask of square holes lined up with the cells; once its colours are read, the canvas takes over in the same frame. The <img> stays in the page for screen readers.
- The loop runs only while something changes and only near the screen; away from it, both canvases give their pixels back. The device pixel ratio is capped at 2.
- The ground, the paper, the type and the controls are light-dark() tokens, so the poster follows the page’s color-scheme, or the colorScheme prop: warm off-white paper by day, dark brown at night. The canvas leaves the gaps and dissolved cells clear so the paper shows through; it reads its only colours of its own, the ripple’s crest and the sparkles, through a hidden probe, again when an unseen 1 ms colour transition ends. The cells keep the picture’s colours.

## API reference

- **src** (`string`, default `'/experiments/mosaic-poster/stone-arch.webp'`): The picture, fitted to the picture area like object-fit: cover. Its pixels are read on a canvas, so it must come from the page’s origin (or be served with CORS headers); otherwise the plain picture stays.
- **alt** (`string`): What the picture shows, for screen readers.
- **headline** (`string`): The headline under the picture; a \n starts a new line.
- **cellSize** (`number`, default `6`): A cell’s side, in px. Cells are 1 px apart.
- **still** (`boolean`, default `false`): Holds the field still, as under reduced motion: one dissolved frame until it is brushed. Setting it turns the demo off.
- **whole** (`boolean`, default `false`): Keeps the picture whole behind its grid of square holes, with no canvas: nothing dissolves. Setting it turns the demo off.
- **onRipple** (`(from: 'pointer' | 'restore' | 'demo') => void`): Called when a ripple goes out: from a click or tap on the picture, from Restore, or from the demo.
- **autoplay** (`boolean`, default `true`): Send a ripple across the poster now and then while it is visible and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The picture is a real <img> with alt text and stays in the page once the canvas takes over; the canvas and the menu mark are hidden from screen readers.
- The Restore button (Tab, then Enter or Space) sends a ripple from the middle, and “Picture restored.” is announced politely.
- Only the picture takes touch-action: none, so a drag on it paints; swiping anywhere else scrolls the page.
- Under reduced motion the field holds still, with no sparkles and no demo; restoring cross-fades the cells in and out.

## Source

### mosaic-poster/mosaic-poster.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import { createMosaic, type Mosaic, type Point } from './mosaic';
import { useAutoplay } from './use-autoplay';
import styles from './mosaic-poster.module.css';

/*
 * Mosaic poster: a portrait poster whose picture is a fine grid of small square cells, each the
 * colour of the picture under it. Blotches of cells dissolve into the poster and come back as a
 * slow noise field drifts over the grid, with a few sparkles; a headline sits under the picture.
 *
 * - The picture is an <img>: it shows, behind a CSS mask of square holes, before JavaScript runs,
 *   and gives screen readers its alt text. Once its colours are read, a <canvas> laid over it
 *   takes over (mosaic.ts) and the <img> steps back.
 * - The pointer is a soft brush: the picture comes back where it passes, then dissolves again
 *   once it has gone. A click or a tap sends a ripple that re-forms the whole picture from that
 *   point outwards; on a touch screen, dragging paints. The Restore button sends one from the
 *   middle.
 * - While on screen and untouched, a ripple goes out now and then. The canvas draws only while
 *   something changes, and only near the screen.
 * - Under reduced motion the field holds still and cells fade in and out without shrinking.
 * - Light or dark: the ground, the poster's paper and type, and the controls are light-dark()
 *   tokens in the CSS, so they follow the page's color-scheme, or the one the colorScheme prop
 *   sets. The cells keep the picture's colours; the paper shows between them.
 */

export type MosaicPosterProps = {
  /** The picture, fitted to the poster's picture area like `object-fit: cover`. Its pixels are
   * read, so it must come from the page's origin (or be served with CORS headers). */
  src?: string;
  /** What the picture shows, for screen readers. */
  alt?: string;
  /** The headline under the picture; a line break (\n) starts a new line. */
  headline?: string;
  /** A cell's side, in px. Cells are 1 px apart. */
  cellSize?: number;
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Holds the noise field still, as under reduced motion (no drift, no sparkles), and turns the
   * demonstration off: the poster stays in one dissolved frame until it is brushed. */
  still?: boolean;
  /** Keeps the picture whole behind its grid of square holes, the look before the canvas takes
   * over: nothing dissolves, and the brush and ripples have nothing to do. Turns the demo off. */
  whole?: boolean;
  /** Called when a ripple goes out: from a click or a tap on the picture, from Restore, or from
   * the demonstration. */
  onRipple?: (from: 'pointer' | 'restore' | 'demo') => void;
};

const DEFAULT_SRC = '/experiments/mosaic-poster/stone-arch.webp';
const DEFAULT_ALT =
  'A stone arch spans a valley filled with cloud at dusk, two walkers standing on top of it.';
const DEFAULT_HEADLINE = 'Let the noise\nsettle. The view\ncomes back.';

/** The demo's ripples: the first a little after it comes on screen, then one every few seconds. */
const DEMO_FIRST = 7000;
const DEMO_EVERY = 9000;
/** A press that travels less than this (px) is a click or a tap, not a stroke. */
const TAP_SLOP = 6;

export function MosaicPoster({
  src = DEFAULT_SRC,
  alt = DEFAULT_ALT,
  headline = DEFAULT_HEADLINE,
  cellSize = 6,
  className = '',
  autoplay = true,
  colorScheme,
  still = false,
  whole = false,
  onRipple,
}: MosaicPosterProps) {
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched.
  const playing = autoplay && !still && !whole && unattended;
  const onRippleRef = useRef(onRipple);
  useEffect(() => {
    onRippleRef.current = onRipple;
  });
  const picture = useRef<HTMLDivElement>(null);
  const image = useRef<HTMLImageElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const mosaic = useRef<Mosaic | null>(null);
  /** The pointer pressed on the picture, where, and whether it has moved since. */
  const press = useRef<{ id: number; at: Point; moved: boolean } | null>(null);
  /** The brush's last point, so the stroke is painted in between. */
  const brushAt = useRef<Point | null>(null);
  const [message, setMessage] = useState('');
  const hintId = useId();

  // The mosaic: laid out to the picture's box, fed the picture once it has loaded, and running
  // only near the screen.
  useEffect(() => {
    const box = picture.current;
    const img = image.current;
    const surface = canvas.current;
    if (!box || !img || !surface) return;
    const engine = createMosaic(surface, {
      cellSize,
      // Straight on the element, in the same frame as the canvas fills or empties.
      onReady: (ready) => root.current?.toggleAttribute('data-ready', ready),
      // The <img>'s mask of holes lines up with the cells, so the hand-over is seamless.
      onLayout: ({ x, y }) => {
        img.style.setProperty('--grid-x', `${x}px`);
        img.style.setProperty('--grid-y', `${y}px`);
      },
    });
    mosaic.current = engine;

    const sizes = new ResizeObserver(([entry]) =>
      engine.resize(entry.contentRect.width, entry.contentRect.height),
    );
    sizes.observe(box);
    const nearby = new IntersectionObserver(([entry]) => engine.setOnScreen(entry.isIntersecting), {
      rootMargin: '120px 0px',
    });
    nearby.observe(box);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => engine.setReducedMotion(still || motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    // Pixels from another origin cannot be read: then the <img> simply stays.
    const load = () => {
      if (img.naturalWidth > 0 && !whole) engine.setImage(img);
    };
    if (img.complete) load();
    img.addEventListener('load', load);

    return () => {
      sizes.disconnect();
      nearby.disconnect();
      motion.removeEventListener('change', readMotion);
      img.removeEventListener('load', load);
      engine.destroy();
      mosaic.current = null;
    };
  }, [src, cellSize, root, still, whole]);

  // The demo: while on screen and untouched, a ripple now and then, from somewhere in the picture.
  useEffect(() => {
    if (!playing) return;
    let timer = 0;
    const next = (delay: number) => {
      timer = window.setTimeout(() => {
        const box = picture.current;
        if (box) {
          mosaic.current?.ripple({
            x: box.clientWidth * (0.2 + 0.6 * Math.random()),
            y: box.clientHeight * (0.25 + 0.5 * Math.random()),
          });
          onRippleRef.current?.('demo');
        }
        next(DEMO_EVERY);
      }, delay);
    };
    next(DEMO_FIRST);
    return () => window.clearTimeout(timer);
  }, [playing]);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const restore = () => {
    take();
    mosaic.current?.ripple();
    onRipple?.('restore');
    announce('Picture restored.');
  };

  /** The pointer's position in the picture's px (even inside a scaled container). */
  const pointIn = (event: PointerEvent<HTMLDivElement>): Point => {
    const element = event.currentTarget;
    const box = element.getBoundingClientRect();
    const scale = box.width / element.offsetWidth || 1;
    return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
  };

  const paint = (event: PointerEvent<HTMLDivElement>, point: Point) => {
    mosaic.current?.brush(brushAt.current, point, event.pointerType === 'touch');
    brushAt.current = point;
  };

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
    const point = pointIn(event);
    press.current = { id: event.pointerId, at: point, moved: false };
    if (event.pointerType === 'touch') {
      // A finger paints from where it lands, and keeps painting outside the picture's box.
      event.currentTarget.setPointerCapture(event.pointerId);
      brushAt.current = null;
      paint(event, point);
    }
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    // A mouse or a pen paints as it hovers; a finger only while it touches.
    if (event.pointerType === 'touch' && current?.id !== event.pointerId) return;
    const point = pointIn(event);
    if (current?.id === event.pointerId && !current.moved) {
      current.moved = Math.hypot(point.x - current.at.x, point.y - current.at.y) > TAP_SLOP;
    }
    take();
    paint(event, point);
  };

  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (current?.id !== event.pointerId) return;
    press.current = null;
    if (!current.moved) {
      mosaic.current?.ripple(pointIn(event));
      onRipple?.('pointer');
    }
    if (event.pointerType === 'touch') brushAt.current = null;
  };

  const onPointerCancel = () => {
    press.current = null;
    brushAt.current = null;
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-part="root"
      role="group"
      aria-label="Mosaic poster"
      aria-describedby={hintId}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.stage}>
        <div className={styles.poster} data-part="poster">
          <div
            ref={picture}
            className={styles.picture}
            data-part="picture"
            style={{ '--cell': `${cellSize}px`, '--pitch': `${cellSize + 1}px` } as CSSProperties}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            onPointerCancel={onPointerCancel}
            onPointerLeave={(event) => {
              // A finger keeps its capture; a mouse or a pen starts a new stroke when it is back.
              if (event.pointerType === 'touch') return;
              press.current = null;
              brushAt.current = null;
            }}
          >
            <img
              ref={image}
              className={styles.image}
              src={src}
              alt={alt}
              draggable={false}
              decoding="async"
            />
            <canvas
              ref={canvas}
              className={styles.canvas}
              width={0}
              height={0}
              aria-hidden="true"
            />
          </div>
          <div className={styles.footer}>
            <p className={styles.headline} data-part="headline">
              {headline}
            </p>
            <svg className={styles.menu} data-part="menu" viewBox="0 0 24 15" aria-hidden="true">
              <rect width="24" height="2.6" rx="0.6" />
              <rect y="6.2" width="24" height="2.6" rx="0.6" />
              <rect y="12.4" width="24" height="2.6" rx="0.6" />
            </svg>
          </div>
        </div>

        <div className={styles.controls}>
          <p id={hintId} className={styles.hint} data-part="hint">
            <span className={styles.forPointer}>
              Hover to bring the view back. Click for a ripple.
            </span>
            <span className={styles.forTouch}>Drag to bring the view back. Tap for a ripple.</span>
          </p>
          <button type="button" className={styles.restore} data-part="restore" onClick={restore}>
            <svg viewBox="0 0 16 16" aria-hidden="true">
              <circle cx="8" cy="8" r="1.8" fill="currentColor" />
              <circle cx="8" cy="8" r="4.4" />
              <circle cx="8" cy="8" r="6.9" opacity="0.45" />
            </svg>
            Restore
          </button>
        </div>
      </div>

      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### mosaic-poster/mosaic-poster.module.css

```css
/* Mosaic poster (mosaic-poster.tsx): a ground with a portrait poster in the middle, its picture
   over a headline, and under it a hint and the Restore button. The poster's type is sized in
   container units, so the poster keeps its proportions at any width.
   Nothing around the canvas clips (no overflow: hidden with a border-radius): on an iPhone, a
   rounded clip around a canvas costs a composited layer and a mask the size of the box.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). Light, a warm paper on a pale ground; dark, an
   olive one on a dark ground. The cells keep the picture's colours on both. */

.root {
  /* The ground, at its edges and in its lit middle; the poster's paper, which shows between the
     cells and where they dissolve. */
  --surface: #ebe5d7;
  --surface-lit: #f7f3ea;
  --surface-2: #fcfaf4;
  /* The headline, Restore's label and the focus ring; the hint. */
  --ink: #2a2217;
  --ink-muted: rgb(42 34 23 / 0.7);
  /* Restore's edge and fill, at rest and hovered. */
  --line: rgb(42 34 23 / 0.18);
  --line-hover: rgb(42 34 23 / 0.32);
  --fill: rgb(255 255 255 / 0.7);
  --fill-hover: #fff;
  /* The menu mark. */
  --accent: #94761f;
  /* The poster's hairline edge, and its shadow. */
  --edge: rgb(60 45 20 / 0.08);
  --shadow: rgb(80 60 25 / 0.32);
  /* What the canvas paints that is not the picture (mosaic.ts reads them): the light on a
     ripple's crest, and a sparkle's heart, glow and edge. */
  --crest-light: rgb(255 226 160);
  --glint: rgb(255 226 160);
  --glint-glow: rgb(246 200 110);
  --glint-edge: rgb(236 180 80);
  --width: 420px;
  position: relative;
  container-type: inline-size;
  border-radius: 18px;
  background: radial-gradient(120% 80% at 50% 36%, var(--surface-lit), var(--surface) 72%);
  color: var(--ink);
  font:
    400 13px/1.45 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#ebe5d7, #1e1910);
    --surface-lit: light-dark(#f7f3ea, #2a2317);
    --surface-2: light-dark(#fcfaf4, #33291b);
    --ink: light-dark(#2a2217, #ede2c4);
    --ink-muted: light-dark(rgb(42 34 23 / 0.7), rgb(237 226 196 / 0.56));
    --line: light-dark(rgb(42 34 23 / 0.18), rgb(237 226 196 / 0.16));
    --line-hover: light-dark(rgb(42 34 23 / 0.32), rgb(237 226 196 / 0.3));
    --fill: light-dark(rgb(255 255 255 / 0.7), rgb(237 226 196 / 0.06));
    --fill-hover: light-dark(#fff, rgb(237 226 196 / 0.12));
    --accent: light-dark(#94761f, #e6d49b);
    --edge: light-dark(rgb(60 45 20 / 0.08), rgb(255 238 200 / 0.05));
    --shadow: light-dark(rgb(80 60 25 / 0.32), rgb(0 0 0 / 0.75));
    --crest-light: light-dark(rgb(255 226 160), rgb(255 242 208));
    --glint: light-dark(rgb(255 226 160), rgb(255 250 232));
    --glint-glow: light-dark(rgb(246 200 110), rgb(255 240 200));
    --glint-edge: light-dark(rgb(236 180 80), rgb(255 228 172));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  padding: 28px 16px 18px;
}

/* The poster: 3 by 4, the picture on top, the headline at the bottom. Its corners are barely
   rounded, so the picture needs no clipping. */
.poster {
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  width: min(100%, var(--width));
  aspect-ratio: 3 / 4;
  border-radius: 2px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--edge),
    0 28px 48px -26px var(--shadow);
}

/* The picture: the brush paints on it, so it takes touch-action: none (and only it). */
.picture {
  position: relative;
  flex: none;
  height: 80cqi;
  touch-action: none;
}
.image,
.canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* A change of colour scheme runs this unseen transition, which tells the mosaic to read its
   colours again (mosaic.ts). */
.canvas {
  color: var(--glint);
  transition: color 1ms;
}
/* Before the canvas: the picture behind a grid of square holes, lined up with the cells. */
.image {
  object-fit: cover;
  -webkit-mask-image:
    linear-gradient(90deg, #000 var(--cell), transparent 0),
    linear-gradient(#000 var(--cell), transparent 0);
  mask-image:
    linear-gradient(90deg, #000 var(--cell), transparent 0),
    linear-gradient(#000 var(--cell), transparent 0);
  -webkit-mask-size: var(--pitch) var(--pitch);
  mask-size: var(--pitch) var(--pitch);
  -webkit-mask-position: var(--grid-x, 50%) var(--grid-y, 50%);
  mask-position: var(--grid-x, 50%) var(--grid-y, 50%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}
/* Once the canvas shows the picture (it is empty until then): the <img> steps back, still read
   by screen readers. */
.root[data-ready] .image {
  opacity: 0;
}

.footer {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 4cqi;
  margin-top: auto;
  padding: 0 7cqi 7.2cqi;
}
.headline {
  margin: 0;
  font:
    400 9.4cqi/1.04 ui-serif,
    Georgia,
    serif;
  letter-spacing: -0.015em;
  white-space: pre-line;
  color: var(--ink);
}
.menu {
  flex: none;
  width: 6.6cqi;
  margin-bottom: 1.5cqi;
  fill: var(--accent);
}

/* Under the poster: the hint, and the Restore button. */
.controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: min(100%, var(--width));
}
.hint {
  margin: 0;
  font-size: 12.5px;
  color: var(--ink-muted);
  text-wrap: pretty;
}
.forTouch {
  display: none;
}
@media (hover: none) {
  .forPointer {
    display: none;
  }
  .forTouch {
    display: inline;
  }
}
.restore {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 34px;
  padding: 0 14px 0 11px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--fill);
  color: var(--ink);
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    border-color 0.2s ease,
    transform 0.12s ease;
}
@media (hover: hover) {
  .restore:hover {
    border-color: var(--line-hover);
    background: var(--fill-hover);
  }
}
.restore:active {
  transform: scale(0.97);
}
.restore:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
/* A ripple: a dot and two rings. */
.restore svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.3;
}

/* For screen readers only: the announcements. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (min-width: 600px) {
  .stage {
    gap: 18px;
    padding: 44px 32px 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .restore {
    transition: none;
  }
}
```

### mosaic-poster/mosaic.ts

```tsx
import { hash, valueNoise } from './noise';

/*
 * The mosaic: a picture drawn on a <canvas> as a grid of small square cells, each the colour of
 * the picture under it, that dissolves in slow, organic blotches and comes back.
 *
 * - The grid: cells of `cellSize` px, 1 px apart, centred in the canvas. Each cell takes the
 *   average colour of the picture under it, the picture fitted like `object-fit: cover`.
 * - The blotches: each frame, every cell reads a 2D value noise (noise.ts), two layers of it
 *   drifting in different directions, so the shapes slide and change as they go. Cells below a
 *   threshold dissolve; the threshold is picked each frame so that a set share of them does (it
 *   breathes slowly). Around the threshold, a soft band makes cells shrink and fade rather than
 *   pop, and a pinch of randomness per cell makes the blotches' edges ragged.
 * - Bringing it back lowers the threshold, locally and for a while: under the brush, and behind a
 *   ripple's front, which swells the cells it passes (the crest). As the threshold rises back, the
 *   blotches grow again from their hearts, so the noise takes over the way it left.
 * - Drawing: the whole cells are painted once on a canvas of their own, rounded corners and all.
 *   Each frame copies it in one go, clears the cells that are not whole and draws them smaller,
 *   then the crest and a few sparkles: all in device pixels, so the 1 px gaps stay crisp.
 * - The loop runs only while something changes (the drift, a trail, a ripple, a sparkle), and
 *   only near the screen. Away from it, both canvases give their pixels back.
 * - The gaps and the dissolved cells are left clear, so the poster's paper (CSS) shows through.
 *   The few colours the canvas paints that are not the picture's (the crest's light, the
 *   sparkles) are CSS tokens, read from the computed style: once, and again when the colour
 *   scheme changes, which runs the canvas's unseen colour transition.
 */

export type Point = { x: number; y: number };

export type MosaicOptions = {
  /** A cell's side, in CSS px. */
  cellSize: number;
  /** Called when the canvas starts (true) or stops (false) showing the picture. */
  onReady: (ready: boolean) => void;
  /** Called with the grid's top-left corner, in CSS px, each time it is laid out. */
  onLayout?: (corner: Point) => void;
};

export type Mosaic = {
  /** Lays the grid out for a canvas of `width` × `height` CSS px. */
  resize: (width: number, height: number) => void;
  /** Takes the picture's colours; false when its pixels cannot be read (another origin). */
  setImage: (image: HTMLImageElement) => boolean;
  /** Near the screen or not: away from it nothing runs, and the canvases hold no pixels. */
  setOnScreen: (onScreen: boolean) => void;
  /** Under reduced motion the field holds still, cells fade without shrinking, no sparkles. */
  setReducedMotion: (reduced: boolean) => void;
  /** Brings the picture back along a stroke from `from` to `to`, in CSS px; wider for a finger. */
  brush: (from: Point | null, to: Point, wide: boolean) => void;
  /** Sends a ripple from `at`, in CSS px (the middle by default), that re-forms the picture. */
  ripple: (at?: Point) => void;
  destroy: () => void;
};

type Ripple = {
  x: number;
  y: number;
  /** When it started, on the mosaic's clock (s). */
  start: number;
  /** The front's speed in px per s, or 0: everywhere at once (reduced motion, the opening). */
  speed: number;
  /** How long the picture stays whole once the front has passed, in s. */
  hold: number;
  /** When the noise has taken over again (s). */
  end: number;
};

type Sparkle = { x: number; y: number; start: number; life: number; size: number };

/** The gap between cells, in CSS px. */
const GAP = 1;
/** The broad layer of noise: lattice squares across the picture's width, and its drift (per s). */
const SCALE = 3;
const DRIFT: Point = { x: 0.1, y: -0.06 };
/** The fine layer: this much finer, turned by this angle (rad), its share, and its drift. */
const DETAIL = 2.1;
const DETAIL_TURN = 0.6;
const DETAIL_SHARE = 0.3;
const DETAIL_DRIFT: Point = { x: -0.14, y: 0.09 };
/** The share of cells dissolved: it breathes from SHARE - BREATH to SHARE + BREATH and back,
 * every BREATH_PERIOD s, starting low so the picture comes apart gently. */
const SHARE = 0.3;
const BREATH = 0.1;
const BREATH_PERIOD = 14;
/** Half the width of the band round the threshold where cells shrink and fade. */
const SOFTNESS = 0.028;
/** Randomness per cell (ragged edges), and how much more the picture's edges dissolve. */
const DITHER = 0.04;
const EDGE = 0.035;
/** A dissolving cell's smallest size, as a share of a whole one. */
const SMALLEST = 0.3;

/** The brush's radius: a share of the picture's width, within bounds (px); wider for a finger. */
const BRUSH = 0.09;
const BRUSH_MIN = 26;
const BRUSH_MAX = 46;
const FINGER = 1.35;
/** A brushed cell's trail starts above 1, so it holds a while, then loses TRAIL_DECAY a second. */
const TRAIL = 1.7;
const TRAIL_DECAY = 0.85;
/** How quickly a brushed cell comes back, in s (a time constant). */
const GROW = 0.06;

/** How long a ripple's front takes to cross the picture's diagonal, in s. */
const CROSSING = 1.05;
/** The picture stays whole for HOLD s behind the front, then the noise takes over in RELEASE s. */
const HOLD = 1.2;
const RELEASE = 1.8;
/** Without a front, the picture returns in FADE_IN s. When it first shows, it holds OPENING s. */
const FADE_IN = 0.3;
const OPENING = 1.4;
/** The crest: its half-width (px), how much it swells the cells, and how bright its light is. */
const CREST = 15;
const SWELL = 0.38;
const CREST_GLOW = 0.3;

/** Sparkles: at most this many at once, born at this rate (per s), each living 1 to 2 × LIFE s. */
const SPARKLES = 5;
const SPARKLE_RATE = 1.8;
const SPARKLE_LIFE = 0.9;

const smoothstep = (from: number, to: number, value: number) => {
  const t = Math.min(1, Math.max(0, (value - from) / (to - from)));
  return t * t * (3 - 2 * t);
};

/** The colours the canvas paints that are not the picture's, and the CSS tokens they come from. */
const TOKENS = {
  crest: '--crest-light',
  heart: '--glint',
  glow: '--glint-glow',
  edge: '--glint-edge',
};
type Palette = Record<keyof typeof TOKENS, string>;

/**
 * The colours as the CSS resolves them here: each token is read through the probe's colour, so
 * light-dark() follows the canvas's color-scheme. They come as rgb() or rgba().
 */
function readPalette(probe: HTMLElement) {
  const style = getComputedStyle(probe);
  const palette = {} as Palette;
  for (const [key, token] of Object.entries(TOKENS)) {
    probe.style.color = `var(${token})`;
    palette[key as keyof Palette] = style.color;
  }
  return palette;
}

/** A colour with its alpha multiplied by `alpha`; a colour that is not rgb() or rgba() is kept. */
function fade(colour: string, alpha: number) {
  const values = colour.startsWith('rgb') ? colour.match(/[\d.]+/g) : null;
  if (!values || values.length < 3) return colour;
  const [red, green, blue, opacity = '1'] = values;
  return `rgba(${red}, ${green}, ${blue}, ${Number(opacity) * alpha})`;
}

/** A soft, warm point of light, painted on `sprite` (32 × 32) and stamped for each sparkle. */
function paintGlint(sprite: HTMLCanvasElement, { heart, glow, edge }: Palette) {
  const pen = sprite.getContext('2d');
  if (!pen) return;
  pen.clearRect(0, 0, sprite.width, sprite.height);
  const light = pen.createRadialGradient(16, 16, 0, 16, 16, 16);
  light.addColorStop(0, fade(heart, 1));
  light.addColorStop(0.14, fade(glow, 0.8));
  light.addColorStop(0.42, fade(edge, 0.16));
  light.addColorStop(1, fade(edge, 0));
  pen.fillStyle = light;
  pen.fillRect(0, 0, 32, 32);
}

export function createMosaic(
  canvas: HTMLCanvasElement,
  { cellSize, onReady, onLayout }: MosaicOptions,
): Mosaic {
  const pitch = cellSize + GAP;
  /** The whole cells, painted once per layout. */
  const tiles = document.createElement('canvas');
  // The colours' probe: hidden, inside the canvas (fallback content, never drawn).
  const probe = document.createElement('span');
  probe.hidden = true;
  canvas.append(probe);
  let palette = readPalette(probe);
  const glint = document.createElement('canvas');
  glint.width = 32;
  glint.height = 32;
  paintGlint(glint, palette);

  // The layout, in CSS px, and the canvas's pixels per CSS px.
  let width = 0;
  let height = 0;
  let ratio = 1;
  let columns = 0;
  let rows = 0;
  let count = 0;
  let corner: Point = { x: 0, y: 0 };
  /** A cell's side, in device px. */
  let side = 0;

  // Each cell: its top-left corner (device px), its centre (CSS px), where it reads the two
  // layers of noise, its own offset to the noise (dither and edge), the noise this frame, its
  // brush trail and how much of the trail shows, and its colour.
  let cellX = new Float32Array(0);
  let cellY = new Float32Array(0);
  let centreX = new Float32Array(0);
  let centreY = new Float32Array(0);
  let broadX = new Float32Array(0);
  let broadY = new Float32Array(0);
  let detailX = new Float32Array(0);
  let detailY = new Float32Array(0);
  let bias = new Float32Array(0);
  let field = new Float32Array(0);
  let trail = new Float32Array(0);
  let shown = new Float32Array(0);
  let colors: string[] = [];
  /** The noise, sorted: where the threshold is. */
  let ordered = new Float32Array(0);
  /** The cells on a ripple's crest this frame, and how much. */
  let crestCells = new Int32Array(0);
  let crestAmounts = new Float32Array(0);

  let image: HTMLImageElement | null = null;
  /** The colours are read for the current layout. */
  let sampled = false;
  let onScreen = false;
  let reduced = false;
  let ready = false;
  /** The first time the picture shows, it starts whole and lets the noise in. */
  let opening = true;
  /** Seconds the loop has run: `clock` for everything, `drift` for the noise (which holds still
   * under reduced motion). */
  let clock = 0;
  let drift = 0;
  let frame = 0;
  let last = 0;
  let trailing = false;
  let ripples: Ripple[] = [];
  let sparkles: Sparkle[] = [];

  const setReady = (value: boolean) => {
    if (ready === value) return;
    ready = value;
    onReady(value);
  };

  function layout() {
    columns = Math.max(0, Math.floor((width + GAP) / pitch));
    rows = Math.max(0, Math.floor((height + GAP) / pitch));
    count = columns * rows;
    corner = {
      x: Math.floor((width - (columns * pitch - GAP)) / 2),
      y: Math.floor((height - (rows * pitch - GAP)) / 2),
    };
    side = Math.round(cellSize * ratio);
    cellX = new Float32Array(count);
    cellY = new Float32Array(count);
    centreX = new Float32Array(count);
    centreY = new Float32Array(count);
    broadX = new Float32Array(count);
    broadY = new Float32Array(count);
    detailX = new Float32Array(count);
    detailY = new Float32Array(count);
    bias = new Float32Array(count);
    field = new Float32Array(count);
    trail = new Float32Array(count);
    shown = new Float32Array(count);
    ordered = new Float32Array(count);
    crestCells = new Int32Array(count);
    crestAmounts = new Float32Array(count);
    trailing = false;

    const cos = Math.cos(DETAIL_TURN);
    const sin = Math.sin(DETAIL_TURN);
    for (let row = 0; row < rows; row++) {
      for (let column = 0; column < columns; column++) {
        const k = row * columns + column;
        const x = corner.x + column * pitch;
        const y = corner.y + row * pitch;
        cellX[k] = Math.round(x * ratio);
        cellY[k] = Math.round(y * ratio);
        const cx = x + cellSize / 2;
        const cy = y + cellSize / 2;
        centreX[k] = cx;
        centreY[k] = cy;
        // The noise is measured in shares of the width, so blotches keep their size on any screen.
        const u = (cx / width) * SCALE;
        const v = (cy / width) * SCALE;
        broadX[k] = u;
        broadY[k] = v;
        detailX[k] = (u * cos - v * sin) * DETAIL + 19.3;
        detailY[k] = (u * sin + v * cos) * DETAIL + 7.1;
        const ex = (cx / width) * 2 - 1;
        const ey = (cy / height) * 2 - 1;
        bias[k] = (hash(column, row, 3) - 0.5) * DITHER - (EDGE * (ex * ex + ey * ey)) / 2;
      }
    }
    onLayout?.(corner);
  }

  /** Reads each cell's colour: the picture is drawn 4 × 4 px per cell, then each block averaged. */
  function sample() {
    if (!image || !count || !image.naturalWidth || !image.naturalHeight) return false;
    const per = 4;
    const probe = document.createElement('canvas');
    probe.width = columns * per;
    probe.height = rows * per;
    const pen = probe.getContext('2d', { willReadFrequently: true });
    if (!pen) return false;
    // Fitted like object-fit: cover, then the grid's box cut out of it.
    const scale = Math.max(width / image.naturalWidth, height / image.naturalHeight);
    pen.imageSmoothingQuality = 'high';
    pen.drawImage(
      image,
      (image.naturalWidth - width / scale) / 2 + corner.x / scale,
      (image.naturalHeight - height / scale) / 2 + corner.y / scale,
      (columns * pitch - GAP) / scale,
      (rows * pitch - GAP) / scale,
      0,
      0,
      probe.width,
      probe.height,
    );
    let data: Uint8ClampedArray;
    try {
      data = pen.getImageData(0, 0, probe.width, probe.height).data;
    } catch {
      return false;
    } finally {
      probe.width = 0;
      probe.height = 0;
    }
    colors = new Array<string>(count);
    const stride = columns * per * 4;
    for (let k = 0; k < count; k++) {
      const first = Math.floor(k / columns) * per * stride + (k % columns) * per * 4;
      let red = 0;
      let green = 0;
      let blue = 0;
      for (let y = 0; y < per; y++) {
        for (let x = 0; x < per; x++) {
          const i = first + y * stride + x * 4;
          red += data[i];
          green += data[i + 1];
          blue += data[i + 2];
        }
      }
      const n = per * per;
      colors[k] = `rgb(${Math.round(red / n)}, ${Math.round(green / n)}, ${Math.round(blue / n)})`;
    }
    return true;
  }

  /** Sizes both canvases to the layout and paints the whole cells. */
  function allocate() {
    canvas.width = Math.round(width * ratio);
    canvas.height = Math.round(height * ratio);
    tiles.width = canvas.width;
    tiles.height = canvas.height;
    const pen = tiles.getContext('2d');
    if (!pen) return;
    const radius = side * 0.2;
    // Square corners where roundRect is missing (older browsers).
    const rounded = typeof pen.roundRect === 'function';
    for (let k = 0; k < count; k++) {
      pen.fillStyle = colors[k];
      pen.beginPath();
      if (rounded) pen.roundRect(cellX[k], cellY[k], side, side, radius);
      else pen.rect(cellX[k], cellY[k], side, side);
      pen.fill();
    }
  }

  function release() {
    canvas.width = 0;
    canvas.height = 0;
    tiles.width = 0;
    tiles.height = 0;
    setReady(false);
  }

  /** Puts the picture on the canvas, if it is near the screen and its colours are read. */
  function show() {
    if (!onScreen || !sampled || !count) return;
    allocate();
    if (opening) {
      // Whole at first, as the picture was before the canvas, then the noise comes in (at once
      // under reduced motion).
      opening = false;
      const start = clock - FADE_IN;
      const end = start + OPENING + RELEASE;
      if (!reduced) ripples.push({ x: 0, y: 0, start, speed: 0, hold: OPENING, end });
    }
    draw();
    setReady(true);
    wake();
  }

  function draw() {
    const pen = canvas.getContext('2d');
    if (!pen || !canvas.width || !count) return;
    pen.globalCompositeOperation = 'source-over';
    pen.globalAlpha = 1;
    pen.clearRect(0, 0, canvas.width, canvas.height);
    pen.drawImage(tiles, 0, 0);

    // The noise under every cell, and the threshold under which a SHARE of the cells dissolve.
    const bx = drift * DRIFT.x;
    const by = drift * DRIFT.y;
    const dx = drift * DETAIL_DRIFT.x;
    const dy = drift * DETAIL_DRIFT.y;
    for (let k = 0; k < count; k++) {
      field[k] =
        (1 - DETAIL_SHARE) * valueNoise(broadX[k] + bx, broadY[k] + by) +
        DETAIL_SHARE * valueNoise(detailX[k] + dx, detailY[k] + dy, 7) +
        bias[k];
    }
    ordered.set(field);
    ordered.sort();
    const breath = reduced ? 0 : -BREATH * Math.cos((drift / BREATH_PERIOD) * Math.PI * 2);
    const threshold = ordered[Math.floor((SHARE + breath) * (count - 1))];
    // How far the threshold has to drop for every cell to show: the depth of a full restore.
    const depth = threshold - ordered[0] + 2 * SOFTNESS;
    const diagonal = Math.hypot(width, height);
    let crests = 0;

    for (let k = 0; k < count; k++) {
      // How much of the picture comes back here, from 0 (the noise alone) to 1 (whole).
      let lift = Math.min(1, shown[k]);
      let crest = 0;
      for (const ripple of ripples) {
        let since = clock - ripple.start;
        let rise = smoothstep(0, FADE_IN, since);
        if (ripple.speed) {
          // How far behind the front this cell is (px): the crest is centred on the front.
          const ox = centreX[k] - ripple.x;
          const oy = centreY[k] - ripple.y;
          const distance = Math.sqrt(ox * ox + oy * oy);
          const behind = since * ripple.speed - distance;
          if (behind < -3 * CREST) continue;
          since = behind / ripple.speed;
          rise = smoothstep(-6, 24, behind);
          const swell = Math.exp(-((behind / CREST) ** 2)) * (1 - (0.5 * distance) / diagonal);
          if (swell > crest) crest = swell;
        }
        const held = rise * (1 - smoothstep(ripple.hold, ripple.hold + RELEASE, since));
        if (held > lift) lift = held;
      }
      if (crest > 0.03) {
        crestCells[crests] = k;
        crestAmounts[crests++] = crest;
      }

      // The threshold drops by `lift`, so blotches shrink back to their hearts and grow from them
      // again; under reduced motion the dissolved cells simply fade in and out, all together.
      const edge = threshold - (reduced ? 0 : lift * depth);
      let visible = smoothstep(edge - SOFTNESS, edge + SOFTNESS, field[k]);
      if (reduced) visible += (1 - visible) * lift;
      // Whole cells are already there, from the tiles; the others are cleared, then drawn smaller.
      if (visible > 0.995) continue;
      pen.clearRect(cellX[k], cellY[k], side, side);
      if (visible < 0.01) continue;
      // In whole device pixels, so the smaller cells stay as crisp as the whole ones.
      const size = reduced ? side : Math.round(side * (SMALLEST + (1 - SMALLEST) * visible));
      const inset = (side - size) >> 1;
      pen.globalAlpha = reduced ? visible : Math.min(1, visible * 1.8);
      pen.fillStyle = colors[k];
      pen.fillRect(cellX[k] + inset, cellY[k] + inset, size, size);
    }

    // The crest: the cells swell as a ripple's front passes, over their gaps, and catch the light.
    for (let pass = 0; pass < 2 && crests; pass++) {
      if (pass) pen.fillStyle = palette.crest;
      for (let i = 0; i < crests; i++) {
        const k = crestCells[i];
        const amount = crestAmounts[i];
        const size = side * (1 + SWELL * amount);
        const inset = (side - size) / 2;
        if (pass) {
          pen.globalAlpha = CREST_GLOW * amount;
        } else {
          pen.globalAlpha = amount;
          pen.fillStyle = colors[k];
        }
        pen.fillRect(cellX[k] + inset, cellY[k] + inset, size, size);
      }
    }

    if (sparkles.length) {
      pen.globalCompositeOperation = 'lighter';
      for (const sparkle of sparkles) {
        const age = (clock - sparkle.start) / sparkle.life;
        const size = (7 + 11 * sparkle.size) * ratio;
        pen.globalAlpha = Math.sin(Math.PI * age) ** 2;
        pen.drawImage(
          glint,
          sparkle.x * ratio - size / 2,
          sparkle.y * ratio - size / 2,
          size,
          size,
        );
      }
      pen.globalCompositeOperation = 'source-over';
    }
    pen.globalAlpha = 1;
  }

  /** Moves everything on by `dt` seconds. */
  function advance(dt: number) {
    clock += dt;
    if (!reduced) drift += dt;
    if (trailing) {
      trailing = false;
      const grow = 1 - Math.exp(-dt / GROW);
      for (let k = 0; k < count; k++) {
        if (trail[k] <= 0 && shown[k] <= 0) continue;
        const next = Math.max(0, trail[k] - dt * TRAIL_DECAY);
        trail[k] = next;
        // A cell comes back quickly, and goes as its trail fades.
        shown[k] = next > shown[k] ? shown[k] + (next - shown[k]) * grow : next;
        if (next > 0) trailing = true;
      }
    }
    if (ripples.length) ripples = ripples.filter((ripple) => clock < ripple.end);
    if (sparkles.length)
      sparkles = sparkles.filter((sparkle) => clock < sparkle.start + sparkle.life);
    if (!reduced && sparkles.length < SPARKLES && Math.random() < dt * SPARKLE_RATE) {
      sparkles.push({
        x: Math.random() * width,
        y: Math.random() * height,
        start: clock,
        life: SPARKLE_LIFE * (1 + Math.random()),
        size: Math.random(),
      });
    }
  }

  /** Something still moves: the drift (unless motion is reduced), a trail, a ripple, a sparkle. */
  const busy = () => !reduced || trailing || ripples.length > 0 || sparkles.length > 0;

  function tick(now: number) {
    frame = 0;
    // A long gap (a hidden tab, a slow frame) counts as one ordinary frame.
    const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
    last = now;
    advance(dt);
    draw();
    if (busy()) frame = requestAnimationFrame(tick);
  }

  function wake() {
    if (frame || !ready) return;
    last = 0;
    frame = requestAnimationFrame(tick);
  }

  function stop() {
    cancelAnimationFrame(frame);
    frame = 0;
  }

  // The colour scheme changed: the colours are read again, and the frame drawn in them (a
  // running loop draws the next one anyway).
  const onScheme = (event: TransitionEvent) => {
    if (event.target !== canvas || event.propertyName !== 'color') return;
    palette = readPalette(probe);
    paintGlint(glint, palette);
    if (ready && !frame) draw();
  };
  canvas.addEventListener('transitionend', onScheme);

  return {
    resize(nextWidth, nextHeight) {
      const nextRatio = Math.min(2, window.devicePixelRatio || 1);
      if (nextWidth === width && nextHeight === height && nextRatio === ratio) return;
      width = nextWidth;
      height = nextHeight;
      ratio = nextRatio;
      layout();
      sampled = sample();
      if (!onScreen) return;
      if (sampled) show();
      else {
        stop();
        release();
      }
    },
    setImage(next) {
      // A 1 × 1 read tells whether the picture's pixels can be read at all.
      const probe = document.createElement('canvas');
      probe.width = 1;
      probe.height = 1;
      try {
        const pen = probe.getContext('2d', { willReadFrequently: true });
        pen?.drawImage(next, 0, 0, 1, 1);
        pen?.getImageData(0, 0, 1, 1);
      } catch {
        return false;
      }
      image = next;
      sampled = sample();
      show();
      return true;
    },
    setOnScreen(next) {
      if (next === onScreen) return;
      onScreen = next;
      if (next) show();
      else {
        stop();
        release();
      }
    },
    setReducedMotion(next) {
      reduced = next;
      if (reduced) sparkles = [];
      if (!ready) return;
      draw();
      wake();
    },
    brush(from, to, wide) {
      if (!count) return;
      const radius = Math.min(BRUSH_MAX, Math.max(BRUSH_MIN, width * BRUSH)) * (wide ? FINGER : 1);
      // Dabs along the stroke, a third of the radius apart, so a quick move leaves no gaps.
      const start = from ?? to;
      const length = Math.hypot(to.x - start.x, to.y - start.y);
      const steps = Math.max(1, Math.ceil(length / (radius / 3)));
      for (let step = from ? 1 : steps; step <= steps; step++) {
        const x = start.x + ((to.x - start.x) * step) / steps;
        const y = start.y + ((to.y - start.y) * step) / steps;
        const firstColumn = Math.max(0, Math.floor((x - radius - corner.x) / pitch));
        const lastColumn = Math.min(columns - 1, Math.floor((x + radius - corner.x) / pitch));
        const firstRow = Math.max(0, Math.floor((y - radius - corner.y) / pitch));
        const lastRow = Math.min(rows - 1, Math.floor((y + radius - corner.y) / pitch));
        for (let row = firstRow; row <= lastRow; row++) {
          for (let column = firstColumn; column <= lastColumn; column++) {
            const k = row * columns + column;
            const distance = Math.hypot(centreX[k] - x, centreY[k] - y);
            const strength = TRAIL * (1 - smoothstep(radius * 0.4, radius, distance));
            if (strength > trail[k]) trail[k] = strength;
          }
        }
      }
      trailing = true;
      wake();
    },
    ripple(at) {
      if (!count) return;
      const x = at?.x ?? width / 2;
      const y = at?.y ?? height / 2;
      // The front runs to the farthest corner; the picture holds, then lets the noise back in.
      const reach = Math.max(
        Math.hypot(x, y),
        Math.hypot(width - x, y),
        Math.hypot(x, height - y),
        Math.hypot(width - x, height - y),
      );
      const speed = reduced ? 0 : Math.hypot(width, height) / CROSSING;
      const end = clock + (speed ? reach / speed : 0) + HOLD + RELEASE;
      ripples.push({ x, y, start: clock, speed, hold: HOLD, end });
      wake();
    },
    destroy() {
      stop();
      release();
      canvas.removeEventListener('transitionend', onScheme);
      probe.remove();
      glint.width = 0;
      glint.height = 0;
    },
  };
}
```

### mosaic-poster/noise.ts

```tsx
/*
 * A small 2D value noise. Every point of a unit lattice gets a random value from an integer hash;
 * between them, the four corners of each square are blended with a smooth (quintic) curve. The
 * result is a soft, cloudy field that is cheap to sample and repeatable: the same point always
 * gives the same value, so the whole field can be read anew every frame, at any offset.
 */

/** A repeatable pseudo-random value in [0, 1) for the lattice point (x, y). */
export function hash(x: number, y: number, seed = 0) {
  let h = Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1) ^ Math.imul(seed, 0x9e3779b1);
  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

/** 0 to 1 with zero slope and curvature at both ends, so the squares join without creases. */
const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);

/** The noise at (x, y), in [0, 1]. One lattice square is one unit wide. */
export function valueNoise(x: number, y: number, seed = 0) {
  const left = Math.floor(x);
  const top = Math.floor(y);
  const u = fade(x - left);
  const v = fade(y - top);
  const a = hash(left, top, seed);
  const b = hash(left + 1, top, seed);
  const c = hash(left, top + 1, seed);
  const d = hash(left + 1, top + 1, seed);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
```

### mosaic-poster/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```