25 CSS Sticky Navigations
08 / 25

All 25 in 25 CSS Sticky Navigations

  1. 01Pure CSS Sticky Navbar (No JavaScript, position:sticky)
  2. 02Sticky Top Header with Flexbox Layout (Logo · Links · CTA)
  3. 03Responsive Sticky Navbar with Hamburger Menu Toggle
  4. 04Sticky Header with Full-Bleed Bar and Container Max-Width Content
  5. 05Floating Glass Pill Sticky Navbar (Rounded, Backdrop Blur, Elevation)
  6. 06Sticky Header Shrink on Scroll (80px → 50px, Compact State)
  7. 07Sticky Nav with Directional Hide (Scroll Down) and Reveal (Scroll Up)
  8. 08Transparent-to-Solid Sticky Header on Hero Scroll
  9. 09Sticky Header with Article Reading Progress Bar Underneath
  10. 10Sticky Header with SVG Logo Wordmark → Mark-Only Morph on Scroll
  11. 11Sticky Navbar with Underline-Slide Active Link Indicator
  12. 12Sticky Header with Theme-Aware Token Swap (Light/Dark)
  13. 13Minimalist Sticky Header with Single High-Emphasis CTA
  14. 14Split Sticky Navbar with Centered Brand Logo (Links Left + Right)
  15. 15Neumorphic Sticky Navigation Bar (Soft-UI Inset/Outset Shadows)
  16. 16Sticky Header with Multi-Column Mega Menu Dropdown
  17. 17E-Commerce Sticky Header with Search Bar and Cart Badge Counter
  18. 18Sticky Announcement Bar Stacked Above Sticky Nav (Dual-Stick)
  19. 19Sticky Sub-Navigation Bar Locked Under the Main Sticky Header
  20. 20Sticky Header with Icon-to-Full-Width Search Expand on Click
  21. 21Sticky Sidebar Table of Contents That Follows a Long Article
  22. 22Mobile Bottom Tab Bar with Safe-Area Inset Padding
  23. 23Active-Section Scrollspy Highlighting in a Sticky Navbar
  24. 24Sticky Horizontal Scroll Tab Bar with Snap Points
  25. 25Sticky Header with Social Share Icons for Articles
Light JSMIT licensed

Transparent-to-Solid Sticky Header on Hero Scroll

The hero pattern used on almost every launch page: the header sits transparently over full-bleed imagery, then takes on a solid, legible background the moment it starts overlapping real content. Implemented with the scroll-state container query — CSS can now style a stuck element without a single scroll listener — plus a sentinel-based fallback for engines that have not shipped it.

Live Demo
Plain CSSTailwind v4
Try it

Complete Tailwind CSS landing page templates in HTML, React, Next.js and Vue · from $49

Cruip · Affiliate link

Browse Cruip templates

The code

