18 CSS Scroll Progress Bar02 / 18

Light JSMIT licensed

Sticky Header with Integrated Scroll Progress Bar (Glassmorphic)

The pattern most publishers actually ship: the progress bar is not a separate strip floating over the page, it is the bottom edge of a frosted-glass header. Content dissolves under the blur as it passes, the hairline fills left-to-right, and the header quietly gains depth the moment it detaches from the top of the page.

Published

Live Demo
Try it

The code

<section class="sp-02" aria-label="Glassmorphic sticky header with progress bar demo">
  <div class="sp-02__sentinel" id="sp-02-sentinel" aria-hidden="true"></div>
  <header class="sp-02__bar" id="sp-02-bar">
    <a class="sp-02__brand" href="#sp-02-s1"><span class="sp-02__logo" aria-hidden="true"></span>Prism<span class="sp-02__brandthin">Journal</span></a>
    <nav class="sp-02__nav" aria-label="Article sections">
      <a class="sp-02__link" href="#sp-02-s1" aria-current="page">Overview</a>
      <a class="sp-02__link" href="#sp-02-s2">Method</a>
      <a class="sp-02__link" href="#sp-02-s3">Results</a>
    </nav>
    <p class="sp-02__pct" id="sp-02-pct" role="progressbar" aria-label="Article read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</p>
  </header>
  <div class="sp-02__hero">
    <img class="sp-02__heroimg" src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&amp;fit=crop&amp;w=1800&amp;q=70" alt="A mountain ridge under a deep blue night sky" loading="eager" decoding="async">
    <div class="sp-02__herotext">
      <p class="sp-02__eyebrow">Field notes · 9 min</p>
      <h2 class="sp-02__htitle">Frosted glass, one hairline, no extra markup</h2>
      <p class="sp-02__hsub">The indicator is the header's bottom edge — scroll and watch it fill while the photograph dissolves underneath.</p>
    </div>
  </div>
  <main class="sp-02__main">
    <section class="sp-02__sec" id="sp-02-s1">
      <p class="sp-02__kicker">Overview</p>
      <h3 class="sp-02__h">One element, three layers</h3>
      <p class="sp-02__p sp-02__p--lede">Glass plate, content row, progress edge. The edge is a pseudo-element, so it shares the header's stacking context and can never be painted over.</p>
      <p class="sp-02__p">Blur alone drains colour. Pairing <code>blur()</code> with <code>saturate()</code> restores it, which is why real frosted glass looks lively and a plain translucent rectangle looks dead.</p>
    </section>
    <section class="sp-02__sec" id="sp-02-s2">
      <p class="sp-02__kicker">Method</p>
      <h3 class="sp-02__h">Depth on detach</h3>
      <p class="sp-02__p">The header starts flat against the hero and gains a shadow the instant it detaches. Where <code>scroll-state(stuck: top)</code> exists that is pure CSS; everywhere else a single sentinel and an IntersectionObserver set the same state.</p>
      <figure class="sp-02__fig">
        <img class="sp-02__img" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&amp;fit=crop&amp;w=1400&amp;q=70" alt="Mist moving between dark pine trees" loading="lazy" decoding="async">
        <figcaption class="sp-02__cap">Translucency only reads as glass when something interesting passes behind it.</figcaption>
      </figure>
    </section>
    <section class="sp-02__sec" id="sp-02-s3">
      <p class="sp-02__kicker">Results</p>
      <h3 class="sp-02__h">The percentage is for people, not for looks</h3>
      <p class="sp-02__p">The numeric readout doubles as the accessible name of a real <code>progressbar</code>. Screen reader users get the same information sighted users get from the hairline.</p>
    </section>
    <section class="sp-02__sec" id="sp-02-s4">
      <p class="sp-02__kicker">Cost</p>
      <h3 class="sp-02__h">Know what blur costs</h3>
      <p class="sp-02__p">A blurred sticky header repaints every scroll frame. It is worth it above the fold on desktop and rarely worth it on a low-end phone — drop to an opaque background under 768px and nobody notices.</p>
      <p class="sp-02__chip">1 header · 1 pseudo-element · 0 extra nodes</p>
    </section>
  </main>
  <footer class="sp-02__pagefoot"><p class="sp-02__pagefootin">Scroll progress pattern 02 of 18 · codefronts.com</p></footer>
