
CSS Sticky Header on Scroll Example
Published
17 hand-coded CSS position: sticky demos: sticky header on scroll, sticky sidebar + Table of Contents with scrollspy, sticky table header + frozen first column, sticky add-to-cart bar, sticky checkout summary, sticky footer, stuck-state detection and the canonical debug lab for "why is my sticky not working". 15 Pure CSS + 2 tiny JS snippets, WCAG 2.2 AA, container-query responsive, uses modern scroll-state() / animation-timeline: scroll() / view() where available with graceful fallbacks. Scoped under .cst-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
Related22 CSS Headers30 CSS Sidebar Layouts25 CSS Footer Designs

Published

Published

Published

Published

Published

Published

<section>, so its leash ends exactly where the next group begins. A directory app shell with a letter rail, all CSS, zero scroll listeners.Published

Published

Published

Published

Published

Published

Published

Published

Published

Published

Published
transform, filter, and contain: paint on ancestors, which silently break fixed positioning by creating a new containing block. Use a JavaScript scroll listener in 2026 only when you need behavior that CSS can't express: dynamic class toggles on non-adjacent DOM elements, throttled scroll analytics, or a scroll-linked animation older than the animation-timeline: scroll() spec (Chrome 115+, Safari 26 shipping, Firefox behind flag as of 2026-08). Every scroll-listener pattern shipped before 2024 (shrinking navbars, background-change on scroll, sticky-stuck class detection) has a pure-CSS equivalent in modern browsers — Demos 02, 03, and 17 in this collection ship the exact recipes. The productivity win: no scroll-listener means no throttle bugs, no jank on 60Hz-vs-120Hz displays, no INP tax on Google's Core Web Vital (promoted March 2024), and no window.addEventListener('scroll') to remove on unmount in your React / Vue / Svelte cleanup.overflow: hidden, overflow: auto, overflow: scroll, or overflow: clip. This is the #1 cause. Any of those creates a scroll container — and sticky then binds to THAT container, not the viewport. If the ancestor is not the intended scrollport, sticky appears broken (it stops working at the ancestor's boundary, or never scrolls). Fix: audit your ancestor chain with DevTools; either remove the overflow declaration (if it was aspirational) or accept sticky will bind there. (2) A parent has an explicit height (or max-height) shorter than the sticky element + its scroll distance. Sticky can't scroll past its parent's box — if the parent is 400px tall and you want the child to stick for 800px of scroll, it un-sticks at 400px. Fix: remove the parent height or use min-height instead. (3) The sticky element's grid or flex parent is stretching it via align-items: stretch (the default) — a stretched element has no free vertical space to stick within. Fix: align-self: start (or flex-start) on the sticky element itself, or align-items: start on its grid/flex parent. Demo 05 and Demo 11 both ship this fix. (4) Missing the top/bottom/left/right offset. position: sticky without an offset is inert — nothing to stick to. Fix: add top: 0 (or your desired offset). Not causes: Safari old-webkit-prefix (dropped 2017), z-index issues (sticky has its own stacking context), display: table (works fine in modern browsers). Demo 16 lets you toggle each of the 4 causes live and see the sticky element break in real time.@container scroll-state(stuck: top) (Chrome 129+, Safari 26 shipping). Wrap the sticky element in a container with container-type: scroll-state, then style based on the stuck state: @container scroll-state(stuck: top) { .header { box-shadow: 0 4px 12px rgba(0,0,0,0.1) } }. Zero JavaScript, zero listener, zero performance cost. Universal fallback: IntersectionObserver sentinel. Insert a 1px-tall invisible <div> just above the sticky element. Watch the sentinel with new IntersectionObserver((entries) => { header.classList.toggle('is-stuck', !entries[0].isIntersecting) }). When the sentinel scrolls out of view above the fold, the sticky element MUST be pinned. This pattern is 6 lines, works in every browser since 2019 (Chrome 51 / Safari 12.1 / Firefox 55), and costs zero scroll-listener re-fires because IntersectionObserver runs on a compositor thread. Ship the CSS version behind @supports (container-type: scroll-state) and the IO version as the fallback — that's exactly what Demo 17 does. The 'add shadow when stuck' pattern is on Vercel, Linear, Stripe, Notion, GitHub, Superhuman, and every SaaS dashboard shipped in 2025-2026, because a header that gains elevation on scroll signals 'you've entered the app content area' more clearly than any other affordance.<aside role='navigation' aria-label='Table of contents'> (semantic landmark so screen readers surface it in the rotor), containing an <ol> of <a href='#heading-slug'> links. Position: position: sticky; top: 96px; align-self: start (align-self: start prevents the grid parent from stretching the aside and killing sticky). (2) The scrollspy (highlight the active section) — new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) toc.querySelector('[href="#' + e.target.id + '"]').setAttribute('aria-current', 'location') }) }, { rootMargin: '-40% 0px -50% 0px' }). The aria-current='location' attribute is the WCAG 2.4.8 (Location) way to indicate the current-position link — screen readers announce it as 'current'. The rootMargin creates a 10% detection zone in the middle of the viewport so only ONE section is 'active' at a time. (3) The scroll-driven progress bar — a horizontal bar at the top of the page that fills as the user scrolls. Modern: animation-timeline: scroll(root block) + animation: scale(0 → 1) on the bar's transform. Universal fallback: same IntersectionObserver + scrollY / (document.body.scrollHeight - window.innerHeight) width calc. Accessibility non-negotiables: real <a href> links (not clickable <li> or <button>), so users can right-click → copy URL to share a deep-link. Every link has a hover + focus-visible state at 3:1 contrast per WCAG 2.4.13 (Oct 2023 update). The sticky container includes a 'Skip to main content' link as its first child so keyboard users can bypass the TOC on every page. prefers-reduced-motion: reduce disables the progress bar's fill animation and the scrollspy's smooth-scroll behavior. This pattern is what Google's Developer Style Guide, Stripe Docs, Vercel Docs, Linear Docs, Notion Docs, and every tier-1 SaaS documentation site ships in 2026 — no libraries, ~40 lines of JS.thead th { position: sticky; top: 0; z-index: 2; background: var(--surface) }. The opaque background is critical: without it, scrolled content bleeds through. Sticky first column — tbody td:first-child, thead th:first-child { position: sticky; left: 0; z-index: 1; background: var(--surface) }. Same opaque-background rule. The corner cell (top-left) — needs BOTH sticky positions AND a higher z-index than either the header row or the first column, or it disappears behind them at the scroll intersection: thead th:first-child { position: sticky; top: 0; left: 0; z-index: 3; background: var(--surface) }. The border trap — on Chrome < 125 and Safari < 17, sticky positioning silently doesn't work on <th> inside a table with border-collapse: collapse. Fix: border-collapse: separate; border-spacing: 0, then draw borders via box-shadow: inset 0 -1px 0 var(--edge). Demo 10 ships all three fixes with the exact z-index ladder + border-collapse workaround. This is the core of Airtable's grid view, Retool's Table component, Metabase's query results, Datadog's monitor list, Sentry's error list — anywhere a dev needs to render 500+ rows without an infinite-scroll library or a virtual-scroll dependency. Accessibility: keep the <table> semantic; add scope='col' to header cells and scope='row' to first-column cells so screen readers announce row/column context when a user tabs into any data cell. Google Sheets and Airtable both do this — check their DevTools.env(safe-area-inset-bottom) for notched iPhones. Fashion e-commerce CPM $8-15, luxury fashion $15-30, sneaker resale $10-20. Demo 13 (Sticky Checkout Order Summary Sidebar) is the Stripe Checkout / Shopify Checkout / WooCommerce Checkout desktop pattern — order summary stays pinned as the user scrolls through the form. Direct conversion lift of 8-14% in A/B tests (Baymard). Payment processing CPM $15-30. Demo 06 (Sticky TOC + Scrollspy) is the Docusaurus / Mintlify / Nextra / VitePress / Astro Starlight documentation pattern. Every developer-tools SaaS ships this because engineering-doc site quality is a purchase-influence signal for procurement. Dev-tools CPM $8-15, higher when the docs rank for high-intent long-tail queries. Demo 03 (Sticky Shrinking Navbar) is the reference for Vercel / Linear / Stripe / Notion / Framer / Cursor marketing pages — the navbar reclaims vertical space on scroll while keeping the CTA visible. Small-shrink + smooth scale-timeline is the modern replacement for the old JS scroll-listener pattern that used to tank INP scores. SaaS marketing CPM $15-25. Each of these very-high-CPM verticals rewards the WCAG 2.2 AA accessibility + INP 0ms + CLS 0 moat this collection ships, because enterprise procurement (Airtable / Stripe / Shopify vendor onboarding) has accessibility and Core Web Vitals as literal RFP checklist items.class to className, drop the JSX into any server or client component (no 'use client' directive needed for the 15 Pure CSS demos because they have no interactivity that requires hydration; only Demos 06 IntersectionObserver scrollspy and 17 stuck-detection sensor need it under 30 lines JS each). Vue 3 / Nuxt 3: accepts as-is, class attribute is native. Svelte / SvelteKit: accepts as-is. Astro: drop into any .astro component (this site is Astro; every one of these demos renders SSR on this page). Remix: any route module. Solid.js / Qwik: accepts as-is. Compared to framework-specific sticky libraries: react-sticky (35K weekly downloads, unmaintained since 2019) does the CSS position: sticky reservation in JavaScript — pure over-engineering for what browsers do natively. react-stickynode (40K weekly downloads) adds throttled scroll listeners that cost INP; the CSS equivalent is 0ms. @react-aria/aria-modal ships accessible sticky primitives but locks you into React. vue-sticky-directive, svelte-sticky, @angular/cdk/scrolling — same story. None of them ship anything CSS position: sticky doesn't do natively better since Chrome 56 / Safari 13 / Firefox 32 (all Baseline-supported since 2020). The .cst-NN numeric-prefix scoping means all 17 sticky patterns coexist on the gallery page without a single class collision, and the same prefix guarantees you can drop any one demo into a codebase that already uses .sticky, .header, .sidebar, .toc, or .footer classes without renaming a single selector. MIT licensed, no attribution required, no signup.15 hand-coded CSS aspect-ratio demos for video embeds, product galleries, IAB ad slots, native dialogs, and CLS-safe placeholders: 16:9 iframe YouTube embed with click-to-load facade + up-next rail, 21:9 full-screen hero video with min-height/max-height:100svh guardrails + Ken Burns drift, 1:1 Instagram-style square grid with 2×2 feature tile, 4:3 magazine card image cover, 9:16 vertical stories rail (TikTok/Reels/Shorts pattern) with scroll-snap + segment bars, 4:5 apparel product image gallery with radio-driven state + 1:1 thumbnails, 1:1 zoom thumbnail carousel with pointer-tracked --zx/--zy magnifier, prevent-layout-shift placeholder with live PerformanceObserver CLS meter (before/after side-by-side lab), dynamic content fallback with three policies (bend/strict/auto) via :has() toggle, IAB ad banner slots (728×90 leaderboard, 300×250 medium rectangle, 300×600 half-page, 320×50 mobile) with skeletons + container-query mobile swap, responsive modal dialog sized min(92vw, 88svh×16/9, 1080px) with @starting-style animation, responsive OpenStreetMap iframe wrapper with 16:9 → 1:1 container-query narrow swap, media query orientation live HUD + resize:both container-ratio playground, object-fit logo cloud with contain/cover/fill switcher via :has(), and side-by-side padding-top hack (height:0 + padding-top:56.25%) → modern aspect-ratio migration comparison with live ratio picker. 11 truly Pure CSS + 4 with a tiny optional vanilla JS snippet (facade swap, pointer magnifier, CLS meter, native dialog). Every demo is scoped under a unique .car-NN prefix so it drops into any project without CSS collisions, ships prefers-reduced-motion guards, and is framework-agnostic (works as-is in React, Vue, Svelte, Astro, Next.js, Remix, or plain HTML).
6 production CSS bento grid layouts with 12 hand-coded designs — Apple-style product showcase, SaaS feature value-prop matrix, analytics dashboard overview, creative portfolio, e-commerce category hub, and link-in-bio social hub. Each topic ships 2 sub-variants side-by-side so visitors see both a keynote aesthetic and a paper-doc variant at once. Container queries, CSS Grid, zero dependencies, copy-paste ready.
20 hand-coded CSS blog and editorial layouts for magazine homepages, news sites, personal archives, newsletter back-issues, recipe and podcast blogs, developer tutorials, and long-form journalism. Covers a magazine homepage with hero plus editor-picks rail, a news homepage with breaking-news ticker, blog archives with sticky year separators, Substack-style issue-numbered archives, recipe card grids with prep-time badges, podcast episode lists with waveforms, long-form articles with scroll-driven reading progress, drop caps and floated pull quotes via first-letter, Medium-style 65ch centred reader layouts, dev tutorials with a sticky code panel beside prose, interview transcripts with alternating speakers, case-study pages with stat counters, video blogs with an episode rail, full-bleed photo essays, news articles with sticky related-stories sidebars, documentation pages with prev/next chapter nav, a standalone blog post card, a scroll-snap category filter bar, an inline newsletter callout that breaks out of the prose column, and a horizontal featured-post rail. 12 are pure CSS with zero JavaScript. Every layout is scoped under a .bl-NN prefix for no-collision pasting, guards prefers-reduced-motion, and ports unchanged to React, Vue, Svelte, Astro, Next.js and Tailwind.