<section class="sn-08" aria-label="Transparent to solid sticky header demo">
    <div class="sn-08__sentinel" id="sn-08-sentinel" aria-hidden="true"></div>
    <header class="sn-08__bar" id="sn-08-bar" data-stuck="false">
      <div class="sn-08__inner">
        <a class="sn-08__brand" href="#sn-08-s1"><span class="sn-08__logo" aria-hidden="true"></span>Cirrus</a>
        <nav class="sn-08__nav" aria-label="Primary">
          <a class="sn-08__link" href="#sn-08-s1" aria-current="page">Expeditions</a>
          <a class="sn-08__link" href="#sn-08-s2">Journal</a>
          <a class="sn-08__link" href="#sn-08-s3">Gear</a>
        </nav>
        <a class="sn-08__cta" href="#sn-08-s3">Join</a>
      </div>
    </header>
    <div class="sn-08__hero">
      <img class="sn-08__img" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?q=80&w=1500&auto=format&fit=crop" alt="Mist rolling across green highland peaks at dawn" width="1500" height="1000" loading="lazy" decoding="async">
      <div class="sn-08__herotext">
        <p class="sn-08__kicker">scroll-state(stuck: top)</p>
        <h2 class="sn-08__title">Transparent over the hero,<br>solid over the words</h2>
      </div>
    </div>
    <main>
      <section class="sn-08__sec" id="sn-08-s1">
        <h3 class="sn-08__h">CSS finally knows it is stuck</h3>
        <p class="sn-08__p">A sticky element can declare itself a scroll-state container, and descendants query <code>stuck: top</code> directly. No listener, no observer, no class toggling.</p>
      </section>
      <section class="sn-08__sec" id="sn-08-s2">
        <h3 class="sn-08__h">Contrast flips with the background</h3>
        <p class="sn-08__p">White labels over the photograph, dark labels over the page. Both token sets change together, so neither state is the accessible-by-accident one.</p>
      </section>
      <section class="sn-08__sec" id="sn-08-s3">
        <h3 class="sn-08__h">A sentinel covers the rest</h3>
        <p class="sn-08__p">Where the query is unavailable, a zero-height sentinel above the header is watched by an <code>IntersectionObserver</code> — it fires on boundary crossings only, never per frame.</p>
      </section>
    </main>
  <footer class="sn-08__pagefoot">
    <p class="sn-08__pagefootin">Sticky navigation pattern 08 of 25 · codefronts.com</p>
  </footer>
</section>
.sn-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sn-08-bg);
  --sn-08-bg: oklch(0.95 0.012 160);
  --sn-08-surface: oklch(1 0 0);
  --sn-08-ink: oklch(0.22 0.03 170);
  --sn-08-mut: oklch(0.52 0.025 170);
  --sn-08-acc: oklch(0.55 0.14 165);
  --sn-08-line: oklch(0.22 0.03 170/.12);
  --sn-08-h: 64px;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sn-08-ink);
  -webkit-font-smoothing: antialiased;
}

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

.sn-08__sentinel {
  height: 1px;
  margin-block-end: -1px;
}

.sn-08__bar {
  position: sticky;
  top: 0;
  z-index: 20;
  container-type: scroll-state;
  container-name: sn-08-bar;
}

.sn-08__inner {
  display: flex;
  align-items: center;
  gap: clamp(10px,2vw,22px);
  min-height: var(--sn-08-h);
  padding: 0 clamp(14px,2.4vw,24px);
  color: oklch(1 0 0);
  background: transparent;
  border-block-end: 1px solid transparent;
  transition: background .28s ease,color .28s ease,border-color .28s ease,backdrop-filter .28s ease;
}

@container sn-08-bar scroll-state(stuck: top) {
  .sn-08__inner {
    color: var(--sn-08-ink);
    background: color-mix(in oklch,var(--sn-08-surface) 86%,transparent);
    backdrop-filter: blur(14px) saturate(1.4);
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    border-block-end-color: var(--sn-08-line);
    box-shadow: 0 10px 30px -22px oklch(0.22 0.03 170/.6);
  }
}

.sn-08__bar[data-stuck="true"] .sn-08__inner {
  color: var(--sn-08-ink);
  background: color-mix(in oklch,var(--sn-08-surface) 86%,transparent);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  border-block-end-color: var(--sn-08-line);
  box-shadow: 0 10px 30px -22px oklch(0.22 0.03 170/.6);
}

.sn-08__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -.025em;
  color: inherit;
  text-decoration: none;
  white-space: nowrap;
}

.sn-08__logo {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 3px solid currentColor;
}

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

.sn-08__link {
  display: flex;
  align-items: center;
  min-height: 38px;
  padding: 0 12px;
  border-radius: 9px;
  font-size: 13.6px;
  font-weight: 600;
  color: inherit;
  opacity: .86;
  text-decoration: none;
  transition: opacity .18s ease,background .18s ease;
}

.sn-08__link:hover {
  opacity: 1;
  background: color-mix(in oklch,currentColor 12%,transparent);
}

