Full-page snap
Every flick lands exactly on the next full-screen slide, like a presentation. No JavaScript.
Full-screen story slides that snap into place, with segmented progress bars across the top.
CSS scroll snap does the paging. An IntersectionObserver finds the slide that fills the screen and fills every progress segment up to it, while the current segment animates its fill so the active slide is clear.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snap stories</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; }
html { scroll-snap-type: y mandatory; }
body { color: #fff; }
.story { height: 100vh; scroll-snap-align: start; scroll-snap-stop: always; background: var(--bg);
display: flex; flex-direction: column; justify-content: flex-end; padding: 0 7vw 12vh; }
.story h2 { font: 900 clamp(2.6rem, 10vw, 5.5rem)/1 system-ui, sans-serif; letter-spacing: -.04em; margin: 0 0 .2em; }
.story p { font-size: 1.3rem; margin: 0; }
.bars { position: fixed; top: 14px; left: 14px; right: 14px; display: flex; gap: 6px; z-index: 5; }
.bars i { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,.35); overflow: hidden; }
.bars b { display: block; height: 100%; width: 100%; background: #fff; transform-origin: 0; transform: scaleX(0); }
.bars b.done { transform: scaleX(1); }
.bars b.now { animation: fill 3s linear forwards; }
@keyframes fill { to { transform: scaleX(1); } }
</style>
</head>
<body>
<div class="bars" id="bars"></div>
<section class="story" style="--bg:linear-gradient(160deg,#ff9a8b,#ff6a88)"><h2>Morning run</h2><p>5.2 km before coffee.</p></section>
<section class="story" style="--bg:linear-gradient(160deg,#a18cd1,#fbc2eb)"><h2>Desk</h2><p>Shipping the scroll update.</p></section>
<section class="story" style="--bg:linear-gradient(160deg,#84fab0,#8fd3f4)"><h2>Lunch</h2><p>Noodles, obviously.</p></section>
<section class="story" style="--bg:linear-gradient(160deg,#fccb90,#d57eeb)"><h2>Golden hour</h2><p>Best light of the day.</p></section>
<section class="story" style="--bg:linear-gradient(160deg,#30cfd0,#330867)"><h2>Night</h2><p>See you tomorrow.</p></section>
<script>
const stories = [...document.querySelectorAll('.story')];
const bars = document.getElementById('bars');
stories.forEach(() => bars.insertAdjacentHTML('beforeend', '<i><b></b></i>'));
const fills = bars.querySelectorAll('b');
const io = new IntersectionObserver(entries => entries.forEach(e => {
if (!e.isIntersecting) return;
const idx = stories.indexOf(e.target);
fills.forEach((f, i) => {
f.classList.remove('now');
f.classList.toggle('done', i < idx);
if (i === idx) { void f.offsetWidth; f.classList.add('now'); }
});
}), { threshold: 0.6 });
stories.forEach(s => io.observe(s));
</script>
</body>
</html>