Mosaic reveal

A grid of dark tiles spins away in random order to uncover the artwork underneath.

How it works

A pinned artwork is covered by a 12 × 8 grid of tiles. Each tile gets a random start point between 0 and 0.8 of the scroll range; once progress passes it, the tile shrinks and turns over the next 15%. The randomness makes the reveal feel organic while staying fully scrubbable.

Full file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mosaic reveal</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }

body { background: #0e0e12; color: #fff; }
.track { height: 300vh; }
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.art { position: absolute; inset: 0; display: grid; place-items: center;
  background: radial-gradient(circle at 30% 30%, #ffbe0b, transparent 35%), radial-gradient(circle at 75% 65%, #ff006e, transparent 40%), radial-gradient(circle at 50% 100%, #3a86ff, transparent 50%), #8338ec; }
.art h2 { font: 900 clamp(3rem, 14vw, 10rem)/1 system-ui, sans-serif; letter-spacing: -.05em; margin: 0; }
.tiles { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: repeat(8, 1fr); }
.tiles i { background: #0e0e12; border: 1px solid #1c1c24; will-change: transform; }
</style>
</head>
<body>
<section class="track">
  <div class="stage">
    <div class="art"><h2>Mosaic</h2></div>
    <div class="tiles" id="tiles"></div>
  </div>
</section>
<script>
const tiles = document.getElementById('tiles');
const list = [];
for (let i = 0; i < 96; i++) {
  const t = document.createElement('i');
  tiles.appendChild(t);
  list.push({ el: t, at: Math.random() * 0.8 });
}
const track = document.querySelector('.track');
function update() {
  const r = track.getBoundingClientRect();
  const p = Math.min(1, Math.max(0, -r.top / (r.height - innerHeight)));
  list.forEach(o => {
    const k = Math.min(1, Math.max(0, (p - o.at) / 0.15));
    o.el.style.transform = 'scale(' + (1 - k).toFixed(3) + ') rotate(' + (k * 90).toFixed(1) + 'deg)';
  });
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>

More reveal effects