.sn-08__link[aria-current="page"] {
  opacity: 1;
  font-weight: 680;
}

.sn-08__cta {
  display: flex;
  align-items: center;
  min-height: 38px;
  padding: 0 16px;
  border-radius: 99px;
  font-size: 13.4px;
  font-weight: 670;
  color: inherit;
  text-decoration: none;
  border: 1px solid color-mix(in oklch,currentColor 45%,transparent);
  white-space: nowrap;
  transition: background .2s ease,color .2s ease;
}

.sn-08__cta:hover {
  background: currentColor;
  color: var(--sn-08-surface);
}

.sn-08__link:focus-visible,
.sn-08__cta:focus-visible,
.sn-08__brand:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

.sn-08__hero {
  position: relative;
  margin-block-start: calc(var(--sn-08-h)*-1);
  height: min(56svh,460px);
  display: grid;
}

.sn-08__img {
  grid-area: 1/1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: linear-gradient(160deg,oklch(0.42 0.1 175),oklch(0.3 0.07 210));
}

.sn-08__herotext {
  grid-area: 1/1;
  align-self: end;
  display: grid;
  gap: 10px;
  padding: clamp(22px,4vw,44px);
  color: oklch(1 0 0);
  background: linear-gradient(to top,oklch(0.16 0.03 175/.88),transparent 72%);
}

.sn-08__kicker {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .13em;
  text-transform: uppercase;
  opacity: .9;
}

.sn-08__title {
  font-size: clamp(25px,4.6vw,42px);
  line-height: 1.04;
  letter-spacing: -.035em;
  font-weight: 720;
  text-wrap: balance;
}

.sn-08__sec {
  scroll-margin-top: var(--sn-08-h);
  display: grid;
  gap: 10px;
  align-content: center;
  min-height: 66svh;
  padding: clamp(24px,4vw,46px);
  border-block-end: 1px solid var(--sn-08-line);
}

.sn-08__sec:nth-child(even) {
  background: oklch(0.98 0.008 165);
}

.sn-08__h {
  font-size: clamp(19px,2.6vw,26px);
  line-height: 1.16;
  letter-spacing: -.025em;
  font-weight: 690;
  text-wrap: balance;
}

.sn-08__p {
  max-width: 52ch;
  font-size: 14.8px;
  line-height: 1.64;
  color: var(--sn-08-mut);
  text-wrap: pretty;
}

.sn-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.22 0.03 170/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

@media (max-width: 560px) {
  .sn-08 {
    --sn-08-h: 56px;
  }

  .sn-08__cta {
    display: none;
  }

  .sn-08__link {
    padding: 0 9px;
    font-size: 12.8px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sn-08 * {
    transition-duration: .01ms !important;
  }
}
/* ── full-page mode: the demo IS the document, so sticky pins against the real viewport ── */

:root:has(.sn-08) {
  scroll-behavior: smooth;
  scroll-padding-top: var(--sn-08-h);
}

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

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

.sn-08__sec {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 1120px)/2));
}

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

