18 CSS Scroll Progress Bar08 / 18

Light JSMIT licensed

Container-Scoped Scroll Progress Bar (Inside a Div)

The version nobody documents: the thing that scrolls is not the page, it is a panel. App shells, modals, chat logs, terms-and-conditions boxes and dashboard cards all scroll their own overflow container — where scroll(root) is permanently zero. Named scroll timelines plus timeline-scope are the correct answer, and this demo is the complete recipe.

Published

Live Demo
Try it

The code

<section class="sp-08" aria-label="Container-scoped scroll progress bar demo">
  <header class="sp-08__bar">
    <a class="sp-08__brand" href="#sp-08-panel"><span class="sp-08__logo" aria-hidden="true"></span>Panelworks</a>
    <p class="sp-08__meta">scroll-timeline-name</p>
  </header>
  <div class="sp-08__intro">
    <p class="sp-08__intro-eyebrow">the page is not always the scroller</p>
    <h2 class="sp-08__intro-title">Progress for a div, not a document</h2>
    <p class="sp-08__intro-sub">Scroll <em>inside</em> the panel below. The bar in its header is bound to the panel's own named timeline — the page behind it never moves.</p>
  </div>
  <div class="sp-08__panel" id="sp-08-panel">
    <div class="sp-08__phead">
      <div class="sp-08__ptitle">
        <span class="sp-08__pdot" aria-hidden="true"></span>
        <h3 class="sp-08__ph">Terms of Service</h3>
      </div>
      <p class="sp-08__ppct" id="sp-08-pct" role="progressbar" aria-label="Terms read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</p>
      <i class="sp-08__pfill" aria-hidden="true"></i>
    </div>
    <div class="sp-08__body" id="sp-08-body" tabindex="0" role="region" aria-label="Terms of Service, scrollable">
      <p class="sp-08__lede">This panel owns its own scroll port. Everything below lives inside it, and the hairline in the header above tracks only this box.</p>
      <h4 class="sp-08__sh">1. Named timelines</h4>
      <p class="sp-08__bp">An anonymous <code>scroll()</code> timeline can only look up the tree. A header sitting above the scroller is a sibling, so it needs a name it can reference from anywhere in the scope.</p>
      <h4 class="sp-08__sh">2. Scope</h4>
      <p class="sp-08__bp">The panel declares <code>timeline-scope</code>. That single property lifts the name out of the scroller's subtree and makes it visible to the header, a sidebar, or a toolbar three levels away.</p>
      <h4 class="sp-08__sh">3. Containment</h4>
      <p class="sp-08__bp">A flick that reaches the bottom of a panel should stop there, not throw the page behind it. <code>overscroll-behavior: contain</code> is one line and prevents the most-reported bug in modal scroll areas.</p>
      <h4 class="sp-08__sh">4. Gutter stability</h4>
      <p class="sp-08__bp"><code>scrollbar-gutter: stable</code> reserves the scrollbar's width up front, so content does not shift sideways the moment a list grows past one screen.</p>
      <h4 class="sp-08__sh">5. Keyboard access</h4>
      <p class="sp-08__bp">A scrollable region that is not focusable cannot be scrolled by keyboard alone. It carries <code>tabindex="0"</code> and a <code>role</code> with a name, so arrow keys and Page Down work as expected.</p>
      <h4 class="sp-08__sh">6. Listening in the right place</h4>
      <p class="sp-08__bp">Window scroll events never fire for an inner div. The fallback listener is attached to this element, which is also the correct instinct for any nested scroller.</p>
      <h4 class="sp-08__sh">7. Bounded height</h4>
      <p class="sp-08__bp">A scroller with no height constraint never overflows, and a timeline over a non-overflowing scroller has zero length — the animation sits frozen at its start value with no error anywhere.</p>
      <h4 class="sp-08__sh">8. Multiple panels</h4>
      <p class="sp-08__bp">Names are scoped, so a dashboard of six cards is six declarations. Each bar tracks its own card and nothing coordinates through JavaScript.</p>
      <p class="sp-08__end">You have reached the end of the panel. The header hairline is full; the page behind you has not moved a pixel.</p>
    </div>
  </div>
  <section class="sp-08__after">
    <p class="sp-08__kicker">Why it matters</p>
    <h3 class="sp-08__h">Most real apps scroll a div</h3>
    <p class="sp-08__p">Fixed sidebars, sticky toolbars, virtualised lists, modals, drawers — in all of them the document body is pinned and an inner element does the scrolling. Every <code>scroll(root)</code> snippet on the internet quietly breaks in that layout.</p>
    <p class="sp-08__chip">1 name · 1 scope · 0 coordination code</p>
  </section>
  <footer class="sp-08__pagefoot"><p class="sp-08__pagefootin">Scroll progress pattern 08 of 18 · codefronts.com</p></footer>