</section>
.sp-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sp-02-bg);
  --sp-02-bg: oklch(0.972 0.008 250);
  --sp-02-surface: oklch(1 0 0);
  --sp-02-ink: oklch(0.2 0.024 265);
  --sp-02-mut: oklch(0.53 0.02 265);
  --sp-02-acc: oklch(0.6 0.19 250);
  --sp-02-acc2: oklch(0.68 0.17 320);
  --sp-02-line: oklch(0.2 0.024 265/.1);
  --sp-02-h: 3px;
  --sp-02-bar: 62px;
  --sp-02-p: 0;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sp-02-ink);
  -webkit-font-smoothing: antialiased;
}

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

.sp-02__sentinel {
  block-size: 1px;
  margin-block-end: -1px;
}

.sp-02__bar {
  position: sticky;
  inset-block-start: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: clamp(10px,2vw,24px);
  min-block-size: var(--sp-02-bar);
  background: color-mix(in oklch,var(--sp-02-surface) 58%,transparent);
  backdrop-filter: blur(18px) saturate(1.8);
  -webkit-backdrop-filter: blur(18px) saturate(1.8);
  box-shadow: inset 0 1px 0 oklch(1 0 0/.6),0 0 0 rgba(0,0,0,0);
  transition: background .3s ease,box-shadow .3s ease;
}

.sp-02__bar::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  block-size: var(--sp-02-h);
  transform-origin: 0 50%;
  transform: scaleX(var(--sp-02-p));
  background: linear-gradient(90deg,var(--sp-02-acc),var(--sp-02-acc2));
  box-shadow: 0 0 12px -1px color-mix(in oklch,var(--sp-02-acc) 65%,transparent);
}

@supports (animation-timeline: scroll()) {
  .sp-02__bar::after {
    transform: scaleX(0);
    animation: sp-02-fill linear both;
    animation-timeline: scroll(root block);
  }
}

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

  to {
    transform: scaleX(1);
  }
}

.sp-02__bar[data-stuck="true"] {
  background: color-mix(in oklch,var(--sp-02-surface) 78%,transparent);
  box-shadow: inset 0 1px 0 oklch(1 0 0/.7),0 10px 30px -18px oklch(0.2 0.024 265/.55);
}

@supports (container-type: scroll-state) {
  .sp-02__bar {
    container-type: scroll-state;
  }

  @container scroll-state(stuck: top) {
    .sp-02__bar {
      background: color-mix(in oklch,var(--sp-02-surface) 78%,transparent);
      box-shadow: inset 0 1px 0 oklch(1 0 0/.7),0 10px 30px -18px oklch(0.2 0.024 265/.55);
    }
  }
}

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

.sp-02__brandthin {
  font-weight: 400;
  color: var(--sp-02-mut);
}

.sp-02__logo {
  inline-size: 20px;
  block-size: 20px;
  border-radius: 6px;
  background: conic-gradient(from 140deg,var(--sp-02-acc),var(--sp-02-acc2),var(--sp-02-acc));
  box-shadow: 0 3px 12px -3px var(--sp-02-acc);
}

.sp-02__nav {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-inline-start: auto;
}

.sp-02__link {
  display: flex;
  align-items: center;
  min-block-size: 36px;
  padding: 0 11px;
  border-radius: 9px;
  font-size: 13.4px;
  font-weight: 600;
  color: var(--sp-02-mut);
  text-decoration: none;
  transition: color .18s ease,background .18s ease;
}

.sp-02__link:hover {
  color: var(--sp-02-ink);
  background: oklch(0.2 0.024 265/.05);
}

.sp-02__link[aria-current="page"] {
  color: var(--sp-02-acc);
  background: color-mix(in oklch,var(--sp-02-acc) 10%,transparent);
}

