<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));
});
})();
// 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.