Venetian blinds

A scene swaps for another as horizontal slats rotate in a wave, like blinds being turned.

How it works

The stage is cut into slats. Each slat is a two-sided 3D card: the front shows its strip of the first scene, the back its strip of the second, using background-size and background-position to line the strips up. Scroll progress, staggered per slat, rotates them from 0 to 180°.

Full file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Venetian blinds</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: #14141c; color: #fff; }
.track { height: 300vh; }
.stage { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; perspective: 1400px; }
.blinds { width: min(86vw, 900px); aspect-ratio: 16 / 10; display: flex; flex-direction: column; }
.slat { flex: 1; position: relative; transform-style: preserve-3d; will-change: transform; }
.slat i { position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden; background-size: 100% var(--n00); }
.slat i:last-child { transform: rotateX(180deg); }
.sceneA { background-image: radial-gradient(circle at 75% 30%, #fff1b8 0 7%, transparent 7.5%), linear-gradient(#5ec8f8 0 58%, #2f9e44 58% 75%, #1d6b2f 75%); }
.sceneB { background-image: radial-gradient(circle at 25% 28%, #f1f3ff 0 6%, transparent 6.5%), radial-gradient(1px 1px at 20% 60%, #fff 50%, transparent), linear-gradient(#0b1026 0 58%, #1b2a4a 58% 75%, #0e1830 75%); }
</style>
</head>
<body>
<section class="track">
  <div class="stage"><div class="blinds" id="blinds"></div></div>
</section>
<script>
const N = 12;
const blinds = document.getElementById('blinds');
blinds.style.setProperty('--n00', (N * 100) + '%');
const slats = [];
for (let i = 0; i < N; i++) {
  const s = document.createElement('div');
  s.className = 'slat';
  const pos = '0 ' + (i / (N - 1)) * 100 + '%';
  s.innerHTML = '<i class="sceneA" style="background-position:' + pos + '"></i><i class="sceneB" style="background-position:' + pos + '"></i>';
  blinds.appendChild(s);
  slats.push(s);
}
const track = document.querySelector('.track');
function update() {
  const r = track.getBoundingClientRect();
  const p = Math.min(1, Math.max(0, -r.top / (r.height - innerHeight)));
  slats.forEach((s, i) => {
    const t = Math.min(1, Math.max(0, p * 1.6 - (i / N) * 0.6));
    s.style.transform = 'rotateX(' + (t * 180).toFixed(1) + 'deg)';
  });
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>

More page turn effects