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
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:
npx shadcn@latest add https://brunodelorence.com/r/mosaic-poster.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Copy the media files
To the same paths under your project’s public folder:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | '/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 | 6 | A cell’s side, in px. Cells are 1 px apart. |
still | boolean | false | Holds the field still, as under reduced motion: one dissolved frame until it is brushed. Setting it turns the demo off. |
whole | boolean | 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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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 (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);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/* * 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 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.