Open demo

CSS code

HTML
<div><span class="lbl">Sticky chart, stepping text</span>
<section class="sty" data-scrolly aria-labelledby="sty-lake-title">
  <ol class="sty-steps">
    <li class="sty-step" data-step="1" aria-current="step"><div><small>2019 to 2020</small><h3>The lake turns green</h3><p>Phosphorus from farm run-off reaches 52 micrograms per litre. Algal blooms close the beach for eleven weeks in 2020.</p></div></li>
    <li class="sty-step" data-step="2"><div><small>2021</small><h3>The flood year</h3><p>A wet spring washes topsoil into the lake and phosphorus peaks at 61. The council and eight farms agree a plan.</p></div></li>
    <li class="sty-step" data-step="3"><div><small>2022 to 2024</small><h3>Buffer strips go in</h3><p>Twelve-metre strips of grass and willow along every stream catch the soil before it reaches the water. Levels fall every year.</p></div></li>
    <li class="sty-step" data-step="4"><div><small>2025 to 2026</small><h3>Swimming again</h3><p>Phosphorus drops below the target of 20 for the first time, and the beach opens for the whole summer.</p></div></li>
  </ol>
  <div class="sty-media">
    <figure class="sty-figure" data-scrolly-figure data-active="1">
      <h2 id="sty-lake-title">Lake Varne, phosphorus</h2>
      <p>Yearly average in micrograms per litre</p>
      <ul class="sty-chart" aria-label="Phosphorus by year">
        <li data-on="1" style="--v:48"><b>48</b><i aria-hidden="true"></i><span class="sty-sr">in 2019</span></li>
        <li data-on="1" style="--v:52"><b>52</b><i aria-hidden="true"></i><span class="sty-sr">in 2020</span></li>
        <li data-on="2" style="--v:61"><b>61</b><i aria-hidden="true"></i><span class="sty-sr">in 2021</span></li>
        <li data-on="3" style="--v:44"><b>44</b><i aria-hidden="true"></i><span class="sty-sr">in 2022</span></li>
        <li data-on="3" style="--v:33"><b>33</b><i aria-hidden="true"></i><span class="sty-sr">in 2023</span></li>
        <li data-on="3" style="--v:27"><b>27</b><i aria-hidden="true"></i><span class="sty-sr">in 2024</span></li>
        <li data-on="4" style="--v:21"><b>21</b><i aria-hidden="true"></i><span class="sty-sr">in 2025</span></li>
        <li data-on="4" style="--v:18"><b>18</b><i aria-hidden="true"></i><span class="sty-sr">in 2026</span></li>
        <li class="sty-target" aria-hidden="true"><span>Target 20</span></li>
      </ul>
      <ol class="sty-years" aria-hidden="true"><li>2019</li><li>2020</li><li>2021</li><li>2022</li><li>2023</li><li>2024</li><li>2025</li><li>2026</li></ol>
      <figcaption class="sty-counter">Step <span data-scrolly-current>1</span> of 4</figcaption>
    </figure>
  </div>
</section></div>

<div><span class="lbl">Product tour inside a scroll container</span>
<div class="sty-box" data-scrolly-root tabindex="0" role="region" aria-label="Ledgerly setup tour, scrollable">
  <section class="sty" data-scrolly aria-labelledby="sty-tour-title">
    <ol class="sty-steps">
      <li class="sty-step" data-step="1" aria-current="step"><div><small>Step 1</small><h3 id="sty-tour-title">Connect your bank</h3><p>Ledgerly reads transactions from 2,400 banks, read-only. Nothing moves money.</p></div></li>
      <li class="sty-step" data-step="2"><div><small>Step 2</small><h3>Import invoices</h3><p>Drop in a CSV or forward invoices to your Ledgerly address. Each one is matched to a payment.</p></div></li>
      <li class="sty-step" data-step="3"><div><small>Step 3</small><h3>Set your VAT rules</h3><p>Pick standard, flat-rate or cash accounting. Returns are drafted every quarter.</p></div></li>
      <li class="sty-step" data-step="4"><div><small>Step 4</small><h3>Invite your accountant</h3><p>They get their own login and can lock a period once it has been filed.</p></div></li>
    </ol>
    <div class="sty-media">
      <figure class="sty-figure sty-app" data-scrolly-figure data-active="1">
        <div class="sty-app-bar" aria-hidden="true"><i></i><i></i><i></i></div>
        <div class="sty-app-body" aria-hidden="true">
          <ul class="sty-app-nav"><li>Bank</li><li>Invoices</li><li>VAT</li><li>Team</li></ul>
          <div class="sty-app-panels">
            <div><strong>Monzo Business ··4471</strong><p>Connected · 312 transactions</p><em class="on"></em><em></em></div>
            <div><strong>48 invoices imported</strong><p>45 matched, 3 to review</p><em class="on"></em><em></em></div>
            <div><strong>VAT: standard rate</strong><p>Next return due 7 May</p><em class="on"></em><em></em></div>
            <div><strong>Priya Shah, ACA</strong><p>Invited as accountant</p><em class="on"></em><em></em></div>
          </div>
        </div>
        <figcaption class="sty-counter">Step <span data-scrolly-current>1</span> of 4</figcaption>
      </figure>
    </div>
  </section>
</div></div>
CSS
/* Sticky scrollytelling: the media column is position: sticky while the steps scroll past it. A small
   IntersectionObserver script marks the step crossing the middle of the scroll area with aria-current="step",
   writes its number into the visible "Step n of 4" counter and sets data-active on the figure, which CSS uses to
   change the picture. Without JavaScript every step is still readable and the figure shows step 1. */
