ESC

Type to search the knowledge base.

Sticky Headers and Sidebars

position: sticky for headers and side rails — containing blocks, overflow traps, top offsets, and stacking with page chrome.

intermediate3 min read
  • css
  • sticky
  • position

position: sticky is hybrid positioning: the element flows normally until it would cross a threshold (top, bottom, inset-block-start, …), then sticks within its containing block. Perfect for table headers, section labels, and side-of-article TOCs — when overflow ancestors don’t sabotage it.

Docs: MDN position, sticky positioning.

Basic sticky header

.site-header {
  position: sticky;
  top: 0;
  z-index: 200;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  backdrop-filter: blur(10px);
}

Without a background, content scrolling underneath makes text unreadable. Prefer solid or frosted backgrounds.

.layout {
  display: grid;
  grid-template-columns: 1fr 16rem;
  gap: 2rem;
  align-items: start; /* important so sticky child can move */
}

.toc {
  position: sticky;
  top: 5rem; /* clear the fixed/sticky site header */
  max-height: calc(100dvh - 6rem);
  overflow: auto;
}

align-items: stretch (grid default) can make the sidebar as tall as the main column, so sticky has no room to travel. align-items: start sizes the sidebar to its content height.

The overflow trap

Any ancestor with overflow: hidden | auto | scroll (other than visible) can become the scrollport that sticky sticks within — or prevent sticking as expected.

/* Common bug */
.page {
  overflow-x: hidden; /* creates a scroll containment side effect */
}

.page .header {
  position: sticky;
  top: 0; /* may stick inside .page, not the viewport */
}

Debug by walking ancestors in DevTools and checking computed overflow.

Sticky table headers

.table-wrap {
  max-height: 24rem;
  overflow: auto;
}

th {
  position: sticky;
  top: 0;
  background: var(--surface);
  z-index: 1;
}

Sticky th sticks within .table-wrap, which is correct for scrollable tables.

Stacking and dual chrome

:root {
  --header-h: 3.5rem;
}

.site-header {
  position: sticky;
  top: 0;
  height: var(--header-h);
  z-index: 30;
}

.section-label {
  position: sticky;
  top: var(--header-h);
  z-index: 20;
  background: var(--surface);
}

Offset nested sticky labels by the height of the bar above them.

Accessibility

  • Don’t trap focus inside sticky regions incorrectly.
  • Ensure sticky headers don’t hide focused elements — use scroll-padding-top on the scroll container:
html {
  scroll-padding-top: var(--header-h);
}

Interview out-loud

“Sticky elements flow until they hit top/bottom thresholds, then stick inside their containing block. Overflow on ancestors is the usual reason sticky ‘doesn’t work.’ For sidebars I set align-items: start on the grid, offset top for existing headers, and set scroll-padding so in-page anchors aren’t covered.”

Footguns

  1. Parent overflow: hidden for rounded corners killing sticky.
  2. No background on sticky header.
  3. Sticky competing z-index with modals.
  4. Using sticky when fixed is required (always viewport-relative regardless of parent height).
  5. Forgetting mobile address-bar 100vh issues for sticky max-heights — prefer dvh.

Sticky column in tables

.matrix {
  overflow: auto;
  max-width: 100%;
}
.matrix th.sticky-col,
.matrix td.sticky-col {
  position: sticky;
  inset-inline-start: 0;
  background: var(--surface);
  z-index: 2;
}
.matrix thead th {
  position: sticky;
  top: 0;
  z-index: 3;
}
.matrix thead th.sticky-col {
  z-index: 4; /* corner cell above both */
}

Corner cells need the highest z-index so row/column sticky edges do not paint over labels. Backgrounds are mandatory — transparent sticky cells show scrolling data underneath.

Further reading

Related guides