.sp-02__link:focus-visible,
.sp-02__brand:focus-visible {
  outline: 2px solid var(--sp-02-acc);
  outline-offset: 2px;
  border-radius: 9px;
}

.sp-02__pct {
  min-inline-size: 4.5ch;
  text-align: end;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  color: var(--sp-02-acc);
  font-weight: 600;
}

.sp-02__hero {
  position: relative;
  min-block-size: min(78svh,720px);
  display: grid;
  align-items: end;
  overflow: hidden;
  background: linear-gradient(150deg,oklch(0.34 0.09 260),oklch(0.2 0.05 290));
}

.sp-02__heroimg {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  opacity: .62;
}

.sp-02__herotext {
  position: relative;
  display: grid;
  gap: 12px;
  padding-block: clamp(30px,7vh,70px);
  color: oklch(0.98 0 0);
  background: linear-gradient(to top,oklch(0.16 0.03 265/.9),transparent);
}

.sp-02__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.85 0.1 250);
}

.sp-02__htitle {
  font-size: clamp(30px,5.6vw,62px);
  line-height: 1;
  letter-spacing: -.04em;
  font-weight: 730;
  max-inline-size: 20ch;
  text-wrap: balance;
}

.sp-02__hsub {
  font-size: clamp(14.5px,1.6vw,18px);
  line-height: 1.55;
  color: oklch(0.9 0.01 250);
  max-inline-size: 52ch;
  text-wrap: pretty;
}

.sp-02__sec {
  scroll-margin-top: calc(var(--sp-02-bar) + 14px);
  display: grid;
  gap: 12px;
  align-content: center;
  min-block-size: 72svh;
  padding-block: clamp(28px,5vw,56px);
  border-block-end: 1px solid var(--sp-02-line);
}

.sp-02__sec:nth-of-type(even) {
  background: oklch(0.988 0.005 265);
}

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

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

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

.sp-02__p--lede {
  font-size: 18px;
  color: var(--sp-02-ink);
}

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

.sp-02__fig {
  display: grid;
  gap: 9px;
  margin-block-start: 6px;
  max-inline-size: 70ch;
}

.sp-02__img {
  inline-size: 100%;
  aspect-ratio: 16/7;
  object-fit: cover;
  display: block;
  border-radius: 16px;
  background: linear-gradient(135deg,oklch(0.5 0.08 200),oklch(0.3 0.05 260));
}

.sp-02__cap {
  font-size: 12.6px;
  color: var(--sp-02-mut);
}

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

.sp-02__pagefoot {
  display: grid;
  place-items: center;
  padding: clamp(44px,9vh,110px) 24px;
  border-block-start: 1px solid var(--sp-02-line);
}

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

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

.sp-02__herotext,
.sp-02__sec {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 940px)/2));
}

:root:has(.sp-02) {
  scroll-behavior: smooth;
  scroll-padding-top: 76px;
}