.sty{--sty-ink:#0b2530;--sty-muted:#4c626b;--sty-accent:#0f766e;--sty-soft:#c9dbe0;--sty-line:#d8e4e7;--sty-card:#fff;--sty-page:#f4f8f9;
  display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(24px,4vw,56px);color:var(--sty-ink)}
.sty :where(h2,h3,p,ol,ul,figure){margin:0}
.sty-steps{list-style:none;padding:0;padding-block:20vh 30vh}
.sty-step{min-height:70vh;display:flex;align-items:center}
.sty-step>div{padding:22px 24px;border:1px solid var(--sty-line);border-left:4px solid var(--sty-line);border-radius:16px;background:var(--sty-card);
  transition:border-color .3s,box-shadow .3s}
.sty-step[aria-current="step"]>div{border-left-color:var(--sty-accent);box-shadow:0 18px 40px -28px rgba(11,37,48,.5)}
.sty-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
.sty-step small{font-size:.75rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--sty-accent)}
.sty-step h3{margin-top:4px;font-size:1.2rem;line-height:1.25}
.sty-step p{margin-top:8px;color:var(--sty-muted)}
.sty-media{position:sticky;top:max(24px,calc(50vh - 230px));align-self:start}
/* chart figure */
.sty-figure{padding:clamp(20px,3vw,28px);border-radius:20px;background:var(--sty-card);border:1px solid var(--sty-line)}
.sty-figure h2{font-size:1.1rem}
.sty-figure>p{font-size:.88rem;color:var(--sty-muted)}
.sty-chart{position:relative;list-style:none;padding:0;margin-top:18px;height:260px;display:grid;grid-template-columns:repeat(8,1fr);gap:clamp(6px,1.2vw,12px);align-items:end;
  border-bottom:2px solid var(--sty-ink)}
