<div class="prg-bar" data-progress="page" aria-hidden="true"></div>
<a class="prg-top" href="#top" aria-label="Back to top">
<svg viewBox="0 0 56 56" aria-hidden="true"><circle class="prg-track" cx="28" cy="28" r="25"/><circle class="prg-arc" data-progress="page" cx="28" cy="28" r="25" pathLength="100"/></svg>
<b aria-hidden="true">↑</b>
</a>
<div><span class="lbl">Page bar, progress ring and chapter bars</span>
<article class="prg-article" aria-labelledby="prg-title">
<header>
<p class="prg-kicker">Northbound · Travel</p>
<h2 class="prg-title" id="prg-title">The night train comes back</h2>
<p class="prg-meta">By Léa Marchetti · 6 min read · Paris to Vienna in thirteen hours</p>
</header>
<section class="prg-chapter" aria-labelledby="prg-c1">
<div class="prg-chapter-head"><h3 id="prg-c1"><span>1</span>Paris Est, 19:58</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>The Lumière Night Express leaves from platform 4, between a commuter train to Meaux and a high-speed service that will be in Strasbourg before we have found our compartment. Nobody runs for it. People arrive with paper bags of bread and cheese, the way they used to.</p>
<p>Our couchette has four bunks, a ladder, a window that opens a hand's width and a small card explaining, in five languages, how to lock the door from inside. The attendant, Matthias, takes breakfast orders on a clipboard: coffee or tea, croissant or rye bread, and whether we want to be woken at Linz.</p>
<p>By the time the train clears the Paris suburbs it is dark enough to see our own reflections in the glass, and the dining car has run out of the cheap red wine.</p>
</section>
<section class="prg-chapter" aria-labelledby="prg-c2">
<div class="prg-chapter-head"><h3 id="prg-c2"><span>2</span>Strasbourg to Munich, overnight</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>Night trains were meant to be finished. Between 2005 and 2020 most of Europe's sleeper routes closed, undercut by budget flights that cost less than a sandwich in the dining car. What changed was not the trains but the passengers: people who would rather lose a night than spend a day in airports.</p>
<p>The ride is not silent. The carriage creaks over points, the brakes sigh at every station, and somewhere near Karlsruhe a group of students two compartments down decides to sing. But the bunk is long enough to lie flat, the blanket is heavier than it looks, and the rocking does what it has always done.</p>
<p>At Munich, at 4:40 in the morning, the train splits in two. Half goes to Venice. We wake for the bump of the coupling and fall asleep again before it has finished.</p>
</section>
<section class="prg-chapter" aria-labelledby="prg-c3">
<div class="prg-chapter-head"><h3 id="prg-c3"><span>3</span>Breakfast in the Danube valley</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>Matthias knocks at seven with two coffees and a paper bag of rolls. Outside, the Danube is the colour of slate and the vineyards are still in shadow. A man in the next compartment is shaving in the corridor using the window as a mirror.</p>
<p>We reach Wien Hauptbahnhof at 9:12, three minutes late, rested in a way no early flight has ever left us. The ticket cost 89 euros for a couchette, about the same as the flight plus the train to the airport, and we gained a night in a hotel we did not have to book.</p>
<p>The return train leaves at 19:40. We have already reserved the same bunks.</p>
</section>
</article></div>
<div><span class="lbl">Inside a scroll container</span>
<div class="prg-box" data-progress-root tabindex="0" role="region" aria-label="Rail pass conditions, scrollable">
<div class="prg-box-head"><h3>Rail pass conditions</h3><p>Lumière Interrail Night Pass, 2027 season</p><i class="prg-box-bar" data-progress="container" aria-hidden="true"></i></div>
<ol>
<li>The pass covers ten night journeys within two months of the first trip, on any Lumière route.</li>
<li>A couchette or seat reservation is required for every journey and costs from 12 euros.</li>
<li>Sleeper compartments can be booked with a supplement of 40 to 95 euros per person per night.</li>
<li>The pass is personal and must be shown with a passport or national ID card on request.</li>
<li>Journeys that start before midnight count against the date of departure.</li>
<li>Children under 12 travel free with an adult pass holder but still need a reservation.</li>
<li>Bicycles travel in the bike carriage with a 15 euro reservation, subject to space.</li>
<li>Unused passes can be refunded within 30 days of purchase, minus a 15 euro fee.</li>
<li>Lost passes cannot be replaced. Keep the mobile pass in the app with a backup on paper.</li>
<li>If a train is more than 60 minutes late at your destination, claim a 25 percent refund online.</li>
</ol>
</div>
</div>
CSS
/* Scroll progress indicators. One registered custom property, --prg (0 to 1), drives every indicator: a bar's
scaleX, a chapter bar and the ring's stroke. Where scroll-driven animations are supported, a scroll() or view()
timeline animates --prg; elsewhere the small script sets it on scroll. Under prefers-reduced-motion the
indicators are not drawn at all. Every indicator is decorative (aria-hidden); the article itself is plain text. */
@property --prg{syntax:"<number>";inherits:false;initial-value:0}
:root{--prg-ink:#14213d;--prg-muted:#4a5468;--prg-paper:#fdfcf8;--prg-line:#e4e1d8;
--prg-fill:linear-gradient(90deg,#d97706,#e11d48 55%,#7c3aed);--prg-ring:#c2185b}
.prg-article,.prg-box{color:var(--prg-ink)}
.prg-article :where(h2,h3,p),.prg-box :where(h3,p){margin:0}
/* 1. page bar, fixed to the top of the window */
.prg-bar{position:fixed;inset:0 0 auto;z-index:20;height:5px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
/* 2. back-to-top link with a progress ring */
.prg-top{position:fixed;right:clamp(14px,3vw,28px);bottom:clamp(14px,3vw,28px);z-index:20;display:grid;place-items:center;width:56px;height:56px;
border-radius:50%;background:#fff;color:var(--prg-ink);box-shadow:0 10px 30px -12px rgba(20,33,61,.45);text-decoration:none}
.prg-top svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
.prg-top circle{fill:none;stroke-width:4}
.prg-top .prg-track{stroke:var(--prg-line)}
.prg-top .prg-arc{stroke:var(--prg-ring);stroke-linecap:round;stroke-dasharray:100;stroke-dashoffset:calc(100 - var(--prg) * 100)}
.prg-top b{position:relative;font-size:1.25rem;line-height:1}
.prg-top:focus-visible{outline:3px solid var(--prg-ink);outline-offset:3px}
/* 3. article with sticky chapter headings, each with its own bar */
.prg-article header{padding-bottom:28px;border-bottom:1px solid var(--prg-line)}
.prg-kicker{font-size:.78rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#a3195b}
.prg-title{margin-top:10px;font-family:Georgia,"Times New Roman",serif;font-size:clamp(2rem,1.4rem + 2.6vw,3.1rem);line-height:1.08;letter-spacing:-.02em}
.prg-meta{margin-top:12px;font-size:.9rem;color:var(--prg-muted)}
.prg-chapter{view-timeline:--prg-chapter block;padding-top:8px}
.prg-chapter-head{position:sticky;top:5px;z-index:5;margin:0 -12px 16px;padding:14px 12px 12px;background:var(--prg-paper);border-bottom:3px solid var(--prg-line)}
.prg-chapter-head h3{font-size:1.05rem}
.prg-chapter-head span{color:#a3195b;margin-right:6px}
.prg-chapter-bar{position:absolute;left:0;right:0;bottom:-3px;height:3px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
.prg-chapter p{margin-bottom:18px;font-family:Georgia,"Times New Roman",serif;font-size:1.08rem;line-height:1.75}
/* 4. inside a scroll container: sticky header with a bar, and a bottom shadow while more is below */
.prg-box{height:380px;overflow-y:auto;border:1px solid var(--prg-line);border-radius:18px;
background:linear-gradient(rgba(255,255,255,0),#fff 70%) bottom/100% 48px no-repeat local,
radial-gradient(farthest-side at 50% 100%,rgba(20,33,61,.22),transparent) bottom/100% 16px no-repeat scroll,#fff}
.prg-box:focus-visible{outline:3px solid var(--prg-ink);outline-offset:3px}
.prg-box-head{position:sticky;top:0;z-index:1;padding:16px 22px 0;background:#fff;border-bottom:1px solid var(--prg-line)}
.prg-box-head h3{font-size:1rem}
.prg-box-head p{font-size:.84rem;color:var(--prg-muted)}
.prg-box-bar{display:block;height:3px;margin:12px -22px -1px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
.prg-box ol{margin:0;padding:18px 22px 22px 44px;display:grid;gap:12px;font-size:.95rem;color:#2c3a55}
.prg-box li::marker{font-weight:700;color:#a3195b}
/* scroll-driven version */
@keyframes prg-fill{to{--prg:1}}
@supports (animation-timeline:scroll()){
@media (prefers-reduced-motion:no-preference){
[data-progress]{animation:prg-fill linear both;animation-timeline:scroll(root block)}
[data-progress="container"]{animation-timeline:scroll(nearest block)}
[data-progress="section"]{animation-timeline:--prg-chapter;animation-range:exit-crossing 0% exit-crossing 100%}
}
}
@media (prefers-reduced-motion:reduce){
[data-progress]{display:none}
}
JavaScript
// Fallback for browsers without scroll-driven animations: set --prg (0 to 1) on every [data-progress] element.
// data-progress="page" follows the window, "container" the closest [data-progress-root], "section" its section.
(() => {
if (CSS.supports('animation-timeline: scroll()')) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const items = [...document.querySelectorAll('[data-progress]')];
if (!items.length) return;
const clamp = n => Math.min(1, Math.max(0, n || 0));
const progressOf = el => {
const type = el.dataset.progress;
if (type === 'container') { const s = el.closest('[data-progress-root]'); return s.scrollTop / (s.scrollHeight - s.clientHeight); }
if (type === 'section') { const r = el.closest('section').getBoundingClientRect(); return -r.top / r.height; }
const d = document.documentElement; return d.scrollTop / (d.scrollHeight - d.clientHeight);
};
let queued = false;
const update = () => { queued = false; items.forEach(el => el.style.setProperty('--prg', clamp(progressOf(el)).toFixed(4))); };
const request = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', request, { passive: true });
addEventListener('resize', request);
document.querySelectorAll('[data-progress-root]').forEach(s => s.addEventListener('scroll', request, { passive: true }));
update();
})();
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div class="prg-bar fixed-top" data-progress="page" aria-hidden="true"></div>
<a class="prg-top position-fixed bottom-0 end-0 m-3 m-md-4 d-grid rounded-circle bg-white shadow" href="#top" aria-label="Back to top">
<svg viewBox="0 0 56 56" aria-hidden="true"><circle class="prg-track" cx="28" cy="28" r="25"/><circle class="prg-arc" data-progress="page" cx="28" cy="28" r="25" pathLength="100"/></svg>
<b aria-hidden="true">↑</b>
</a>
<div><span class="lbl">Page bar, progress ring and chapter bars</span>
<article class="prg-article" aria-labelledby="prg-title">
<header class="pb-4 border-bottom">
<p class="prg-kicker mb-0">Northbound · Travel</p>
<h2 class="prg-title" id="prg-title">The night train comes back</h2>
<p class="prg-meta">By Léa Marchetti · 6 min read · Paris to Vienna in thirteen hours</p>
</header>
<section class="prg-chapter" aria-labelledby="prg-c1">
<div class="prg-chapter-head sticky-top"><h3 id="prg-c1"><span>1</span>Paris Est, 19:58</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>The Lumière Night Express leaves from platform 4, between a commuter train to Meaux and a high-speed service that will be in Strasbourg before we have found our compartment. Nobody runs for it. People arrive with paper bags of bread and cheese, the way they used to.</p>
<p>Our couchette has four bunks, a ladder, a window that opens a hand's width and a small card explaining, in five languages, how to lock the door from inside. The attendant, Matthias, takes breakfast orders on a clipboard: coffee or tea, croissant or rye bread, and whether we want to be woken at Linz.</p>
<p>By the time the train clears the Paris suburbs it is dark enough to see our own reflections in the glass, and the dining car has run out of the cheap red wine.</p>
</section>
<section class="prg-chapter" aria-labelledby="prg-c2">
<div class="prg-chapter-head sticky-top"><h3 id="prg-c2"><span>2</span>Strasbourg to Munich, overnight</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>Night trains were meant to be finished. Between 2005 and 2020 most of Europe's sleeper routes closed, undercut by budget flights that cost less than a sandwich in the dining car. What changed was not the trains but the passengers: people who would rather lose a night than spend a day in airports.</p>
<p>The ride is not silent. The carriage creaks over points, the brakes sigh at every station, and somewhere near Karlsruhe a group of students two compartments down decides to sing. But the bunk is long enough to lie flat, the blanket is heavier than it looks, and the rocking does what it has always done.</p>
<p>At Munich, at 4:40 in the morning, the train splits in two. Half goes to Venice. We wake for the bump of the coupling and fall asleep again before it has finished.</p>
</section>
<section class="prg-chapter" aria-labelledby="prg-c3">
<div class="prg-chapter-head sticky-top"><h3 id="prg-c3"><span>3</span>Breakfast in the Danube valley</h3><i class="prg-chapter-bar" data-progress="section" aria-hidden="true"></i></div>
<p>Matthias knocks at seven with two coffees and a paper bag of rolls. Outside, the Danube is the colour of slate and the vineyards are still in shadow. A man in the next compartment is shaving in the corridor using the window as a mirror.</p>
<p>We reach Wien Hauptbahnhof at 9:12, three minutes late, rested in a way no early flight has ever left us. The ticket cost 89 euros for a couchette, about the same as the flight plus the train to the airport, and we gained a night in a hotel we did not have to book.</p>
<p>The return train leaves at 19:40. We have already reserved the same bunks.</p>
</section>
</article></div>
<div><span class="lbl">Inside a scroll container</span>
<div class="prg-box overflow-auto border rounded-4" data-progress-root tabindex="0" role="region" aria-label="Rail pass conditions, scrollable">
<div class="prg-box-head sticky-top"><h3>Rail pass conditions</h3><p>Lumière Interrail Night Pass, 2027 season</p><i class="prg-box-bar" data-progress="container" aria-hidden="true"></i></div>
<ol>
<li>The pass covers ten night journeys within two months of the first trip, on any Lumière route.</li>
<li>A couchette or seat reservation is required for every journey and costs from 12 euros.</li>
<li>Sleeper compartments can be booked with a supplement of 40 to 95 euros per person per night.</li>
<li>The pass is personal and must be shown with a passport or national ID card on request.</li>
<li>Journeys that start before midnight count against the date of departure.</li>
<li>Children under 12 travel free with an adult pass holder but still need a reservation.</li>
<li>Bicycles travel in the bike carriage with a 15 euro reservation, subject to space.</li>
<li>Unused passes can be refunded within 30 days of purchase, minus a 15 euro fee.</li>
<li>Lost passes cannot be replaced. Keep the mobile pass in the app with a backup on paper.</li>
<li>If a train is more than 60 minutes late at your destination, claim a 25 percent refund online.</li>
</ol>
</div>
</div>
CSS
/* Scroll progress indicators in Bootstrap 5: .fixed-top, .position-fixed, .sticky-top, .rounded-circle, .shadow,
.border and .overflow-auto for placement; the indicators are the shared CSS below. One registered custom property, --prg (0 to 1), drives every indicator: a bar's
scaleX, a chapter bar and the ring's stroke. Where scroll-driven animations are supported, a scroll() or view()
timeline animates --prg; elsewhere the small script sets it on scroll. Under prefers-reduced-motion the
indicators are not drawn at all. Every indicator is decorative (aria-hidden); the article itself is plain text. */
@property --prg{syntax:"<number>";inherits:false;initial-value:0}
:root{--prg-ink:#14213d;--prg-muted:#4a5468;--prg-paper:#fdfcf8;--prg-line:#e4e1d8;
--prg-fill:linear-gradient(90deg,#d97706,#e11d48 55%,#7c3aed);--prg-ring:#c2185b}
.prg-article,.prg-box{color:var(--prg-ink)}
.prg-article :where(h2,h3,p),.prg-box :where(h3,p){margin:0}
/* 1. page bar, fixed to the top of the window */
.prg-bar{height:5px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
/* 2. back-to-top link with a progress ring */
.prg-top{z-index:1030;place-items:center;width:56px;height:56px;color:var(--prg-ink);text-decoration:none}
.prg-top svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
.prg-top circle{fill:none;stroke-width:4}
.prg-top .prg-track{stroke:var(--prg-line)}
.prg-top .prg-arc{stroke:var(--prg-ring);stroke-linecap:round;stroke-dasharray:100;stroke-dashoffset:calc(100 - var(--prg) * 100)}
.prg-top b{position:relative;font-size:1.25rem;line-height:1}
.prg-top:focus-visible{outline:3px solid var(--prg-ink);outline-offset:3px}
/* 3. article with sticky chapter headings, each with its own bar */
.prg-article header{border-color:var(--prg-line)!important}
.prg-kicker{font-size:.78rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#a3195b}
.prg-title{margin-top:10px!important;font-weight:700;font-family:Georgia,"Times New Roman",serif;font-size:clamp(2rem,1.4rem + 2.6vw,3.1rem);line-height:1.08;letter-spacing:-.02em}
.prg-meta{margin-top:12px!important;font-size:.9rem;color:var(--prg-muted)}
.prg-chapter{view-timeline:--prg-chapter block;padding-top:8px}
.prg-chapter-head{top:5px;margin:0 -12px 16px;padding:14px 12px 12px;background:var(--prg-paper);border-bottom:3px solid var(--prg-line)}
.prg-chapter-head h3{font-size:1.05rem;font-weight:700}
.prg-chapter-head span{color:#a3195b;margin-right:6px}
.prg-chapter-bar{position:absolute;left:0;right:0;bottom:-3px;height:3px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
.prg-chapter p{margin:0 0 18px;font-family:Georgia,"Times New Roman",serif;font-size:1.08rem;line-height:1.75}
/* 4. inside a scroll container: sticky header with a bar, and a bottom shadow while more is below */
.prg-box{height:380px;border-color:var(--prg-line)!important;
background:linear-gradient(rgba(255,255,255,0),#fff 70%) bottom/100% 48px no-repeat local,
radial-gradient(farthest-side at 50% 100%,rgba(20,33,61,.22),transparent) bottom/100% 16px no-repeat scroll,#fff}
.prg-box:focus-visible{outline:3px solid var(--prg-ink);outline-offset:3px}
.prg-box-head{padding:16px 22px 0;background:#fff;border-bottom:1px solid var(--prg-line)}
.prg-box-head h3{font-size:1rem;font-weight:700}
.prg-box-head p{font-size:.84rem;color:var(--prg-muted)}
.prg-box-bar{display:block;height:3px;margin:12px -22px -1px;background:var(--prg-fill);transform-origin:0 50%;transform:scaleX(var(--prg))}
.prg-box ol{margin:0;padding:18px 22px 22px 44px;display:grid;gap:12px;font-size:.95rem;color:#2c3a55}
.prg-box li::marker{font-weight:700;color:#a3195b}
/* scroll-driven version */
@keyframes prg-fill{to{--prg:1}}
@supports (animation-timeline:scroll()){
@media (prefers-reduced-motion:no-preference){
[data-progress]{animation:prg-fill linear both;animation-timeline:scroll(root block)}
[data-progress="container"]{animation-timeline:scroll(nearest block)}
[data-progress="section"]{animation-timeline:--prg-chapter;animation-range:exit-crossing 0% exit-crossing 100%}
}
}
@media (prefers-reduced-motion:reduce){
[data-progress]{display:none}
}
JavaScript
// Fallback for browsers without scroll-driven animations: set --prg (0 to 1) on every [data-progress] element.
// data-progress="page" follows the window, "container" the closest [data-progress-root], "section" its section.
(() => {
if (CSS.supports('animation-timeline: scroll()')) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const items = [...document.querySelectorAll('[data-progress]')];
if (!items.length) return;
const clamp = n => Math.min(1, Math.max(0, n || 0));
const progressOf = el => {
const type = el.dataset.progress;
if (type === 'container') { const s = el.closest('[data-progress-root]'); return s.scrollTop / (s.scrollHeight - s.clientHeight); }
if (type === 'section') { const r = el.closest('section').getBoundingClientRect(); return -r.top / r.height; }
const d = document.documentElement; return d.scrollTop / (d.scrollHeight - d.clientHeight);
};
let queued = false;
const update = () => { queued = false; items.forEach(el => el.style.setProperty('--prg', clamp(progressOf(el)).toFixed(4))); };
const request = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', request, { passive: true });
addEventListener('resize', request);
document.querySelectorAll('[data-progress-root]').forEach(s => s.addEventListener('scroll', request, { passive: true }));
update();
})();
Tailwind 4 code
Requires: Tailwind CSS 4
HTML
<div class="fixed inset-x-0 top-0 z-20 h-[5px] origin-left bg-linear-to-r from-[#d97706] via-[#e11d48] via-55% to-[#7c3aed] [transform:scaleX(var(--prg))]" data-progress="page" aria-hidden="true"></div>
<a class="fixed right-[clamp(14px,3vw,28px)] bottom-[clamp(14px,3vw,28px)] z-20 grid size-14 place-items-center rounded-full bg-white text-[#14213d] shadow-[0_10px_30px_-12px_rgba(20,33,61,.45)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#14213d]" href="#top" aria-label="Back to top">
<svg class="absolute inset-0 size-full -rotate-90" viewBox="0 0 56 56" aria-hidden="true"><circle class="fill-none stroke-[#e4e1d8] stroke-4" cx="28" cy="28" r="25"/><circle class="fill-none stroke-[#c2185b] stroke-4 [stroke-dasharray:100] [stroke-dashoffset:calc(100_-_var(--prg)_*_100)] [stroke-linecap:round]" data-progress="page" cx="28" cy="28" r="25" pathLength="100"/></svg>
<b class="relative text-xl leading-none" aria-hidden="true">↑</b>
</a>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5b6478] uppercase">Page bar, progress ring and chapter bars</span>
<article aria-labelledby="prg-title">
<header class="border-b border-[#e4e1d8] pb-7">
<p class="text-[.78rem] font-bold tracking-[.14em] text-[#a3195b] uppercase">Northbound · Travel</p>
<h2 class="mt-2.5 font-[Georgia,'Times_New_Roman',serif] text-[clamp(2rem,1.4rem+2.6vw,3.1rem)] leading-[1.08] font-bold tracking-[-.02em]" id="prg-title">The night train comes back</h2>
<p class="mt-3 text-[.9rem] text-[#4a5468]">By Léa Marchetti · 6 min read · Paris to Vienna in thirteen hours</p>
</header>
<section class="pt-2 [view-timeline:--prg-chapter_block]" aria-labelledby="prg-c1">
<div class="sticky top-[5px] z-[5] -mx-3 mb-4 border-b-3 border-[#e4e1d8] bg-[#fdfcf8] px-3 pt-3.5 pb-3"><h3 class="text-[1.05rem] font-bold" id="prg-c1"><span class="mr-1.5 text-[#a3195b]">1</span>Paris Est, 19:58</h3><i class="absolute inset-x-0 -bottom-[3px] h-[3px] origin-left bg-linear-to-r from-[#d97706] via-[#e11d48] via-55% to-[#7c3aed] [transform:scaleX(var(--prg))]" data-progress="section" aria-hidden="true"></i></div>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">The Lumière Night Express leaves from platform 4, between a commuter train to Meaux and a high-speed service that will be in Strasbourg before we have found our compartment. Nobody runs for it. People arrive with paper bags of bread and cheese, the way they used to.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">Our couchette has four bunks, a ladder, a window that opens a hand's width and a small card explaining, in five languages, how to lock the door from inside. The attendant, Matthias, takes breakfast orders on a clipboard: coffee or tea, croissant or rye bread, and whether we want to be woken at Linz.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">By the time the train clears the Paris suburbs it is dark enough to see our own reflections in the glass, and the dining car has run out of the cheap red wine.</p>
</section>
<section class="pt-2 [view-timeline:--prg-chapter_block]" aria-labelledby="prg-c2">
<div class="sticky top-[5px] z-[5] -mx-3 mb-4 border-b-3 border-[#e4e1d8] bg-[#fdfcf8] px-3 pt-3.5 pb-3"><h3 class="text-[1.05rem] font-bold" id="prg-c2"><span class="mr-1.5 text-[#a3195b]">2</span>Strasbourg to Munich, overnight</h3><i class="absolute inset-x-0 -bottom-[3px] h-[3px] origin-left bg-linear-to-r from-[#d97706] via-[#e11d48] via-55% to-[#7c3aed] [transform:scaleX(var(--prg))]" data-progress="section" aria-hidden="true"></i></div>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">Night trains were meant to be finished. Between 2005 and 2020 most of Europe's sleeper routes closed, undercut by budget flights that cost less than a sandwich in the dining car. What changed was not the trains but the passengers: people who would rather lose a night than spend a day in airports.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">The ride is not silent. The carriage creaks over points, the brakes sigh at every station, and somewhere near Karlsruhe a group of students two compartments down decides to sing. But the bunk is long enough to lie flat, the blanket is heavier than it looks, and the rocking does what it has always done.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">At Munich, at 4:40 in the morning, the train splits in two. Half goes to Venice. We wake for the bump of the coupling and fall asleep again before it has finished.</p>
</section>
<section class="pt-2 [view-timeline:--prg-chapter_block]" aria-labelledby="prg-c3">
<div class="sticky top-[5px] z-[5] -mx-3 mb-4 border-b-3 border-[#e4e1d8] bg-[#fdfcf8] px-3 pt-3.5 pb-3"><h3 class="text-[1.05rem] font-bold" id="prg-c3"><span class="mr-1.5 text-[#a3195b]">3</span>Breakfast in the Danube valley</h3><i class="absolute inset-x-0 -bottom-[3px] h-[3px] origin-left bg-linear-to-r from-[#d97706] via-[#e11d48] via-55% to-[#7c3aed] [transform:scaleX(var(--prg))]" data-progress="section" aria-hidden="true"></i></div>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">Matthias knocks at seven with two coffees and a paper bag of rolls. Outside, the Danube is the colour of slate and the vineyards are still in shadow. A man in the next compartment is shaving in the corridor using the window as a mirror.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">We reach Wien Hauptbahnhof at 9:12, three minutes late, rested in a way no early flight has ever left us. The ticket cost 89 euros for a couchette, about the same as the flight plus the train to the airport, and we gained a night in a hotel we did not have to book.</p>
<p class="mb-[18px] font-[Georgia,'Times_New_Roman',serif] text-[1.08rem] leading-[1.75]">The return train leaves at 19:40. We have already reserved the same bunks.</p>
</section>
</article></div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5b6478] uppercase">Inside a scroll container</span>
<div class="h-[380px] overflow-y-auto rounded-[18px] border border-[#e4e1d8] [background:linear-gradient(rgba(255,255,255,0),#fff_70%)_bottom/100%_48px_no-repeat_local,radial-gradient(farthest-side_at_50%_100%,rgba(20,33,61,.22),transparent)_bottom/100%_16px_no-repeat_scroll,#fff] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#14213d]" data-progress-root tabindex="0" role="region" aria-label="Rail pass conditions, scrollable">
<div class="sticky top-0 z-[1] border-b border-[#e4e1d8] bg-white px-[22px] pt-4"><h3 class="font-bold">Rail pass conditions</h3><p class="text-[.84rem] text-[#4a5468]">Lumière Interrail Night Pass, 2027 season</p><i class="-mx-[22px] mt-3 -mb-px block h-[3px] origin-left bg-linear-to-r from-[#d97706] via-[#e11d48] via-55% to-[#7c3aed] [transform:scaleX(var(--prg))]" data-progress="container" aria-hidden="true"></i></div>
<ol class="grid list-decimal gap-3 pt-[18px] pr-[22px] pb-[22px] pl-11 text-[.95rem] text-[#2c3a55] marker:font-bold marker:text-[#a3195b]">
<li>The pass covers ten night journeys within two months of the first trip, on any Lumière route.</li>
<li>A couchette or seat reservation is required for every journey and costs from 12 euros.</li>
<li>Sleeper compartments can be booked with a supplement of 40 to 95 euros per person per night.</li>
<li>The pass is personal and must be shown with a passport or national ID card on request.</li>
<li>Journeys that start before midnight count against the date of departure.</li>
<li>Children under 12 travel free with an adult pass holder but still need a reservation.</li>
<li>Bicycles travel in the bike carriage with a 15 euro reservation, subject to space.</li>
<li>Unused passes can be refunded within 30 days of purchase, minus a 15 euro fee.</li>
<li>Lost passes cannot be replaced. Keep the mobile pass in the app with a backup on paper.</li>
<li>If a train is more than 60 minutes late at your destination, claim a 25 percent refund online.</li>
</ol>
</div>
</div>
CSS
/* Placement and look are utility classes. One registered custom property, --prg (0 to 1), drives every
indicator; a scroll() or view() timeline animates it where supported, and the script sets it elsewhere.
Under prefers-reduced-motion the indicators are not drawn. All indicators are decorative (aria-hidden). */
@property --prg{syntax:"<number>";inherits:false;initial-value:0}
@keyframes prg-fill{to{--prg:1}}
@supports (animation-timeline:scroll()){
@media (prefers-reduced-motion:no-preference){
[data-progress]{animation:prg-fill linear both;animation-timeline:scroll(root block)}
[data-progress="container"]{animation-timeline:scroll(nearest block)}
[data-progress="section"]{animation-timeline:--prg-chapter;animation-range:exit-crossing 0% exit-crossing 100%}
}
}
@media (prefers-reduced-motion:reduce){
[data-progress]{display:none}
}
JavaScript
// Fallback for browsers without scroll-driven animations: set --prg (0 to 1) on every [data-progress] element.
// data-progress="page" follows the window, "container" the closest [data-progress-root], "section" its section.
(() => {
if (CSS.supports('animation-timeline: scroll()')) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const items = [...document.querySelectorAll('[data-progress]')];
if (!items.length) return;
const clamp = n => Math.min(1, Math.max(0, n || 0));
const progressOf = el => {
const type = el.dataset.progress;
if (type === 'container') { const s = el.closest('[data-progress-root]'); return s.scrollTop / (s.scrollHeight - s.clientHeight); }
if (type === 'section') { const r = el.closest('section').getBoundingClientRect(); return -r.top / r.height; }
const d = document.documentElement; return d.scrollTop / (d.scrollHeight - d.clientHeight);
};
let queued = false;
const update = () => { queued = false; items.forEach(el => el.style.setProperty('--prg', clamp(progressOf(el)).toFixed(4))); };
const request = () => { if (!queued) { queued = true; requestAnimationFrame(update); } };
addEventListener('scroll', request, { passive: true });
addEventListener('resize', request);
document.querySelectorAll('[data-progress-root]').forEach(s => s.addEventListener('scroll', request, { passive: true }));
update();
})();
About this set
Four reading progress indicators around a long travel article. A gradient bar fixed to the top of the window fills as the page scrolls. A round back-to-top link in the corner draws a progress ring around its arrow. Each chapter heading sticks to the top while you read that chapter and fills its own thin bar, so readers can see how far they are through the section as well as the page. The last example is a box of rail pass conditions with its own sticky header bar that follows the box’s scroll position, plus a soft shadow at the bottom edge that disappears when you reach the end. All four are driven by one registered custom property, –prg, which runs from 0 to 1. Where scroll-driven animations are supported, a scroll() timeline animates it for the page and the box, and a named view() timeline with the exit-crossing range animates it for each chapter. In other browsers a small script sets the same property on scroll, throttled with requestAnimationFrame. Use the top bar for long articles, the ring when you also want a way back up, and chapter bars for guides with clear sections.
What’s included
Fixed page bar, progress ring on a back-to-top link and sticky chapter bars
A scroll container with its own sticky progress bar and bottom shadow
One @property --prg drives every indicator
scroll() and view() timelines with a requestAnimationFrame fallback
Indicators are decorative and switch off under reduced motion
Accessibility
The bars and the ring are aria-hidden because they only repeat what the scroll position already tells assistive technology. The back-to-top link has an accessible name and a visible focus outline, and the conditions box is a labelled, focusable region that scrolls with the arrow keys. Under prefers-reduced-motion no indicator is drawn and the article reads as plain text.
Customise it
Change --prg-fill for the gradient, --prg-ring for the ring colour and the bar heights in the rules for .prg-bar and .prg-chapter-bar. In Tailwind the gradient is the from-, via- and to- classes on each bar.