</section>
.sp-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sp-08-bg);
  --sp-08-bg: oklch(0.965 0.008 245);
  --sp-08-surface: oklch(1 0 0);
  --sp-08-ink: oklch(0.2 0.022 255);
  --sp-08-mut: oklch(0.52 0.018 255);
  --sp-08-acc: oklch(0.58 0.16 235);
  --sp-08-line: oklch(0.2 0.022 255/.11);
  --sp-08-p: 0;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sp-08-ink);
  -webkit-font-smoothing: antialiased;
}

.sp-08 *,
.sp-08 *::before,
.sp-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.sp-08__bar {
  position: sticky;
  inset-block-start: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 14px;
  min-block-size: 58px;
  background: color-mix(in oklch,var(--sp-08-surface) 82%,transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-block-end: 1px solid var(--sp-08-line);
}

.sp-08__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 15px;
  font-weight: 730;
  letter-spacing: -.02em;
  color: inherit;
  text-decoration: none;
}

.sp-08__brand:focus-visible {
  outline: 2px solid var(--sp-08-acc);
  outline-offset: 3px;
  border-radius: 6px;
}

.sp-08__logo {
  inline-size: 18px;
  block-size: 18px;
  border-radius: 5px;
  border: 2px solid var(--sp-08-acc);
  background: linear-gradient(180deg,transparent 55%,var(--sp-08-acc) 55%);
}

.sp-08__meta {
  margin-inline-start: auto;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .08em;
  color: var(--sp-08-mut);
}

.sp-08__intro {
  display: grid;
  gap: 14px;
  justify-items: center;
  text-align: center;
  padding-block: clamp(44px,9vh,96px) clamp(24px,5vh,48px);
}

.sp-08__intro-eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--sp-08-acc);
}

.sp-08__intro-title {
  font-size: clamp(32px,5.6vw,64px);
  line-height: 1;
  letter-spacing: -.04em;
  font-weight: 730;
  max-inline-size: 20ch;
  text-wrap: balance;
}

.sp-08__intro-sub {
  font-size: clamp(14.5px,1.6vw,18px);
  line-height: 1.55;
  color: var(--sp-08-mut);
  max-inline-size: 52ch;
  text-wrap: pretty;
}

.sp-08__panel {
  timeline-scope: --sp-08-tl;
  display: grid;
  grid-template-rows: auto minmax(0,1fr);
  block-size: min(66svh,560px);
  margin-inline: auto;
  max-inline-size: min(760px,calc(100% - clamp(24px,6vw,64px)));
  border: 1px solid var(--sp-08-line);
  border-radius: 18px;
  background: var(--sp-08-surface);
  overflow: hidden;
  box-shadow: 0 26px 60px -40px oklch(0.2 0.022 255/.7);
}

.sp-08__phead {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 18px;
  border-block-end: 1px solid var(--sp-08-line);
  background: color-mix(in oklch,var(--sp-08-surface) 92%,var(--sp-08-acc) 8%);
}

.sp-08__ptitle {
  display: flex;
  align-items: center;
  gap: 9px;
  min-inline-size: 0;
}

.sp-08__pdot {
  inline-size: 9px;
  block-size: 9px;
  border-radius: 50%;
  background: var(--sp-08-acc);
  box-shadow: 0 0 0 4px color-mix(in oklch,var(--sp-08-acc) 18%,transparent);
}