.sty-chart li{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;font-size:.78rem}
.sty-chart i{display:block;width:100%;height:calc(var(--v) / 70 * 100%);border-radius:8px 8px 0 0;background:var(--sty-soft);transition:background-color .35s,transform .35s;transform-origin:bottom}
.sty-chart b{margin-bottom:4px;font-weight:700;color:var(--sty-muted)}
.sty-years{list-style:none;padding:0;margin-top:6px;display:grid;grid-template-columns:repeat(8,1fr);gap:clamp(6px,1.2vw,12px);text-align:center;font-size:.75rem;color:var(--sty-muted)}
.sty-chart .sty-target{display:block;height:auto;position:absolute;left:0;right:0;bottom:calc(20 / 70 * 100%);border-top:2px dashed #b45309;opacity:0;transition:opacity .35s}
.sty-target span{position:absolute;left:0;top:-24px;padding:0 6px;font-size:.75rem;font-weight:700;color:#9a4508;background:var(--sty-card)}
.sty-figure[data-active="1"] [data-on~="1"] i,.sty-figure[data-active="2"] [data-on~="2"] i,
.sty-figure[data-active="3"] [data-on~="3"] i,.sty-figure[data-active="4"] [data-on~="4"] i{background:var(--sty-accent)}
.sty-figure[data-active="1"] [data-on~="1"] b,.sty-figure[data-active="2"] [data-on~="2"] b,
.sty-figure[data-active="3"] [data-on~="3"] b,.sty-figure[data-active="4"] [data-on~="4"] b{color:var(--sty-ink)}
.sty-figure[data-active="2"] [data-on~="2"] i{background:#b91c1c}
.sty-figure[data-active="4"] .sty-target{opacity:1}
.sty-counter{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:.86rem;font-weight:600}
.sty-counter [data-scrolly-current]{display:inline-grid;place-items:center;min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:var(--sty-ink);color:#fff;font-size:.8rem}
/* product tour inside a scroll container */
.sty-box{height:560px;overflow-y:auto;border-radius:20px;border:1px solid #d8e4e7;background:#e9f1f3;padding:0 clamp(16px,3vw,32px);overscroll-behavior:contain}
.sty-box:focus-visible{outline:3px solid #0f766e;outline-offset:3px}
.sty-box .sty{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
.sty-box .sty-steps{padding-block:120px 200px}
.sty-box .sty-step{min-height:330px}
.sty-box .sty-media{top:80px}
.sty-app{padding:0;border-radius:16px;overflow:hidden;background:#fff;border:1px solid var(--sty-line);box-shadow:0 30px 60px -40px rgba(11,37,48,.6)}
.sty-app .sty-counter{padding:0 14px 14px}
.sty-app-bar{display:flex;gap:6px;padding:10px 14px;background:#0b2530}
.sty-app-bar i{width:10px;height:10px;border-radius:50%;background:#4c626b}
.sty-app-body{display:grid;grid-template-columns:110px 1fr;min-height:250px}
.sty-app-nav{list-style:none;padding:12px 8px;background:#f1f6f7;display:grid;align-content:start;gap:4px;font-size:.78rem;color:var(--sty-muted)}
.sty-app-nav li{padding:7px 9px;border-radius:8px;transition:background-color .3s,color .3s}
.sty-app-panels{display:grid;padding:18px}
.sty-app-panels>div{grid-area:1/1;visibility:hidden;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s,visibility .3s}
.sty-app-panels strong{display:block;font-size:.95rem}
.sty-app-panels p{margin-top:4px;font-size:.8rem;color:var(--sty-muted)}
.sty-app-panels em{display:block;margin-top:14px;height:10px;border-radius:5px;background:#d8e4e7}
.sty-app-panels em+em{width:70%}
.sty-app-panels em.on{background:var(--sty-accent);width:46%}
.sty-figure[data-active="1"] .sty-app-nav li:nth-child(1),.sty-figure[data-active="2"] .sty-app-nav li:nth-child(2),
.sty-figure[data-active="3"] .sty-app-nav li:nth-child(3),.sty-figure[data-active="4"] .sty-app-nav li:nth-child(4){background:var(--sty-accent);color:#fff}
.sty-figure[data-active="1"] .sty-app-panels>div:nth-child(1),.sty-figure[data-active="2"] .sty-app-panels>div:nth-child(2),
.sty-figure[data-active="3"] .sty-app-panels>div:nth-child(3),.sty-figure[data-active="4"] .sty-app-panels>div:nth-child(4){visibility:visible;opacity:1;transform:none}
@media (max-width:767.98px){
  .sty,.sty-box .sty{grid-template-columns:1fr;gap:0}
  .sty-media,.sty-box .sty-media{order:-1;top:0;z-index:2;padding-block:8px;background:var(--sty-page)}
  .sty-box .sty-media{background:#e9f1f3}
  .sty-chart{height:150px}
  .sty-chart b{font-size:.68rem}
  .sty-figure>p{display:none}
  .sty-steps{padding-block:24px 40vh}
  .sty-step{min-height:60vh}
  .sty-box .sty-steps{padding-block:16px 220px}
  .sty-box .sty-step{min-height:280px}
  .sty-app-body{grid-template-columns:86px 1fr;min-height:150px}
}
@media (prefers-reduced-motion:reduce){
  .sty *{transition:none!important}
  .sty-app-panels>div{transform:none}
}
JavaScript
// Mark the step that crosses the middle of its scroll area: aria-current="step" on the step, data-active on the
// figure and the number in [data-scrolly-current]. Works for the page or any [data-scrolly-root] scroll container.
(() => {
  if (!('IntersectionObserver' in window)) return;
  document.querySelectorAll('[data-scrolly]').forEach(scrolly => {
    const steps = [...scrolly.querySelectorAll('[data-step]')];
    const figure = scrolly.querySelector('[data-scrolly-figure]');
    if (!steps.length || !figure) return;
    const counters = scrolly.querySelectorAll('[data-scrolly-current]');
    const activate = step => {
      steps.forEach(s => s === step ? s.setAttribute('aria-current', 'step') : s.removeAttribute('aria-current'));
      figure.dataset.active = step.dataset.step;
      counters.forEach(c => { c.textContent = step.dataset.step; });
    };
    const io = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) activate(e.target); });
    }, {
      root: scrolly.closest('[data-scrolly-root]'),
      // on narrow screens the sticky figure covers the top half, so watch a band below it
      rootMargin: matchMedia('(max-width: 767.98px)').matches ? '-62% 0px -28% 0px' : '-45% 0px -45% 0px'
    });
    steps.forEach(s => io.observe(s));
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Sticky chart, stepping text</span>
<section class="sty" data-scrolly aria-labelledby="sty-lake-title">
  <ol class="sty-steps list-unstyled">
    <li class="sty-step" data-step="1" aria-current="step"><div class="card"><div class="card-body"><small>2019 to 2020</small><h3>The lake turns green</h3><p>Phosphorus from farm run-off reaches 52 micrograms per litre. Algal blooms close the beach for eleven weeks in 2020.</p></div></div></li>
    <li class="sty-step" data-step="2"><div class="card"><div class="card-body"><small>2021</small><h3>The flood year</h3><p>A wet spring washes topsoil into the lake and phosphorus peaks at 61. The council and eight farms agree a plan.</p></div></div></li>
    <li class="sty-step" data-step="3"><div class="card"><div class="card-body"><small>2022 to 2024</small><h3>Buffer strips go in</h3><p>Twelve-metre strips of grass and willow along every stream catch the soil before it reaches the water. Levels fall every year.</p></div></div></li>
    <li class="sty-step" data-step="4"><div class="card"><div class="card-body"><small>2025 to 2026</small><h3>Swimming again</h3><p>Phosphorus drops below the target of 20 for the first time, and the beach opens for the whole summer.</p></div></div></li>
  </ol>
  <div class="sty-media">
    <figure class="sty-figure card mb-0" data-scrolly-figure data-active="1">
      <h2 id="sty-lake-title">Lake Varne, phosphorus</h2>
      <p>Yearly average in micrograms per litre</p>
      <ul class="sty-chart" aria-label="Phosphorus by year">
        <li data-on="1" style="--v:48"><b>48</b><i aria-hidden="true"></i><span class="visually-hidden">in 2019</span></li>
        <li data-on="1" style="--v:52"><b>52</b><i aria-hidden="true"></i><span class="visually-hidden">in 2020</span></li>
        <li data-on="2" style="--v:61"><b>61</b><i aria-hidden="true"></i><span class="visually-hidden">in 2021</span></li>
        <li data-on="3" style="--v:44"><b>44</b><i aria-hidden="true"></i><span class="visually-hidden">in 2022</span></li>
        <li data-on="3" style="--v:33"><b>33</b><i aria-hidden="true"></i><span class="visually-hidden">in 2023</span></li>
        <li data-on="3" style="--v:27"><b>27</b><i aria-hidden="true"></i><span class="visually-hidden">in 2024</span></li>
        <li data-on="4" style="--v:21"><b>21</b><i aria-hidden="true"></i><span class="visually-hidden">in 2025</span></li>
        <li data-on="4" style="--v:18"><b>18</b><i aria-hidden="true"></i><span class="visually-hidden">in 2026</span></li>
        <li class="sty-target" aria-hidden="true"><span>Target 20</span></li>
      </ul>
      <ol class="sty-years" aria-hidden="true"><li>2019</li><li>2020</li><li>2021</li><li>2022</li><li>2023</li><li>2024</li><li>2025</li><li>2026</li></ol>
      <figcaption class="sty-counter">Step <span class="badge rounded-pill" data-scrolly-current>1</span> of 4</figcaption>
    </figure>
  </div>
</section></div>

<div><span class="lbl">Product tour inside a scroll container</span>
<div class="sty-box" data-scrolly-root tabindex="0" role="region" aria-label="Ledgerly setup tour, scrollable">
  <section class="sty" data-scrolly aria-labelledby="sty-tour-title">
    <ol class="sty-steps list-unstyled">
      <li class="sty-step" data-step="1" aria-current="step"><div class="card"><div class="card-body"><small>Step 1</small><h3 id="sty-tour-title">Connect your bank</h3><p>Ledgerly reads transactions from 2,400 banks, read-only. Nothing moves money.</p></div></div></li>
      <li class="sty-step" data-step="2"><div class="card"><div class="card-body"><small>Step 2</small><h3>Import invoices</h3><p>Drop in a CSV or forward invoices to your Ledgerly address. Each one is matched to a payment.</p></div></div></li>
      <li class="sty-step" data-step="3"><div class="card"><div class="card-body"><small>Step 3</small><h3>Set your VAT rules</h3><p>Pick standard, flat-rate or cash accounting. Returns are drafted every quarter.</p></div></div></li>
      <li class="sty-step" data-step="4"><div class="card"><div class="card-body"><small>Step 4</small><h3>Invite your accountant</h3><p>They get their own login and can lock a period once it has been filed.</p></div></div></li>
    </ol>
    <div class="sty-media">
      <figure class="sty-figure sty-app card mb-0" data-scrolly-figure data-active="1">
        <div class="sty-app-bar" aria-hidden="true"><i></i><i></i><i></i></div>
        <div class="sty-app-body" aria-hidden="true">
          <ul class="sty-app-nav"><li>Bank</li><li>Invoices</li><li>VAT</li><li>Team</li></ul>
          <div class="sty-app-panels">
            <div><strong>Monzo Business ··4471</strong><p>Connected · 312 transactions</p><em class="on"></em><em></em></div>
            <div><strong>48 invoices imported</strong><p>45 matched, 3 to review</p><em class="on"></em><em></em></div>
            <div><strong>VAT: standard rate</strong><p>Next return due 7 May</p><em class="on"></em><em></em></div>
            <div><strong>Priya Shah, ACA</strong><p>Invited as accountant</p><em class="on"></em><em></em></div>
          </div>
        </div>
        <figcaption class="sty-counter">Step <span class="badge rounded-pill" data-scrolly-current>1</span> of 4</figcaption>
      </figure>
    </div>
  </section>
</div></div>
CSS
/* Sticky scrollytelling in Bootstrap 5: .card, .badge, .list-unstyled and .visually-hidden for the parts, and
   the shared CSS below for the sticky two-column layout and the figure states. The media column is position: sticky while the steps scroll past it. A small
   IntersectionObserver script marks the step crossing the middle of the scroll area with aria-current="step",
   writes its number into the visible "Step n of 4" counter and sets data-active on the figure, which CSS uses to
   change the picture. Without JavaScript every step is still readable and the figure shows step 1. */
.sty{--sty-ink:#0b2530;--sty-muted:#4c626b;--sty-accent:#0f766e;--sty-soft:#c9dbe0;--sty-line:#d8e4e7;--sty-card:#fff;--sty-page:#f4f8f9;
  display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(24px,4vw,56px);color:var(--sty-ink)}
.sty :where(h2,h3,p,ol,ul,figure){margin:0}
.sty-steps{list-style:none;padding:0;padding-block:20vh 30vh}
.sty-step{min-height:70vh;display:flex;align-items:center}
.sty-step>.card{--bs-card-border-color:var(--sty-line);--bs-card-border-radius:16px;--bs-card-spacer-x:24px;--bs-card-spacer-y:22px;border-left:4px solid var(--sty-line);
  transition:border-color .3s,box-shadow .3s}
.sty-step[aria-current="step"]>.card{border-left-color:var(--sty-accent);box-shadow:0 18px 40px -28px rgba(11,37,48,.5)}
.sty-step small{font-size:.75rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--sty-accent)}
.sty-step h3{margin-top:4px;font-size:1.2rem;font-weight:700;line-height:1.25}
.sty-step p{margin-top:8px;color:var(--sty-muted)}
.sty-media{position:sticky;top:max(24px,calc(50vh - 230px));align-self:start}
/* chart figure */
.sty-figure{--bs-card-border-color:var(--sty-line);--bs-card-border-radius:20px;padding:clamp(20px,3vw,28px)}
.sty-figure h2{font-size:1.1rem;font-weight:700}
.sty-figure>p{font-size:.88rem;color:var(--sty-muted)}
.sty-chart{position:relative;list-style:none;padding:0;margin-top:18px;height:260px;display:grid;grid-template-columns:repeat(8,1fr);gap:clamp(6px,1.2vw,12px);align-items:end;
  border-bottom:2px solid var(--sty-ink)}
.sty-chart li{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;font-size:.78rem}
.sty-chart i{display:block;width:100%;height:calc(var(--v) / 70 * 100%);border-radius:8px 8px 0 0;background:var(--sty-soft);transition:background-color .35s,transform .35s;transform-origin:bottom}
.sty-chart b{margin-bottom:4px;font-weight:700;color:var(--sty-muted)}
.sty-years{list-style:none;padding:0;margin-top:6px;display:grid;grid-template-columns:repeat(8,1fr);gap:clamp(6px,1.2vw,12px);text-align:center;font-size:.75rem;color:var(--sty-muted)}
.sty-chart .sty-target{display:block;height:auto;position:absolute;left:0;right:0;bottom:calc(20 / 70 * 100%);border-top:2px dashed #b45309;opacity:0;transition:opacity .35s}
.sty-target span{position:absolute;left:0;top:-24px;padding:0 6px;font-size:.75rem;font-weight:700;color:#9a4508;background:var(--sty-card)}
.sty-figure[data-active="1"] [data-on~="1"] i,.sty-figure[data-active="2"] [data-on~="2"] i,
.sty-figure[data-active="3"] [data-on~="3"] i,.sty-figure[data-active="4"] [data-on~="4"] i{background:var(--sty-accent)}
.sty-figure[data-active="1"] [data-on~="1"] b,.sty-figure[data-active="2"] [data-on~="2"] b,
.sty-figure[data-active="3"] [data-on~="3"] b,.sty-figure[data-active="4"] [data-on~="4"] b{color:var(--sty-ink)}
.sty-figure[data-active="2"] [data-on~="2"] i{background:#b91c1c}
.sty-figure[data-active="4"] .sty-target{opacity:1}
.sty-counter{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:.86rem;font-weight:600}
.sty-counter .badge{--bs-badge-font-size:.8rem;min-width:28px;background:var(--sty-ink)}
/* product tour inside a scroll container */
.sty-box{height:560px;overflow-y:auto;border-radius:20px;border:1px solid #d8e4e7;background:#e9f1f3;padding:0 clamp(16px,3vw,32px);overscroll-behavior:contain}
.sty-box:focus-visible{outline:3px solid #0f766e;outline-offset:3px}
.sty-box .sty{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
.sty-box .sty-steps{padding-block:120px 200px}
.sty-box .sty-step{min-height:330px}
.sty-box .sty-media{top:80px}
.sty-app{--bs-card-border-radius:16px;padding:0;overflow:hidden;box-shadow:0 30px 60px -40px rgba(11,37,48,.6)}
.sty-app .sty-counter{padding:0 14px 14px}
.sty-app-bar{display:flex;gap:6px;padding:10px 14px;background:#0b2530}
.sty-app-bar i{width:10px;height:10px;border-radius:50%;background:#4c626b}
.sty-app-body{display:grid;grid-template-columns:110px 1fr;min-height:250px}
.sty-app-nav{list-style:none;padding:12px 8px;background:#f1f6f7;display:grid;align-content:start;gap:4px;font-size:.78rem;color:var(--sty-muted)}
.sty-app-nav li{padding:7px 9px;border-radius:8px;transition:background-color .3s,color .3s}
.sty-app-panels{display:grid;padding:18px}
.sty-app-panels>div{grid-area:1/1;visibility:hidden;opacity:0;transform:translateY(8px);transition:opacity .3s,transform .3s,visibility .3s}
.sty-app-panels strong{display:block;font-size:.95rem;font-weight:700}
.sty-app-panels p{margin-top:4px;font-size:.8rem;color:var(--sty-muted)}
.sty-app-panels em{display:block;margin-top:14px;height:10px;border-radius:5px;background:#d8e4e7}
.sty-app-panels em+em{width:70%}
.sty-app-panels em.on{background:var(--sty-accent);width:46%}
.sty-figure[data-active="1"] .sty-app-nav li:nth-child(1),.sty-figure[data-active="2"] .sty-app-nav li:nth-child(2),
.sty-figure[data-active="3"] .sty-app-nav li:nth-child(3),.sty-figure[data-active="4"] .sty-app-nav li:nth-child(4){background:var(--sty-accent);color:#fff}
.sty-figure[data-active="1"] .sty-app-panels>div:nth-child(1),.sty-figure[data-active="2"] .sty-app-panels>div:nth-child(2),
.sty-figure[data-active="3"] .sty-app-panels>div:nth-child(3),.sty-figure[data-active="4"] .sty-app-panels>div:nth-child(4){visibility:visible;opacity:1;transform:none}
@media (max-width:767.98px){
  .sty,.sty-box .sty{grid-template-columns:1fr;gap:0}
  .sty-media,.sty-box .sty-media{order:-1;top:0;z-index:2;padding-block:8px;background:var(--sty-page)}
  .sty-box .sty-media{background:#e9f1f3}
  .sty-chart{height:150px}
  .sty-chart b{font-size:.68rem}
  .sty-figure>p{display:none}
  .sty-steps{padding-block:24px 40vh}
  .sty-step{min-height:60vh}
  .sty-box .sty-steps{padding-block:16px 220px}
  .sty-box .sty-step{min-height:280px}
  .sty-app-body{grid-template-columns:86px 1fr;min-height:150px}
}
@media (prefers-reduced-motion:reduce){
  .sty *{transition:none!important}
  .sty-app-panels>div{transform:none}
}
JavaScript
// Mark the step that crosses the middle of its scroll area: aria-current="step" on the step, data-active on the
// figure and the number in [data-scrolly-current]. Works for the page or any [data-scrolly-root] scroll container.
(() => {
  if (!('IntersectionObserver' in window)) return;
  document.querySelectorAll('[data-scrolly]').forEach(scrolly => {
    const steps = [...scrolly.querySelectorAll('[data-step]')];
    const figure = scrolly.querySelector('[data-scrolly-figure]');
    if (!steps.length || !figure) return;
    const counters = scrolly.querySelectorAll('[data-scrolly-current]');
    const activate = step => {
      steps.forEach(s => s === step ? s.setAttribute('aria-current', 'step') : s.removeAttribute('aria-current'));
      figure.dataset.active = step.dataset.step;
      counters.forEach(c => { c.textContent = step.dataset.step; });
    };
    const io = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) activate(e.target); });
    }, {
      root: scrolly.closest('[data-scrolly-root]'),
      // on narrow screens the sticky figure covers the top half, so watch a band below it
      rootMargin: matchMedia('(max-width: 767.98px)').matches ? '-62% 0px -28% 0px' : '-45% 0px -45% 0px'
    });
    steps.forEach(s => io.observe(s));
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<!-- Utilities only: the script sets data-active on the figure (group/fig) and aria-current="step" on a step
     (group/step); group-data-[active=n]/fig: and group-aria-[current=step]/step: variants restyle the parts. -->
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4c626b] uppercase">Sticky chart, stepping text</span>
<section class="grid grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-[clamp(24px,4vw,56px)] text-[#0b2530] max-md:grid-cols-1 max-md:gap-0" data-scrolly aria-labelledby="sty-lake-title">
  <ol class="pt-[20vh] pb-[30vh] max-md:pt-6 max-md:pb-[40vh]">
    <li class="group/step flex items-center min-h-[70vh] max-md:min-h-[60vh]" data-step="1" aria-current="step"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">2019 to 2020</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">The lake turns green</h3><p class="mt-2 text-[#4c626b]">Phosphorus from farm run-off reaches 52 micrograms per litre. Algal blooms close the beach for eleven weeks in 2020.</p></div></li>
    <li class="group/step flex items-center min-h-[70vh] max-md:min-h-[60vh]" data-step="2"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">2021</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">The flood year</h3><p class="mt-2 text-[#4c626b]">A wet spring washes topsoil into the lake and phosphorus peaks at 61. The council and eight farms agree a plan.</p></div></li>
    <li class="group/step flex items-center min-h-[70vh] max-md:min-h-[60vh]" data-step="3"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">2022 to 2024</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">Buffer strips go in</h3><p class="mt-2 text-[#4c626b]">Twelve-metre strips of grass and willow along every stream catch the soil before it reaches the water. Levels fall every year.</p></div></li>
    <li class="group/step flex items-center min-h-[70vh] max-md:min-h-[60vh]" data-step="4"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">2025 to 2026</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">Swimming again</h3><p class="mt-2 text-[#4c626b]">Phosphorus drops below the target of 20 for the first time, and the beach opens for the whole summer.</p></div></li>
  </ol>
  <div class="sticky top-[max(24px,calc(50vh-230px))] self-start max-md:order-first max-md:top-0 max-md:z-[2] max-md:py-2 max-md:bg-[#f4f8f9]">
    <figure class="group/fig rounded-[20px] border border-[#d8e4e7] bg-white p-[clamp(20px,3vw,28px)]" data-scrolly-figure data-active="1">
      <h2 class="text-[1.1rem] font-bold" id="sty-lake-title">Lake Varne, phosphorus</h2>
      <p class="text-[.88rem] text-[#4c626b] max-md:hidden">Yearly average in micrograms per litre</p>
      <ul class="relative mt-[18px] grid h-[260px] grid-cols-8 items-end gap-[clamp(6px,1.2vw,12px)] border-b-2 border-[#0b2530] max-md:h-[150px]" aria-label="Phosphorus by year">
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=1]/fig:text-[#0b2530] max-md:text-[.68rem]">48</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=1]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(48 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2019</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=1]/fig:text-[#0b2530] max-md:text-[.68rem]">52</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=1]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(52 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2020</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=2]/fig:text-[#0b2530] max-md:text-[.68rem]">61</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=2]/fig:bg-[#b91c1c] motion-reduce:transition-none" style="height:calc(61 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2021</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=3]/fig:text-[#0b2530] max-md:text-[.68rem]">44</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=3]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(44 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2022</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=3]/fig:text-[#0b2530] max-md:text-[.68rem]">33</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=3]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(33 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2023</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=3]/fig:text-[#0b2530] max-md:text-[.68rem]">27</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=3]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(27 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2024</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=4]/fig:text-[#0b2530] max-md:text-[.68rem]">21</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=4]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(21 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2025</span></li>
        <li class="flex h-full flex-col items-center justify-end text-[.78rem]"><b class="mb-1 font-bold text-[#4c626b] group-data-[active=4]/fig:text-[#0b2530] max-md:text-[.68rem]">18</b><i class="block w-full rounded-t-lg bg-[#c9dbe0] transition-colors duration-300 group-data-[active=4]/fig:bg-[#0f766e] motion-reduce:transition-none" style="height:calc(18 / 70 * 100%)" aria-hidden="true"></i><span class="sr-only">in 2026</span></li>
        <li class="absolute inset-x-0 bottom-[calc(20/70*100%)] border-t-2 border-dashed border-[#b45309] opacity-0 transition-opacity duration-300 group-data-[active=4]/fig:opacity-100 motion-reduce:transition-none" aria-hidden="true"><span class="absolute -top-6 left-0 bg-white px-1.5 text-[.75rem] font-bold text-[#9a4508]">Target 20</span></li>
      </ul>
      <ol class="mt-1.5 grid grid-cols-8 gap-[clamp(6px,1.2vw,12px)] text-center text-[.75rem] text-[#4c626b]" aria-hidden="true"><li>2019</li><li>2020</li><li>2021</li><li>2022</li><li>2023</li><li>2024</li><li>2025</li><li>2026</li></ol>
      <figcaption class="mt-4 flex items-center gap-2.5 text-[.86rem] font-semibold">Step <span class="inline-grid h-7 min-w-7 place-items-center rounded-full bg-[#0b2530] px-2 text-[.8rem] text-white" data-scrolly-current>1</span> of 4</figcaption>
    </figure>
  </div>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4c626b] uppercase">Product tour inside a scroll container</span>
<div class="h-[560px] overflow-y-auto overscroll-contain rounded-[20px] border border-[#d8e4e7] bg-[#e9f1f3] px-[clamp(16px,3vw,32px)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#0f766e]" data-scrolly-root tabindex="0" role="region" aria-label="Ledgerly setup tour, scrollable">
  <section class="grid grid-cols-2 gap-[clamp(24px,4vw,56px)] text-[#0b2530] max-md:grid-cols-1 max-md:gap-0" data-scrolly aria-labelledby="sty-tour-title">
    <ol class="pt-[120px] pb-[200px] max-md:pt-4 max-md:pb-[220px]">
      <li class="group/step flex items-center min-h-[330px] max-md:min-h-[280px]" data-step="1" aria-current="step"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">Step 1</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold" id="sty-tour-title">Connect your bank</h3><p class="mt-2 text-[#4c626b]">Ledgerly reads transactions from 2,400 banks, read-only. Nothing moves money.</p></div></li>
      <li class="group/step flex items-center min-h-[330px] max-md:min-h-[280px]" data-step="2"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">Step 2</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">Import invoices</h3><p class="mt-2 text-[#4c626b]">Drop in a CSV or forward invoices to your Ledgerly address. Each one is matched to a payment.</p></div></li>
      <li class="group/step flex items-center min-h-[330px] max-md:min-h-[280px]" data-step="3"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">Step 3</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">Set your VAT rules</h3><p class="mt-2 text-[#4c626b]">Pick standard, flat-rate or cash accounting. Returns are drafted every quarter.</p></div></li>
      <li class="group/step flex items-center min-h-[330px] max-md:min-h-[280px]" data-step="4"><div class="rounded-2xl border border-l-4 border-[#d8e4e7] bg-white px-6 py-[22px] transition-[border-color,box-shadow] duration-300 group-aria-[current=step]/step:border-l-[#0f766e] group-aria-[current=step]/step:shadow-[0_18px_40px_-28px_rgba(11,37,48,.5)] motion-reduce:transition-none"><small class="text-[.75rem] font-bold tracking-[.12em] text-[#0f766e] uppercase">Step 4</small><h3 class="mt-1 text-[1.2rem] leading-[1.25] font-bold">Invite your accountant</h3><p class="mt-2 text-[#4c626b]">They get their own login and can lock a period once it has been filed.</p></div></li>
    </ol>
    <div class="sticky top-20 self-start max-md:order-first max-md:top-0 max-md:z-[2] max-md:bg-[#e9f1f3] max-md:py-2">
      <figure class="group/fig overflow-hidden rounded-2xl border border-[#d8e4e7] bg-white shadow-[0_30px_60px_-40px_rgba(11,37,48,.6)]" data-scrolly-figure data-active="1">
        <div class="flex gap-1.5 bg-[#0b2530] px-3.5 py-2.5" aria-hidden="true"><i class="size-2.5 rounded-full bg-[#4c626b]"></i><i class="size-2.5 rounded-full bg-[#4c626b]"></i><i class="size-2.5 rounded-full bg-[#4c626b]"></i></div>
        <div class="grid min-h-[250px] grid-cols-[110px_1fr] max-md:min-h-[150px] max-md:grid-cols-[86px_1fr]" aria-hidden="true">
          <ul class="grid content-start gap-1 bg-[#f1f6f7] px-2 py-3 text-[.78rem] text-[#4c626b]"><li class="rounded-lg px-[9px] py-[7px] transition-colors duration-300 group-data-[active=1]/fig:bg-[#0f766e] group-data-[active=1]/fig:text-white motion-reduce:transition-none">Bank</li><li class="rounded-lg px-[9px] py-[7px] transition-colors duration-300 group-data-[active=2]/fig:bg-[#0f766e] group-data-[active=2]/fig:text-white motion-reduce:transition-none">Invoices</li><li class="rounded-lg px-[9px] py-[7px] transition-colors duration-300 group-data-[active=3]/fig:bg-[#0f766e] group-data-[active=3]/fig:text-white motion-reduce:transition-none">VAT</li><li class="rounded-lg px-[9px] py-[7px] transition-colors duration-300 group-data-[active=4]/fig:bg-[#0f766e] group-data-[active=4]/fig:text-white motion-reduce:transition-none">Team</li></ul>
          <div class="grid p-[18px]">
            <div class="invisible col-start-1 row-start-1 translate-y-2 opacity-0 transition-[opacity,translate,visibility] duration-300 group-data-[active=1]/fig:visible group-data-[active=1]/fig:translate-y-0 group-data-[active=1]/fig:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none"><strong class="block text-[.95rem]">Monzo Business ··4471</strong><p class="mt-1 text-[.8rem] text-[#4c626b]">Connected · 312 transactions</p><em class="mt-3.5 block h-2.5 w-[46%] rounded-[5px] bg-[#0f766e]"></em><em class="mt-3.5 block h-2.5 w-[70%] rounded-[5px] bg-[#d8e4e7]"></em></div>
            <div class="invisible col-start-1 row-start-1 translate-y-2 opacity-0 transition-[opacity,translate,visibility] duration-300 group-data-[active=2]/fig:visible group-data-[active=2]/fig:translate-y-0 group-data-[active=2]/fig:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none"><strong class="block text-[.95rem]">48 invoices imported</strong><p class="mt-1 text-[.8rem] text-[#4c626b]">45 matched, 3 to review</p><em class="mt-3.5 block h-2.5 w-[46%] rounded-[5px] bg-[#0f766e]"></em><em class="mt-3.5 block h-2.5 w-[70%] rounded-[5px] bg-[#d8e4e7]"></em></div>
            <div class="invisible col-start-1 row-start-1 translate-y-2 opacity-0 transition-[opacity,translate,visibility] duration-300 group-data-[active=3]/fig:visible group-data-[active=3]/fig:translate-y-0 group-data-[active=3]/fig:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none"><strong class="block text-[.95rem]">VAT: standard rate</strong><p class="mt-1 text-[.8rem] text-[#4c626b]">Next return due 7 May</p><em class="mt-3.5 block h-2.5 w-[46%] rounded-[5px] bg-[#0f766e]"></em><em class="mt-3.5 block h-2.5 w-[70%] rounded-[5px] bg-[#d8e4e7]"></em></div>
            <div class="invisible col-start-1 row-start-1 translate-y-2 opacity-0 transition-[opacity,translate,visibility] duration-300 group-data-[active=4]/fig:visible group-data-[active=4]/fig:translate-y-0 group-data-[active=4]/fig:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none"><strong class="block text-[.95rem]">Priya Shah, ACA</strong><p class="mt-1 text-[.8rem] text-[#4c626b]">Invited as accountant</p><em class="mt-3.5 block h-2.5 w-[46%] rounded-[5px] bg-[#0f766e]"></em><em class="mt-3.5 block h-2.5 w-[70%] rounded-[5px] bg-[#d8e4e7]"></em></div>
          </div>
        </div>
        <figcaption class="flex items-center gap-2.5 px-3.5 pb-3.5 text-[.86rem] font-semibold">Step <span class="inline-grid h-7 min-w-7 place-items-center rounded-full bg-[#0b2530] px-2 text-[.8rem] text-white" data-scrolly-current>1</span> of 4</figcaption>
      </figure>
    </div>
  </section>
</div></div>
JavaScript
// Mark the step that crosses the middle of its scroll area: aria-current="step" on the step, data-active on the
// figure and the number in [data-scrolly-current]. Works for the page or any [data-scrolly-root] scroll container.
(() => {
  if (!('IntersectionObserver' in window)) return;
  document.querySelectorAll('[data-scrolly]').forEach(scrolly => {
    const steps = [...scrolly.querySelectorAll('[data-step]')];
    const figure = scrolly.querySelector('[data-scrolly-figure]');
    if (!steps.length || !figure) return;
    const counters = scrolly.querySelectorAll('[data-scrolly-current]');
    const activate = step => {
      steps.forEach(s => s === step ? s.setAttribute('aria-current', 'step') : s.removeAttribute('aria-current'));
      figure.dataset.active = step.dataset.step;
      counters.forEach(c => { c.textContent = step.dataset.step; });
    };
    const io = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) activate(e.target); });
    }, {
      root: scrolly.closest('[data-scrolly-root]'),
      // on narrow screens the sticky figure covers the top half, so watch a band below it
      rootMargin: matchMedia('(max-width: 767.98px)').matches ? '-62% 0px -28% 0px' : '-45% 0px -45% 0px'
    });
    steps.forEach(s => io.observe(s));
  });
})();

About this set

Two scrollytelling layouts where a figure stays put while the story scrolls past it. The first is a data story about a lake recovering from algal blooms: a bar chart of phosphorus levels is sticky beside four text steps, and as each step reaches the middle of the screen the chart highlights the years it talks about, turns the flood year red and finally draws the target line. The second is a four-step product tour for an accounting app inside its own scroll container, where a drawn app window switches the selected menu item and panel for each step. The figure column uses position: sticky. A small IntersectionObserver script watches a band across the middle of the scroll area, gives the current step aria-current=”step”, writes its number into a visible Step n of 4 counter and sets data-active on the figure, and the CSS changes the picture from that attribute. On narrow screens the figure sticks to the top and the steps scroll underneath it. Without JavaScript every step is still readable and the figure shows step one.

What’s included

  • Sticky chart with stepping text, and a product tour in a scroll container
  • aria-current="step" and a visible Step n of 4 counter
  • One small IntersectionObserver script for any number of stories
  • Figure states driven by a data-active attribute
  • Stacked layout with a sticky figure on narrow screens

Accessibility

Every step is ordinary text in an ordered list, so the story makes sense without the figure. The current step gets aria-current="step" and the figure shows the step number as visible text. The chart is a list of values with the year added as visually hidden text, and the drawn app window is aria-hidden because the steps carry its content. Under reduced motion the state changes are instant, with no transitions.

Customise it

Change --sty-accent and --sty-soft for the highlighted and resting bars, the --v value on each bar and the rootMargin in the script to move the trigger band. In Tailwind, each bar's group-data-[active=n]/fig: classes say which step highlights it.