Sticky scrollytelling

A pinned graphic changes shape and colour as each step of the story scrolls past beside it.

How it works

The graphic is sticky in one column while step cards scroll in the other. An IntersectionObserver marks the step crossing the middle of the screen, and the graphic picks up that step’s data attributes for shape, colour and label.

Full file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sticky scrollytelling</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: #fafafa; color: #222; }
.wrap { display: grid; grid-template-columns: 1fr 1fr; }
.stage { position: sticky; top: 0; height: 100vh; display: grid; place-items: center; }
.shape {
  width: 40vmin; height: 40vmin; display: grid; place-items: center; color: #fff;
  font: 800 4rem system-ui, sans-serif; background: #ff6b6b; border-radius: 50%;
  transition: background .6s, border-radius .6s, transform .8s cubic-bezier(.3,.7,.2,1);
}
.shape span { transition: transform .8s; }
.steps { padding: 50vh 6vw; }
.step {
  min-height: 80vh; display: flex; align-items: center;
  font: 600 clamp(1.4rem, 3vw, 2.2rem)/1.3 system-ui, sans-serif; opacity: .25; transition: opacity .4s;
}
.step.on { opacity: 1; }
</style>
</head>
<body>
<div class="wrap">
  <div class="stage"><div class="shape" id="shape"><span id="label">1</span></div></div>
  <div class="steps">
    <section class="step" data-color="#ff6b6b" data-radius="50%" data-rot="0">A circle starts the story.</section>
    <section class="step" data-color="#feca57" data-radius="12%" data-rot="45">It becomes a diamond.</section>
    <section class="step" data-color="#1dd1a1" data-radius="0%" data-rot="90">Then a plain square.</section>
    <section class="step" data-color="#54a0ff" data-radius="50% 0" data-rot="180">A leaf, turning.</section>
    <section class="step" data-color="#5f27cd" data-radius="50%" data-rot="360">And back to a circle.</section>
  </div>
</div>
<script>
const shape = document.getElementById('shape');
const label = document.getElementById('label');
const steps = document.querySelectorAll('.step');
const io = new IntersectionObserver(entries => {
  entries.forEach(e => {
    if (!e.isIntersecting) return;
    steps.forEach(s => s.classList.remove('on'));
    const s = e.target, d = s.dataset;
    s.classList.add('on');
    shape.style.background = d.color;
    shape.style.borderRadius = d.radius;
    shape.style.transform = 'rotate(' + d.rot + 'deg)';
    label.style.transform = 'rotate(' + (-d.rot) + 'deg)';
    label.textContent = [...steps].indexOf(s) + 1;
  });
}, { rootMargin: '-50% 0px -50% 0px' });
steps.forEach(s => io.observe(s));
</script>
</body>
</html>

More sticky effects