.sn-08__pagefootin {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 12px;
  letter-spacing: .06em;
  color: var(--sn-08-mut);
  text-align: center;
}
(() => {
  const root = document.querySelector('.sn-08');
  if (!root || root.dataset.snWired) return;
  root.dataset.snWired = '1';
  if (CSS.supports('container-type: scroll-state')) return; // native query handles it
  const bar = root.querySelector('#sn-08-bar');
  const io = new IntersectionObserver(
    ([e]) => bar.dataset.stuck = String(!e.isIntersecting),
    { root: null, threshold: 0 }
  );
  io.observe(root.querySelector('#sn-08-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 Sticky Navigation 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: Transparent-to-Solid Sticky Header on Hero Scroll
Source: https://codefronts.com/navigation/css-sticky-navigation/transparent-to-solid-sticky-header-on-hero-scroll/

The hero pattern used on almost every launch page: the header sits transparently over full-bleed imagery, then takes on a solid, legible background the moment it starts overlapping real content. Implemented with the scroll-state container query — CSS can now style a stuck element without a single scroll listener — plus a sentinel-based fallback for engines that have not shipped it.
## HTML
```html
<section class="sn-08" aria-label="Transparent to solid sticky header demo">
    <div class="sn-08__sentinel" id="sn-08-sentinel" aria-hidden="true"></div>
    <header class="sn-08__bar" id="sn-08-bar" data-stuck="false">
      <div class="sn-08__inner">
        <a class="sn-08__brand" href="#sn-08-s1"><span class="sn-08__logo" aria-hidden="true"></span>Cirrus</a>
        <nav class="sn-08__nav" aria-label="Primary">
          <a class="sn-08__link" href="#sn-08-s1" aria-current="page">Expeditions</a>
          <a class="sn-08__link" href="#sn-08-s2">Journal</a>
          <a class="sn-08__link" href="#sn-08-s3">Gear</a>
        </nav>
        <a class="sn-08__cta" href="#sn-08-s3">Join</a>
      </div>
    </header>
    <div class="sn-08__hero">
      <img class="sn-08__img" src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?q=80&w=1500&auto=format&fit=crop" alt="Mist rolling across green highland peaks at dawn" width="1500" height="1000" loading="lazy" decoding="async">
      <div class="sn-08__herotext">
        <p class="sn-08__kicker">scroll-state(stuck: top)</p>
        <h2 class="sn-08__title">Transparent over the hero,<br>solid over the words</h2>
      </div>
    </div>
    <main>
      <section class="sn-08__sec" id="sn-08-s1">
        <h3 class="sn-08__h">CSS finally knows it is stuck</h3>
        <p class="sn-08__p">A sticky element can declare itself a scroll-state container, and descendants query <code>stuck: top</code> directly. No listener, no observer, no class toggling.</p>
      </section>
      <section class="sn-08__sec" id="sn-08-s2">
        <h3 class="sn-08__h">Contrast flips with the background</h3>
        <p class="sn-08__p">White labels over the photograph, dark labels over the page. Both token sets change together, so neither state is the accessible-by-accident one.</p>
      </section>
      <section class="sn-08__sec" id="sn-08-s3">
        <h3 class="sn-08__h">A sentinel covers the rest</h3>
        <p class="sn-08__p">Where the query is unavailable, a zero-height sentinel above the header is watched by an <code>IntersectionObserver</code> — it fires on boundary crossings only, never per frame.</p>
      </section>
    </main>
  <footer class="sn-08__pagefoot">
    <p class="sn-08__pagefootin">Sticky navigation pattern 08 of 25 · codefronts.com</p>
  </footer>
</section>
```
## CSS
```css
.sn-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  background: var(--sn-08-bg);
  --sn-08-bg: oklch(0.95 0.012 160);
  --sn-08-surface: oklch(1 0 0);
  --sn-08-ink: oklch(0.22 0.03 170);
  --sn-08-mut: oklch(0.52 0.025 170);
  --sn-08-acc: oklch(0.55 0.14 165);
  --sn-08-line: oklch(0.22 0.03 170/.12);
  --sn-08-h: 64px;
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  color: var(--sn-08-ink);
  -webkit-font-smoothing: antialiased;
}

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

.sn-08__sentinel {
  height: 1px;
  margin-block-end: -1px;
}

.sn-08__bar {
  position: sticky;
  top: 0;
  z-index: 20;
  container-type: scroll-state;
  container-name: sn-08-bar;
}

.sn-08__inner {
  display: flex;
  align-items: center;
  gap: clamp(10px,2vw,22px);
  min-height: var(--sn-08-h);
  padding: 0 clamp(14px,2.4vw,24px);
  color: oklch(1 0 0);
  background: transparent;
  border-block-end: 1px solid transparent;
  transition: background .28s ease,color .28s ease,border-color .28s ease,backdrop-filter .28s ease;
}

@container sn-08-bar scroll-state(stuck: top) {
  .sn-08__inner {
    color: var(--sn-08-ink);
    background: color-mix(in oklch,var(--sn-08-surface) 86%,transparent);
    backdrop-filter: blur(14px) saturate(1.4);
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    border-block-end-color: var(--sn-08-line);
    box-shadow: 0 10px 30px -22px oklch(0.22 0.03 170/.6);
  }
}

.sn-08__bar[data-stuck="true"] .sn-08__inner {
  color: var(--sn-08-ink);
  background: color-mix(in oklch,var(--sn-08-surface) 86%,transparent);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  border-block-end-color: var(--sn-08-line);
  box-shadow: 0 10px 30px -22px oklch(0.22 0.03 170/.6);
}

.sn-08__brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -.025em;
  color: inherit;
  text-decoration: none;
  white-space: nowrap;
}

.sn-08__logo {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 3px solid currentColor;
}

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

.sn-08__link {
  display: flex;
  align-items: center;
  min-height: 38px;
  padding: 0 12px;
  border-radius: 9px;
  font-size: 13.6px;
  font-weight: 600;
  color: inherit;
  opacity: .86;
  text-decoration: none;
  transition: opacity .18s ease,background .18s ease;
}

.sn-08__link:hover {
  opacity: 1;
  background: color-mix(in oklch,currentColor 12%,transparent);
}

.sn-08__link[aria-current="page"] {
  opacity: 1;
  font-weight: 680;
}

.sn-08__cta {
  display: flex;
  align-items: center;
  min-height: 38px;
  padding: 0 16px;
  border-radius: 99px;
  font-size: 13.4px;
  font-weight: 670;
  color: inherit;
  text-decoration: none;
  border: 1px solid color-mix(in oklch,currentColor 45%,transparent);
  white-space: nowrap;
  transition: background .2s ease,color .2s ease;
}

.sn-08__cta:hover {
  background: currentColor;
  color: var(--sn-08-surface);
}

.sn-08__link:focus-visible,
.sn-08__cta:focus-visible,
.sn-08__brand:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

.sn-08__hero {
  position: relative;
  margin-block-start: calc(var(--sn-08-h)*-1);
  height: min(56svh,460px);
  display: grid;
}

.sn-08__img {
  grid-area: 1/1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: linear-gradient(160deg,oklch(0.42 0.1 175),oklch(0.3 0.07 210));
}

.sn-08__herotext {
  grid-area: 1/1;
  align-self: end;
  display: grid;
  gap: 10px;
  padding: clamp(22px,4vw,44px);
  color: oklch(1 0 0);
  background: linear-gradient(to top,oklch(0.16 0.03 175/.88),transparent 72%);
}

.sn-08__kicker {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11px;
  letter-spacing: .13em;
  text-transform: uppercase;
  opacity: .9;
}

.sn-08__title {
  font-size: clamp(25px,4.6vw,42px);
  line-height: 1.04;
  letter-spacing: -.035em;
  font-weight: 720;
  text-wrap: balance;
}

.sn-08__sec {
  scroll-margin-top: var(--sn-08-h);
  display: grid;
  gap: 10px;
  align-content: center;
  min-height: 66svh;
  padding: clamp(24px,4vw,46px);
  border-block-end: 1px solid var(--sn-08-line);
}

.sn-08__sec:nth-child(even) {
  background: oklch(0.98 0.008 165);
}

.sn-08__h {
  font-size: clamp(19px,2.6vw,26px);
  line-height: 1.16;
  letter-spacing: -.025em;
  font-weight: 690;
  text-wrap: balance;
}

.sn-08__p {
  max-width: 52ch;
  font-size: 14.8px;
  line-height: 1.64;
  color: var(--sn-08-mut);
  text-wrap: pretty;
}

.sn-08__p code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .85em;
  background: oklch(0.22 0.03 170/.07);
  padding: 2px 6px;
  border-radius: 5px;
}

@media (max-width: 560px) {
  .sn-08 {
    --sn-08-h: 56px;
  }

  .sn-08__cta {
    display: none;
  }

  .sn-08__link {
    padding: 0 9px;
    font-size: 12.8px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sn-08 * {
    transition-duration: .01ms !important;
  }
}
/* ── full-page mode: the demo IS the document, so sticky pins against the real viewport ── */

:root:has(.sn-08) {
  scroll-behavior: smooth;
  scroll-padding-top: var(--sn-08-h);
}

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

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

.sn-08__sec {
  padding-inline: max(clamp(22px,5vw,44px),calc((100% - 1120px)/2));
}

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

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

## JavaScript
```js
(() => {
  const root = document.querySelector('.sn-08');
  if (!root || root.dataset.snWired) return;
  root.dataset.snWired = '1';
  if (CSS.supports('container-type: scroll-state')) return; // native query handles it
  const bar = root.querySelector('#sn-08-bar');
  const io = new IntersectionObserver(
    ([e]) => bar.dataset.stuck = String(!e.isIntersecting),
    { root: null, threshold: 0 }
  );
  io.observe(root.querySelector('#sn-08-sentinel'));
})();
```

How this works

Until recently CSS had no way to know an element was currently stuck. It does now. A sticky element can declare itself a scroll-state container, and its descendants can query that state:

.bar{
  position: sticky; top: 0;
  container-type: scroll-state;
  container-name: sn-bar;
}
@container sn-bar scroll-state(stuck: top){
  .bar__inner{ background: var(--surface); border-block-end: 1px solid var(--line) }
  .bar__link { color: var(--ink) }
}

Note the shape of it: the container queries its own stuck state, but the rules apply to descendants — an element cannot restyle itself through its own container query. So the sticky element stays a bare positioning wrapper and an inner element carries all the paint.

The state swap is more than a background. Over a dark photograph the links must be white; over the solid page surface they must be dark. Both colour sets are declared as tokens and the query flips them together, so contrast is correct in both states rather than only in the screenshot.

For engines without scroll-state queries, a zero-height sentinel <div> is placed at the very top of the scroll port and observed with IntersectionObserver. When the sentinel leaves the viewport the header must be stuck, so a data-stuck attribute is toggled and duplicate rules cover it. An observer costs nothing per frame — unlike a scroll listener, it only fires when the boundary is actually crossed.

Make it yours

  • Trigger later: give the sentinel a height equal to the hero, so the header only solidifies after the whole hero has passed.
  • Fade rather than switch: transition background-color and color over 250ms — the query and attribute both flip instantly, so the transition does the smoothing.
  • Blur only when stuck: apply backdrop-filter inside the query so the compositor layer is created only when it is doing work.
  • Scroll-state also exposes snapped and scrollable — the same block can style scroll-snap targets or reveal 'more content below' affordances.

Gotchas — read before shipping

  • A scroll-state container cannot style itself. Put the visual treatment on an inner element or the rules silently do nothing.
  • container-type: scroll-state establishes containment; check that it does not clip an overflowing dropdown before adding it to an existing header.
  • The classic fallback bug is observing the header itself — a stuck header is always intersecting. Observe a separate sentinel above it.
  • Transparent headers over photography fail contrast unless the text colour flips too. Swap the whole token set, not just the background.
  • A transparent header still occupies flow space unless the hero is pulled up under it with a negative margin — otherwise you get a blank strip above the image.

Browser support

ChromeSafariFirefoxEdge
133+26+not yet133+

Scroll-state container queries shipped in Chrome 133 (2025) and Safari 26; Firefox has not shipped them. The bundled IntersectionObserver fallback (supported everywhere since 2019) delivers identical behaviour, and is installed only when CSS.supports() reports the query is unavailable.

Techniques used in this demo

Search CodeFronts

Loading…