Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.

mosaic-poster/mosaic-poster.tsx
Live · interactive
A stone arch spans a valley filled with cloud at dusk, two walkers standing on top of it.

Hover to bring the view back. Click for a ripple.Drag to bring the view back. Tap for a ripple.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/mosaic-poster of any React project, with no setup:

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

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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.

Usage

view-poster.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.'}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
srcstring'/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.
altstring—What the picture shows, for screen readers.
headlinestring—The headline under the picture; a \n starts a new line.
cellSizenumber6A cell’s side, in px. Cells are 1 px apart.
stillbooleanfalseHolds the field still, as under reduced motion: one dissolved frame until it is brushed. Setting it turns the demo off.
wholebooleanfalseKeeps 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.
autoplaybooleantrueSend 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.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

mosaic-poster.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.
mosaic-poster.module.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);
mosaic.ts
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
noise.ts
/* * 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);
use-autoplay.ts
'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;
  • 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.