.sp-08__ph {
  font-size: 14.5px;
  font-weight: 700;
  letter-spacing: -.015em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sp-08__ppct {
  margin-inline-start: auto;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  font-weight: 650;
  color: var(--sp-08-acc);
}

.sp-08__pfill {
  position: absolute;
  inset: auto 0 0 0;
  block-size: 3px;
  transform-origin: 0 50%;
  transform: scaleX(var(--sp-08-p));
  background: linear-gradient(90deg,var(--sp-08-acc),oklch(0.72 0.15 200));
}

.sp-08__body {
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklch,var(--sp-08-acc) 55%,transparent) transparent;
  padding: 20px clamp(16px,3vw,26px) 28px;
  display: grid;
  gap: 12px;
  align-content: start;
}

@supports (animation-timeline: scroll()) {
  .sp-08__body {
    scroll-timeline-name: --sp-08-tl;
    scroll-timeline-axis: block;
  }

  .sp-08__pfill {
    transform: scaleX(0);
    animation: sp-08-fill linear both;
    animation-timeline: --sp-08-tl;
  }
}

@keyframes sp-08-fill {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}

.sp-08__body:focus-visible {
  outline: 2px solid var(--sp-08-acc);
  outline-offset: -3px;
}

.sp-08__lede {
  font-size: 15.5px;
  line-height: 1.65;
  color: var(--sp-08-ink);
  text-wrap: pretty;
}

.sp-08__sh {
  margin-block-start: 8px;
  font-size: 13.4px;
  font-weight: 700;
  letter-spacing: .01em;
  color: var(--sp-08-acc);
}

.sp-08__bp {
  font-size: 14.6px;
  line-height: 1.68;
  color: var(--sp-08-mut);
  text-wrap: pretty;
}

.sp-08__bp code,
.sp-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.2 0.022 255/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

.sp-08__end {
  margin-block-start: 10px;
  padding: 12px 14px;
  border-radius: 12px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--sp-08-acc);
  background: color-mix(in oklch,var(--sp-08-acc) 9%,transparent);
  border: 1px solid color-mix(in oklch,var(--sp-08-acc) 22%,transparent);
}

.sp-08__after {
  display: grid;
  gap: 12px;
  padding-block: clamp(40px,8vh,90px);
}

.sp-08__kicker {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--sp-08-acc);
}

.sp-08__h {
  font-size: clamp(22px,3.2vw,32px);
  line-height: 1.12;
  letter-spacing: -.032em;
  font-weight: 700;
  text-wrap: balance;
}

.sp-08__p {
  max-inline-size: 62ch;
  font-size: 15.8px;
  line-height: 1.7;
  color: var(--sp-08-mut);
  text-wrap: pretty;
}

.sp-08__chip {
  justify-self: start;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--sp-08-acc);
  padding: 7px 13px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--sp-08-acc) 10%,transparent);
  border: 1px solid color-mix(in oklch,var(--sp-08-acc) 24%,transparent);
}

.sp-08__pagefoot {
  display: grid;
  place-items: center;
  padding: clamp(40px,8vh,100px) 24px;
  border-block-start: 1px solid var(--sp-08-line);
}

.sp-08__pagefootin {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  letter-spacing: .06em;
  color: var(--sp-08-mut);
  text-align: center;
}

.sp-08__bar {
  padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}

.sp-08__intro,
.sp-08__after {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 760px)/2));
}