@media (max-width: 720px) {
  .sp-02 {
    --sp-02-bar: 54px;
  }

  .sp-02__nav {
    display: none;
  }

  .sp-02__bar {
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }

  .sp-02__sec {
    min-block-size: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  :root:has(.sp-02) {
    scroll-behavior: auto;
  }

  .sp-02 * {
    transition-duration: .01ms !important;
  }

  .sp-02__bar::after {
    box-shadow: none;
  }
}
(() => {
  const root = document.querySelector('.sp-02');
  if (!root || root.dataset.spWired) return;
  root.dataset.spWired = '1';
  const bar = root.querySelector('#sp-02-bar');
  const pct = root.querySelector('#sp-02-pct');
  const native = CSS.supports('animation-timeline', 'scroll()');
  let ticking = false;
  const paint = () => {
    ticking = false;
    const d = document.documentElement;
    const max = d.scrollHeight - d.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
    if (!native) root.style.setProperty('--sp-02-p', p.toFixed(4));
    const n = Math.round(p * 100);
    pct.textContent = n + '%';
    pct.setAttribute('aria-valuenow', String(n));
  };
  const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
  addEventListener('scroll', onScroll, { passive: true });
  addEventListener('resize', onScroll, { passive: true });
  paint();
  const sentinel = root.querySelector('#sp-02-sentinel');
  new IntersectionObserver(([e]) => { bar.dataset.stuck = String(!e.isIntersecting); },
    { threshold: 0 }).observe(sentinel);
})();
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: Sticky Header with Integrated Scroll Progress Bar (Glassmorphic)
Source: https://codefronts.com/motion/css-scroll-progress-bar/sticky-header-with-integrated-scroll-progress-bar-glassmorphic/

The pattern most publishers actually ship: the progress bar is not a separate strip floating over the page, it is the bottom edge of a frosted-glass header. Content dissolves under the blur as it passes, the hairline fills left-to-right, and the header quietly gains depth the moment it detaches from the top of the page.
## HTML
```html
<section class="sp-02" aria-label="Glassmorphic sticky header with progress bar demo">
  <div class="sp-02__sentinel" id="sp-02-sentinel" aria-hidden="true"></div>
  <header class="sp-02__bar" id="sp-02-bar">
    <a class="sp-02__brand" href="#sp-02-s1"><span class="sp-02__logo" aria-hidden="true"></span>Prism<span class="sp-02__brandthin">Journal</span></a>
    <nav class="sp-02__nav" aria-label="Article sections">
      <a class="sp-02__link" href="#sp-02-s1" aria-current="page">Overview</a>
      <a class="sp-02__link" href="#sp-02-s2">Method</a>
      <a class="sp-02__link" href="#sp-02-s3">Results</a>
    </nav>
    <p class="sp-02__pct" id="sp-02-pct" role="progressbar" aria-label="Article read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">0%</p>
  </header>
  <div class="sp-02__hero">
    <img class="sp-02__heroimg" src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&amp;fit=crop&amp;w=1800&amp;q=70" alt="A mountain ridge under a deep blue night sky" loading="eager" decoding="async">
    <div class="sp-02__herotext">
      <p class="sp-02__eyebrow">Field notes · 9 min</p>
      <h2 class="sp-02__htitle">Frosted glass, one hairline, no extra markup</h2>
      <p class="sp-02__hsub">The indicator is the header's bottom edge — scroll and watch it fill while the photograph dissolves underneath.</p>
    </div>
  </div>
  <main class="sp-02__main">
    <section class="sp-02__sec" id="sp-02-s1">
      <p class="sp-02__kicker">Overview</p>
      <h3 class="sp-02__h">One element, three layers</h3>
      <p class="sp-02__p sp-02__p--lede">Glass plate, content row, progress edge. The edge is a pseudo-element, so it shares the header's stacking context and can never be painted over.</p>
      <p class="sp-02__p">Blur alone drains colour. Pairing <code>blur()</code> with <code>saturate()</code> restores it, which is why real frosted glass looks lively and a plain translucent rectangle looks dead.</p>
    </section>
    <section class="sp-02__sec" id="sp-02-s2">
      <p class="sp-02__kicker">Method</p>
      <h3 class="sp-02__h">Depth on detach</h3>
      <p class="sp-02__p">The header starts flat against the hero and gains a shadow the instant it detaches. Where <code>scroll-state(stuck: top)</code> exists that is pure CSS; everywhere else a single sentinel and an IntersectionObserver set the same state.</p>
      <figure class="sp-02__fig">
        <img class="sp-02__img" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&amp;fit=crop&amp;w=1400&amp;q=70" alt="Mist moving between dark pine trees" loading="lazy" decoding="async">
        <figcaption class="sp-02__cap">Translucency only reads as glass when something interesting passes behind it.</figcaption>
      </figure>
    </section>
    <section class="sp-02__sec" id="sp-02-s3">
      <p class="sp-02__kicker">Results</p>
      <h3 class="sp-02__h">The percentage is for people, not for looks</h3>
      <p class="sp-02__p">The numeric readout doubles as the accessible name of a real <code>progressbar</code>. Screen reader users get the same information sighted users get from the hairline.</p>
    </section>
    <section class="sp-02__sec" id="sp-02-s4">
      <p class="sp-02__kicker">Cost</p>
      <h3 class="sp-02__h">Know what blur costs</h3>
      <p class="sp-02__p">A blurred sticky header repaints every scroll frame. It is worth it above the fold on desktop and rarely worth it on a low-end phone — drop to an opaque background under 768px and nobody notices.</p>
      <p class="sp-02__chip">1 header · 1 pseudo-element · 0 extra nodes</p>
    </section>
  </main>
  <footer class="sp-02__pagefoot"><p class="sp-02__pagefootin">Scroll progress pattern 02 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
.sp-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sp-02-bg);
  --sp-02-bg: oklch(0.972 0.008 250);
  --sp-02-surface: oklch(1 0 0);
  --sp-02-ink: oklch(0.2 0.024 265);
  --sp-02-mut: oklch(0.53 0.02 265);
  --sp-02-acc: oklch(0.6 0.19 250);
  --sp-02-acc2: oklch(0.68 0.17 320);
  --sp-02-line: oklch(0.2 0.024 265/.1);
  --sp-02-h: 3px;
  --sp-02-bar: 62px;
  --sp-02-p: 0;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sp-02-ink);
  -webkit-font-smoothing: antialiased;
}

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

.sp-02__sentinel {
  block-size: 1px;
  margin-block-end: -1px;
}

.sp-02__bar {
  position: sticky;
  inset-block-start: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: clamp(10px,2vw,24px);
  min-block-size: var(--sp-02-bar);
  background: color-mix(in oklch,var(--sp-02-surface) 58%,transparent);
  backdrop-filter: blur(18px) saturate(1.8);
  -webkit-backdrop-filter: blur(18px) saturate(1.8);
  box-shadow: inset 0 1px 0 oklch(1 0 0/.6),0 0 0 rgba(0,0,0,0);
  transition: background .3s ease,box-shadow .3s ease;
}

.sp-02__bar::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  block-size: var(--sp-02-h);
  transform-origin: 0 50%;
  transform: scaleX(var(--sp-02-p));
  background: linear-gradient(90deg,var(--sp-02-acc),var(--sp-02-acc2));
  box-shadow: 0 0 12px -1px color-mix(in oklch,var(--sp-02-acc) 65%,transparent);
}

@supports (animation-timeline: scroll()) {
  .sp-02__bar::after {
    transform: scaleX(0);
    animation: sp-02-fill linear both;
    animation-timeline: scroll(root block);
  }
}

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

  to {
    transform: scaleX(1);
  }
}

.sp-02__bar[data-stuck="true"] {
  background: color-mix(in oklch,var(--sp-02-surface) 78%,transparent);
  box-shadow: inset 0 1px 0 oklch(1 0 0/.7),0 10px 30px -18px oklch(0.2 0.024 265/.55);
}

@supports (container-type: scroll-state) {
  .sp-02__bar {
    container-type: scroll-state;
  }

  @container scroll-state(stuck: top) {
    .sp-02__bar {
      background: color-mix(in oklch,var(--sp-02-surface) 78%,transparent);
      box-shadow: inset 0 1px 0 oklch(1 0 0/.7),0 10px 30px -18px oklch(0.2 0.024 265/.55);
    }
  }
}

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

.sp-02__brandthin {
  font-weight: 400;
  color: var(--sp-02-mut);
}

.sp-02__logo {
  inline-size: 20px;
  block-size: 20px;
  border-radius: 6px;
  background: conic-gradient(from 140deg,var(--sp-02-acc),var(--sp-02-acc2),var(--sp-02-acc));
  box-shadow: 0 3px 12px -3px var(--sp-02-acc);
}

.sp-02__nav {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-inline-start: auto;
}

.sp-02__link {
  display: flex;
  align-items: center;
  min-block-size: 36px;
  padding: 0 11px;
  border-radius: 9px;
  font-size: 13.4px;
  font-weight: 600;
  color: var(--sp-02-mut);
  text-decoration: none;
  transition: color .18s ease,background .18s ease;
}

.sp-02__link:hover {
  color: var(--sp-02-ink);
  background: oklch(0.2 0.024 265/.05);
}

.sp-02__link[aria-current="page"] {
  color: var(--sp-02-acc);
  background: color-mix(in oklch,var(--sp-02-acc) 10%,transparent);
}

.sp-02__link:focus-visible,
.sp-02__brand:focus-visible {
  outline: 2px solid var(--sp-02-acc);
  outline-offset: 2px;
  border-radius: 9px;
}

.sp-02__pct {
  min-inline-size: 4.5ch;
  text-align: end;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  color: var(--sp-02-acc);
  font-weight: 600;
}

.sp-02__hero {
  position: relative;
  min-block-size: min(78svh,720px);
  display: grid;
  align-items: end;
  overflow: hidden;
  background: linear-gradient(150deg,oklch(0.34 0.09 260),oklch(0.2 0.05 290));
}

.sp-02__heroimg {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  opacity: .62;
}

.sp-02__herotext {
  position: relative;
  display: grid;
  gap: 12px;
  padding-block: clamp(30px,7vh,70px);
  color: oklch(0.98 0 0);
  background: linear-gradient(to top,oklch(0.16 0.03 265/.9),transparent);
}

.sp-02__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.85 0.1 250);
}

.sp-02__htitle {
  font-size: clamp(30px,5.6vw,62px);
  line-height: 1;
  letter-spacing: -.04em;
  font-weight: 730;
  max-inline-size: 20ch;
  text-wrap: balance;
}

.sp-02__hsub {
  font-size: clamp(14.5px,1.6vw,18px);
  line-height: 1.55;
  color: oklch(0.9 0.01 250);
  max-inline-size: 52ch;
  text-wrap: pretty;
}

.sp-02__sec {
  scroll-margin-top: calc(var(--sp-02-bar) + 14px);
  display: grid;
  gap: 12px;
  align-content: center;
  min-block-size: 72svh;
  padding-block: clamp(28px,5vw,56px);
  border-block-end: 1px solid var(--sp-02-line);
}

.sp-02__sec:nth-of-type(even) {
  background: oklch(0.988 0.005 265);
}

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

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

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

.sp-02__p--lede {
  font-size: 18px;
  color: var(--sp-02-ink);
}

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

.sp-02__fig {
  display: grid;
  gap: 9px;
  margin-block-start: 6px;
  max-inline-size: 70ch;
}

.sp-02__img {
  inline-size: 100%;
  aspect-ratio: 16/7;
  object-fit: cover;
  display: block;
  border-radius: 16px;
  background: linear-gradient(135deg,oklch(0.5 0.08 200),oklch(0.3 0.05 260));
}

.sp-02__cap {
  font-size: 12.6px;
  color: var(--sp-02-mut);
}

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

.sp-02__pagefoot {
  display: grid;
  place-items: center;
  padding: clamp(44px,9vh,110px) 24px;
  border-block-start: 1px solid var(--sp-02-line);
}

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

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

.sp-02__herotext,
.sp-02__sec {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 940px)/2));
}

:root:has(.sp-02) {
  scroll-behavior: smooth;
  scroll-padding-top: 76px;
}

@media (max-width: 720px) {
  .sp-02 {
    --sp-02-bar: 54px;
  }

  .sp-02__nav {
    display: none;
  }

  .sp-02__bar {
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }

  .sp-02__sec {
    min-block-size: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  :root:has(.sp-02) {
    scroll-behavior: auto;
  }

  .sp-02 * {
    transition-duration: .01ms !important;
  }

  .sp-02__bar::after {
    box-shadow: none;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.querySelector('.sp-02');
  if (!root || root.dataset.spWired) return;
  root.dataset.spWired = '1';
  const bar = root.querySelector('#sp-02-bar');
  const pct = root.querySelector('#sp-02-pct');
  const native = CSS.supports('animation-timeline', 'scroll()');
  let ticking = false;
  const paint = () => {
    ticking = false;
    const d = document.documentElement;
    const max = d.scrollHeight - d.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
    if (!native) root.style.setProperty('--sp-02-p', p.toFixed(4));
    const n = Math.round(p * 100);
    pct.textContent = n + '%';
    pct.setAttribute('aria-valuenow', String(n));
  };
  const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
  addEventListener('scroll', onScroll, { passive: true });
  addEventListener('resize', onScroll, { passive: true });
  paint();
  const sentinel = root.querySelector('#sp-02-sentinel');
  new IntersectionObserver(([e]) => { bar.dataset.stuck = String(!e.isIntersecting); },
    { threshold: 0 }).observe(sentinel);
})();
```

How this works

Three layers stack in one sticky element. The glass plate itself is a translucent background plus a blur:

.bar{
  position: sticky; top: 0;
  background: color-mix(in oklch, var(--surface) 62%, transparent);
  backdrop-filter: blur(18px) saturate(1.8);
  -webkit-backdrop-filter: blur(18px) saturate(1.8);
}
.bar::after{                       /* the fill, welded to the bottom edge */
  content:''; position:absolute; inset:auto 0 0 0; height:3px;
  transform-origin:0 50%;
  animation: sp-fill linear both;
  animation-timeline: scroll(root block);
}

Welding the bar to the header with a pseudo-element means zero extra markup and — more usefully — the indicator inherits the header's stacking context, so it can never be painted over by a hero image or a section with its own transform.

The blur is the expensive part, so it is spent carefully. saturate() alongside blur() is what stops frosted glass looking grey and dead: blurring alone averages colour toward the middle, saturation pushes it back out. A one-pixel top highlight (inset 0 1px 0 rgba(255,255,255,.6)) reads as the lit edge of a physical pane and is the single detail that separates convincing glass from a translucent rectangle.

Finally, the header responds to being stuck. Where scroll-state() container queries are supported the shadow and the background opacity both step up with @container scroll-state(stuck: top) — pure CSS. Everywhere else a two-line IntersectionObserver on a sentinel sets data-stuck and the same rules apply.

Make it yours

  • Bar thickness: --sp-02-h. Anything above 4px starts to read as a separate element rather than an edge — 2–3px is the sweet spot for this pattern.
  • Warmer glass: raise the surface percentage in the color-mix() and drop the blur radius. Colder, more aggressive glass: lower the percentage, raise blur to 24px and add brightness(1.05).
  • Full-bleed vs contained: the bar is a pseudo-element on the header, so it always spans the full header width even when the header content is centred to a measure. Add inset-inline: max(24px, calc((100% - 1180px)/2)) to the pseudo-element if you want it aligned to the content instead.
  • Dark mode: flip the surface and ink tokens inside @media (prefers-color-scheme: dark) — glass over a dark page needs less blur and a slightly higher opacity to stay legible.

Gotchas — read before shipping

  • backdrop-filter silently does nothing if the element is fully opaque — there must be translucency for a backdrop to show through.
  • Safari still needs the -webkit- prefix for backdrop-filter. It is one extra line and the pattern collapses to a flat bar without it.
  • A blurred sticky header is a compositing layer that repaints on every scroll frame. On very long pages, pair it with will-change:backdrop-filter sparingly — or drop to an opaque background below 768px, where the effect is least visible and most expensive.
  • Putting the progress bar in a separate fixed element above the header causes a 1px seam on fractional device pixel ratios. The pseudo-element approach cannot seam.
  • Any ancestor with overflow:hidden turns the header's sticky port into that ancestor and the header stops sticking — use overflow-x:clip if you need to kill horizontal scroll.
  • Glass over busy photography can fail contrast. Test the header text against your worst-case hero image, not against a flat background.

Browser support

ChromeSafariFirefoxEdge
115+26+144+115+

position:sticky and backdrop-filter are supported everywhere current (backdrop-filter in Firefox since 103). The floor quoted is for animation-timeline; the JS fallback covers older engines. scroll-state(stuck) is Chrome 133+ and is used as pure enhancement — the data-stuck attribute path runs everywhere.

Techniques used in this demo

Search CodeFronts

Loading…