@media (max-width: 640px) {
  .sp-08__panel {
    block-size: min(70svh,480px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .sp-08 * {
    transition-duration: .01ms !important;
  }
}
(() => {
  const root = document.querySelector('.sp-08');
  if (!root || root.dataset.spWired) return;
  root.dataset.spWired = '1';
  const body = root.querySelector('#sp-08-body');
  const pct = root.querySelector('#sp-08-pct');
  const native = CSS.supports('animation-timeline', 'scroll()');
  let ticking = false;
  const paint = () => {
    ticking = false;
    const max = body.scrollHeight - body.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, body.scrollTop / max)) : 0;
    if (!native) root.style.setProperty('--sp-08-p', p.toFixed(4));
    const n = Math.round(p * 100);
    pct.textContent = n + '%';
    pct.setAttribute('aria-valuenow', String(n));
  };
  // note: listen on the panel, never on window — window scroll never fires for an inner div
  body.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
  addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
  paint();
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Scroll Progress Bar from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Container-Scoped Scroll Progress Bar (Inside a Div)
Source: https://codefronts.com/motion/css-scroll-progress-bar/container-scoped-scroll-progress-bar-inside-a-div/

The version nobody documents: the thing that scrolls is not the page, it is a panel. App shells, modals, chat logs, terms-and-conditions boxes and dashboard cards all scroll their own overflow container — where scroll(root) is permanently zero. Named scroll timelines plus timeline-scope are the correct answer, and this demo is the complete recipe.
## HTML
```html
<section class="sp-08" aria-label="Container-scoped scroll progress bar demo">
  <header class="sp-08__bar">
    <a class="sp-08__brand" href="#sp-08-panel"><span class="sp-08__logo" aria-hidden="true"></span>Panelworks</a>
    <p class="sp-08__meta">scroll-timeline-name</p>
  </header>
  <div class="sp-08__intro">
    <p class="sp-08__intro-eyebrow">the page is not always the scroller</p>
    <h2 class="sp-08__intro-title">Progress for a div, not a document</h2>
    <p class="sp-08__intro-sub">Scroll <em>inside</em> the panel below. The bar in its header is bound to the panel's own named timeline — the page behind it never moves.</p>
  </div>
  <div class="sp-08__panel" id="sp-08-panel">
    <div class="sp-08__phead">
      <div class="sp-08__ptitle">
        <span class="sp-08__pdot" aria-hidden="true"></span>
        <h3 class="sp-08__ph">Terms of Service</h3>
      </div>
      <p class="sp-08__ppct" id="sp-08-pct" role="progressbar" aria-label="Terms read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</p>
      <i class="sp-08__pfill" aria-hidden="true"></i>
    </div>
    <div class="sp-08__body" id="sp-08-body" tabindex="0" role="region" aria-label="Terms of Service, scrollable">
      <p class="sp-08__lede">This panel owns its own scroll port. Everything below lives inside it, and the hairline in the header above tracks only this box.</p>
      <h4 class="sp-08__sh">1. Named timelines</h4>
      <p class="sp-08__bp">An anonymous <code>scroll()</code> timeline can only look up the tree. A header sitting above the scroller is a sibling, so it needs a name it can reference from anywhere in the scope.</p>
      <h4 class="sp-08__sh">2. Scope</h4>
      <p class="sp-08__bp">The panel declares <code>timeline-scope</code>. That single property lifts the name out of the scroller's subtree and makes it visible to the header, a sidebar, or a toolbar three levels away.</p>
      <h4 class="sp-08__sh">3. Containment</h4>
      <p class="sp-08__bp">A flick that reaches the bottom of a panel should stop there, not throw the page behind it. <code>overscroll-behavior: contain</code> is one line and prevents the most-reported bug in modal scroll areas.</p>
      <h4 class="sp-08__sh">4. Gutter stability</h4>
      <p class="sp-08__bp"><code>scrollbar-gutter: stable</code> reserves the scrollbar's width up front, so content does not shift sideways the moment a list grows past one screen.</p>
      <h4 class="sp-08__sh">5. Keyboard access</h4>
      <p class="sp-08__bp">A scrollable region that is not focusable cannot be scrolled by keyboard alone. It carries <code>tabindex="0"</code> and a <code>role</code> with a name, so arrow keys and Page Down work as expected.</p>
      <h4 class="sp-08__sh">6. Listening in the right place</h4>
      <p class="sp-08__bp">Window scroll events never fire for an inner div. The fallback listener is attached to this element, which is also the correct instinct for any nested scroller.</p>
      <h4 class="sp-08__sh">7. Bounded height</h4>
      <p class="sp-08__bp">A scroller with no height constraint never overflows, and a timeline over a non-overflowing scroller has zero length — the animation sits frozen at its start value with no error anywhere.</p>
      <h4 class="sp-08__sh">8. Multiple panels</h4>
      <p class="sp-08__bp">Names are scoped, so a dashboard of six cards is six declarations. Each bar tracks its own card and nothing coordinates through JavaScript.</p>
      <p class="sp-08__end">You have reached the end of the panel. The header hairline is full; the page behind you has not moved a pixel.</p>
    </div>
  </div>
  <section class="sp-08__after">
    <p class="sp-08__kicker">Why it matters</p>
    <h3 class="sp-08__h">Most real apps scroll a div</h3>
    <p class="sp-08__p">Fixed sidebars, sticky toolbars, virtualised lists, modals, drawers — in all of them the document body is pinned and an inner element does the scrolling. Every <code>scroll(root)</code> snippet on the internet quietly breaks in that layout.</p>
    <p class="sp-08__chip">1 name · 1 scope · 0 coordination code</p>
  </section>
  <footer class="sp-08__pagefoot"><p class="sp-08__pagefootin">Scroll progress pattern 08 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
.sp-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sp-08-bg);
  --sp-08-bg: oklch(0.965 0.008 245);
  --sp-08-surface: oklch(1 0 0);
  --sp-08-ink: oklch(0.2 0.022 255);
  --sp-08-mut: oklch(0.52 0.018 255);
  --sp-08-acc: oklch(0.58 0.16 235);
  --sp-08-line: oklch(0.2 0.022 255/.11);
  --sp-08-p: 0;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sp-08-ink);
  -webkit-font-smoothing: antialiased;
}

.sp-08 *,
.sp-08 *::before,
.sp-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.sp-08__bar {
  position: sticky;
  inset-block-start: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 14px;
  min-block-size: 58px;
  background: color-mix(in oklch,var(--sp-08-surface) 82%,transparent);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-block-end: 1px solid var(--sp-08-line);
}

.sp-08__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 15px;
  font-weight: 730;
  letter-spacing: -.02em;
  color: inherit;
  text-decoration: none;
}

.sp-08__brand:focus-visible {
  outline: 2px solid var(--sp-08-acc);
  outline-offset: 3px;
  border-radius: 6px;
}

.sp-08__logo {
  inline-size: 18px;
  block-size: 18px;
  border-radius: 5px;
  border: 2px solid var(--sp-08-acc);
  background: linear-gradient(180deg,transparent 55%,var(--sp-08-acc) 55%);
}

.sp-08__meta {
  margin-inline-start: auto;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .08em;
  color: var(--sp-08-mut);
}

.sp-08__intro {
  display: grid;
  gap: 14px;
  justify-items: center;
  text-align: center;
  padding-block: clamp(44px,9vh,96px) clamp(24px,5vh,48px);
}

.sp-08__intro-eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--sp-08-acc);
}

.sp-08__intro-title {
  font-size: clamp(32px,5.6vw,64px);
  line-height: 1;
  letter-spacing: -.04em;
  font-weight: 730;
  max-inline-size: 20ch;
  text-wrap: balance;
}

.sp-08__intro-sub {
  font-size: clamp(14.5px,1.6vw,18px);
  line-height: 1.55;
  color: var(--sp-08-mut);
  max-inline-size: 52ch;
  text-wrap: pretty;
}

.sp-08__panel {
  timeline-scope: --sp-08-tl;
  display: grid;
  grid-template-rows: auto minmax(0,1fr);
  block-size: min(66svh,560px);
  margin-inline: auto;
  max-inline-size: min(760px,calc(100% - clamp(24px,6vw,64px)));
  border: 1px solid var(--sp-08-line);
  border-radius: 18px;
  background: var(--sp-08-surface);
  overflow: hidden;
  box-shadow: 0 26px 60px -40px oklch(0.2 0.022 255/.7);
}

.sp-08__phead {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 18px;
  border-block-end: 1px solid var(--sp-08-line);
  background: color-mix(in oklch,var(--sp-08-surface) 92%,var(--sp-08-acc) 8%);
}

.sp-08__ptitle {
  display: flex;
  align-items: center;
  gap: 9px;
  min-inline-size: 0;
}

.sp-08__pdot {
  inline-size: 9px;
  block-size: 9px;
  border-radius: 50%;
  background: var(--sp-08-acc);
  box-shadow: 0 0 0 4px color-mix(in oklch,var(--sp-08-acc) 18%,transparent);
}

.sp-08__ph {
  font-size: 14.5px;
  font-weight: 700;
  letter-spacing: -.015em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.sp-08__ppct {
  margin-inline-start: auto;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  font-weight: 650;
  color: var(--sp-08-acc);
}

.sp-08__pfill {
  position: absolute;
  inset: auto 0 0 0;
  block-size: 3px;
  transform-origin: 0 50%;
  transform: scaleX(var(--sp-08-p));
  background: linear-gradient(90deg,var(--sp-08-acc),oklch(0.72 0.15 200));
}

.sp-08__body {
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklch,var(--sp-08-acc) 55%,transparent) transparent;
  padding: 20px clamp(16px,3vw,26px) 28px;
  display: grid;
  gap: 12px;
  align-content: start;
}

@supports (animation-timeline: scroll()) {
  .sp-08__body {
    scroll-timeline-name: --sp-08-tl;
    scroll-timeline-axis: block;
  }

  .sp-08__pfill {
    transform: scaleX(0);
    animation: sp-08-fill linear both;
    animation-timeline: --sp-08-tl;
  }
}

@keyframes sp-08-fill {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}

.sp-08__body:focus-visible {
  outline: 2px solid var(--sp-08-acc);
  outline-offset: -3px;
}

.sp-08__lede {
  font-size: 15.5px;
  line-height: 1.65;
  color: var(--sp-08-ink);
  text-wrap: pretty;
}

.sp-08__sh {
  margin-block-start: 8px;
  font-size: 13.4px;
  font-weight: 700;
  letter-spacing: .01em;
  color: var(--sp-08-acc);
}

.sp-08__bp {
  font-size: 14.6px;
  line-height: 1.68;
  color: var(--sp-08-mut);
  text-wrap: pretty;
}

.sp-08__bp code,
.sp-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.2 0.022 255/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

.sp-08__end {
  margin-block-start: 10px;
  padding: 12px 14px;
  border-radius: 12px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--sp-08-acc);
  background: color-mix(in oklch,var(--sp-08-acc) 9%,transparent);
  border: 1px solid color-mix(in oklch,var(--sp-08-acc) 22%,transparent);
}

.sp-08__after {
  display: grid;
  gap: 12px;
  padding-block: clamp(40px,8vh,90px);
}

.sp-08__kicker {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--sp-08-acc);
}

.sp-08__h {
  font-size: clamp(22px,3.2vw,32px);
  line-height: 1.12;
  letter-spacing: -.032em;
  font-weight: 700;
  text-wrap: balance;
}

.sp-08__p {
  max-inline-size: 62ch;
  font-size: 15.8px;
  line-height: 1.7;
  color: var(--sp-08-mut);
  text-wrap: pretty;
}

.sp-08__chip {
  justify-self: start;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  color: var(--sp-08-acc);
  padding: 7px 13px;
  border-radius: 99px;
  background: color-mix(in oklch,var(--sp-08-acc) 10%,transparent);
  border: 1px solid color-mix(in oklch,var(--sp-08-acc) 24%,transparent);
}

.sp-08__pagefoot {
  display: grid;
  place-items: center;
  padding: clamp(40px,8vh,100px) 24px;
  border-block-start: 1px solid var(--sp-08-line);
}

.sp-08__pagefootin {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  letter-spacing: .06em;
  color: var(--sp-08-mut);
  text-align: center;
}

.sp-08__bar {
  padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}

.sp-08__intro,
.sp-08__after {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 760px)/2));
}

@media (max-width: 640px) {
  .sp-08__panel {
    block-size: min(70svh,480px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .sp-08 * {
    transition-duration: .01ms !important;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.sp-08');
  if (!root || root.dataset.spWired) return;
  root.dataset.spWired = '1';
  const body = root.querySelector('#sp-08-body');
  const pct = root.querySelector('#sp-08-pct');
  const native = CSS.supports('animation-timeline', 'scroll()');
  let ticking = false;
  const paint = () => {
    ticking = false;
    const max = body.scrollHeight - body.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, body.scrollTop / max)) : 0;
    if (!native) root.style.setProperty('--sp-08-p', p.toFixed(4));
    const n = Math.round(p * 100);
    pct.textContent = n + '%';
    pct.setAttribute('aria-valuenow', String(n));
  };
  // note: listen on the panel, never on window — window scroll never fires for an inner div
  body.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
  addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
  paint();
})();
```

How this works

An anonymous scroll() timeline can only look up the tree (nearest, root, self). When the progress bar is a sibling of the scroller — a panel header sitting above it — nothing anonymous will reach. Name the timeline instead:

.panel{ timeline-scope: --panel-tl }        /* hoist the name to a shared ancestor */
.panel__body{                              /* the actual scroller */
  overflow-y: auto;
  scroll-timeline-name: --panel-tl;
  scroll-timeline-axis: block;
}
.panel__head::after{                        /* the bar, outside the scroller */
  animation: sp-fill linear both;
  animation-timeline: --panel-tl;
}

scroll-timeline-name declares a timeline that, by default, is only visible to that element's descendants. timeline-scope on a common ancestor lifts the name so any element inside that ancestor can consume it. Between the two, a progress bar can live anywhere in the layout — a fixed toolbar, a sidebar, another panel entirely.

The rest is scroll-container hygiene that most panels get wrong. overscroll-behavior:contain stops a flick at the bottom of the panel from chaining into the page behind it. scrollbar-gutter:stable reserves the scrollbar's width so content does not jump sideways when the list grows past one screen. And the panel header is position:sticky inside the scroller, so it pins against the panel rather than the viewport.

Where scroll timelines are missing, the fallback listens on the panel element itself — never on window — which is also the right instinct for any nested scroller: window scroll events never fire for an inner div.

Make it yours

  • Multiple panels on one screen: give each its own name (--sp-08-a, --sp-08-b) and scope them all on the grid wrapper. Names are per-scope, so a dashboard of six cards is six declarations.
  • Horizontal panel: switch scroll-timeline-axis to inline and the bar to scaleX on the same name.
  • Bar at the panel's bottom edge instead: move the pseudo-element to a sticky footer; nothing about the timeline changes.
  • Shadow that appears only when scrolled: add a second animation on the same timeline with animation-range: 0 40px, fading in an inset shadow under the header.
  • Progress-driven header title fade: bind the panel title's opacity to the same timeline over the first 15% for an app-like crossfade.

Gotchas — read before shipping

  • scroll(root) inside an app shell that scrolls a div is the classic silent failure — the timeline exists, it just never advances. Reach for a named timeline the moment the page body is not the scroller.
  • Without timeline-scope on a shared ancestor, a name declared on the scroller is invisible to siblings and ancestors. There is no warning; the animation simply sits at its start value.
  • The scroller needs a bounded height (height, max-height, or a grid/flex track that constrains it) or it will not overflow, and a non-overflowing scroller produces a zero-length timeline.
  • window.addEventListener('scroll') never fires for an inner div. Listen on the element.
  • Omitting overscroll-behavior:contain lets a fast flick chain into the page behind the panel — the single most common complaint about in-app scroll areas.
  • Nested scrollers inherit scroll-behavior from the root; if the page sets smooth scrolling globally, programmatic panel scrolling will also animate, which is often unwanted.

Browser support

ChromeSafariFirefoxEdge
115+26+144+115+

scroll-timeline-name, scroll-timeline-axis and timeline-scope all ship together with animation-timeline. scrollbar-gutter is Chrome 94+, Firefox 97+, Safari 18.2+. The panel-scoped fallback listener works in every browser back to IE-era APIs.

Techniques used in this demo

Search CodeFronts

Loading…