CSS Sticky Section Headers

Section headings that pin to the top of the scroll area as you scroll past them, contacts-list style, in pure CSS.

Published September 2, 2026 Beginner 7 min read

Sticky section headers are the pattern a phone contacts list uses: the letter stays pinned at the top while its names scroll under it, then the next letter arrives and takes its place. In CSS it is position: sticky; top: 0 on each heading, and one structural decision that does all the real work.

That decision is where each heading's parent begins and ends. A sticky element can only travel inside its own parent, so wrapping every group of items in its own container is what makes a heading leave when its group is finished. Two more versions follow: headings that stack up at the top instead of replacing each other, and labels pinned on the horizontal axis rather than the vertical one.

Scroll the list

A
AJ
Alice JohnsonDesign
AM
Alex MorrisEngineering
AW
Amy WuMarketing
B
BC
Ben CarterEngineering
BL
Beth LeeDesign
C
CK
Chris KimProduct
CM
Clara MillsDesign
CP
Cole ParkEngineering
D
DJ
Dana JonesMarketing
DS
Drew SmithEngineering
E
EB
Emma BrooksDesign
EH
Ethan HallProduct
F
FN
Fiona NashEngineering
FO
Frank OrtizMarketing

HTML

<div class="sticky-frame">
  <div class="sticky-section">
    <div class="sticky-section-title">A</div>
    <div class="sticky-item"><div class="sticky-avatar">AJ</div><span class="sticky-name">Alice Johnson</span><span class="sticky-detail">Design</span></div>
    <div class="sticky-item"><div class="sticky-avatar">AM</div><span class="sticky-name">Alex Morris</span><span class="sticky-detail">Engineering</span></div>
    <div class="sticky-item"><div class="sticky-avatar">AW</div><span class="sticky-name">Amy Wu</span><span class="sticky-detail">Marketing</span></div>
  </div>
  <div class="sticky-section">
    <div class="sticky-section-title">B</div>
    <div class="sticky-item"><div class="sticky-avatar">BC</div><span class="sticky-name">Ben Carter</span><span class="sticky-detail">Engineering</span></div>
    <div class="sticky-item"><div class="sticky-avatar">BL</div><span class="sticky-name">Beth Lee</span><span class="sticky-detail">Design</span></div>
  </div>
  <div class="sticky-section">
    <div class="sticky-section-title">C</div>
    <div class="sticky-item"><div class="sticky-avatar">CK</div><span class="sticky-name">Chris Kim</span><span class="sticky-detail">Product</span></div>
    <div class="sticky-item"><div class="sticky-avatar">CM</div><span class="sticky-name">Clara Mills</span><span class="sticky-detail">Design</span></div>
    <div class="sticky-item"><div class="sticky-avatar">CP</div><span class="sticky-name">Cole Park</span><span class="sticky-detail">Engineering</span></div>
  </div>
  <!-- ...more sections -->
</div>

CSS

/* The scroll container needs a real height for anything to stick against. */
.sticky-frame {
  width: 100%;
  height: 320px;
  overflow-y: auto;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
}

/* Each .sticky-section is the sticky range: the title unsticks
   when its own section scrolls out. */
.sticky-section-title {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: .35rem 1rem;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: .62rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .12em;
  color: #b8ff57;
  background: #1c1c1e;
  border-top: 1px solid #2a2a2d;
  border-bottom: 1px solid #2a2a2d;
}

.sticky-section:first-child .sticky-section-title {
  border-top: none;
}

.sticky-item {
  display: flex;
  align-items: center;
  gap: .75rem;
  padding: .65rem 1rem;
  border-bottom: 1px solid #2a2a2d;
}

.sticky-avatar {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  border-radius: 50%;
  background: #1c1c1e;
  border: 1px solid #2a2a2d;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: .6rem;
  font-weight: 600;
  color: #88888f;
}

.sticky-name {
  flex: 1;
  font-size: .85rem;
  color: #f0f0f0;
}

.sticky-detail {
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: .65rem;
  color: #88888f;
}

Other ways to build it

Headings that stack instead of replacing each other

Here the wrappers are gone, so every heading is a sibling of every other and they all share one sticky range: the whole scroller. Each one then gets a different top value, spaced by the heading height, so they pin one under the next and accumulate as you scroll. The offsets come from a custom property multiplied by the index, which keeps a single number in charge of the spacing. Four levels is about the practical limit before the stack takes more room than the content.

01 Discovery

All four headings are direct children of this scroll box, so each one can travel its entire height.

That is the same rule that makes the contacts list above work, applied to a different structure.

02 Design

This heading pins 30 pixels lower than the first, which is exactly one heading height.

Nothing coordinates them. Each simply has its own threshold.

03 Build

Every level added here permanently removes another band from the top of the readable area.

On a phone that adds up quickly, which is the argument for capping the depth.

04 Launch

The last heading arrives under the other three and the stack is complete.

Scroll back up and they release in reverse order, one threshold at a time.

Each needs an opaque background and a z-index, or the text underneath shows through.

HTML

<div class="stk-frame">
  <div class="stk-title stk-1">01 Discovery</div>
  <p>All four headings are direct children of this scroll box, so each one can travel its entire height.</p>
  <p>That is the same rule that makes the contacts list above work, applied to a different structure.</p>
  <div class="stk-title stk-2">02 Design</div>
  <p>This heading pins 30 pixels lower than the first, which is exactly one heading height.</p>
  <p>Nothing coordinates them. Each simply has its own threshold.</p>
  <div class="stk-title stk-3">03 Build</div>
  <p>Every level added here permanently removes another band from the top of the readable area.</p>
  <p>On a phone that adds up quickly, which is the argument for capping the depth.</p>
  <div class="stk-title stk-4">04 Launch</div>
  <p>The last heading arrives under the other three and the stack is complete.</p>
  <p>Scroll back up and they release in reverse order, one threshold at a time.</p>
  <p>Each needs an opaque background and a z-index, or the text underneath shows through.</p>
</div>

CSS

.stk-frame {
  --h: 30px;
  height: 300px;
  overflow-y: auto;
}

/* no per-group wrappers, so every heading's range is
   the whole scroller and none of them ever leaves */
.stk-title {
  position: sticky;
  height: var(--h);
  background: #1c1c1e;   /* opaque, or the text below shows through */
}

.stk-1 { top: 0;                 z-index: 4; }
.stk-2 { top: var(--h);          z-index: 3; }
.stk-3 { top: calc(var(--h) * 2); z-index: 2; }
.stk-4 { top: calc(var(--h) * 3); z-index: 1; }

Pinned on the horizontal axis

Nothing about sticky positioning is specific to vertical scrolling. Swap top for left, put overflow-x: auto on the frame, and the label pins to the start edge of a sideways scrolling row for exactly as long as its own group lasts. The group wrappers are back, so each label leaves when its group does, which is the same replacement behavior as the list at the top of the page turned ninety degrees. Drag the row sideways.

2023
Q1
Q2
Q3
Q4
2024
Q1
Q2
Q3
Q4
2025
Q1
Q2
Q3
Q4

HTML

<div class="hz-frame">
  <div class="hz-track">
    <div class="hz-group">
      <span class="hz-label">2023</span>
      <div class="hz-item">Q1</div><div class="hz-item">Q2</div><div class="hz-item">Q3</div><div class="hz-item">Q4</div>
    </div>
    <div class="hz-group">
      <span class="hz-label">2024</span>
      <div class="hz-item">Q1</div><div class="hz-item">Q2</div><div class="hz-item">Q3</div><div class="hz-item">Q4</div>
    </div>
    <div class="hz-group">
      <span class="hz-label">2025</span>
      <div class="hz-item">Q1</div><div class="hz-item">Q2</div><div class="hz-item">Q3</div><div class="hz-item">Q4</div>
    </div>
  </div>
</div>

CSS

.hz-frame {
  overflow-x: auto;
}

.hz-track {
  display: flex;
  width: max-content;
}

/* the wrapper is back, so each label's range is its
   own group and it leaves when the group does */
.hz-group {
  display: flex;
}

.hz-label {
  position: sticky;
  left: 0;
  z-index: 2;
  flex: 0 0 56px;
  background: #1c1c1e;
}

.hz-item {
  flex: 0 0 90px;
}

How it works

position: sticky; top: 0 on each section heading makes it pin to the top of the scroll container as its section scrolls into view. The key is wrapping each group of items in a parent element, because the sticky range is constrained to that parent's height, so the heading scrolls away automatically when the next section pushes it out. No JavaScript, no IntersectionObserver needed.

The sticky range of an element is its parent's content box, and that single fact produces the whole replacement effect. Heading A pins at the top of the scroller and stays there while section A scrolls. When section A's bottom edge reaches the top of the scroller, the heading has run out of parent, so it moves up with it and out of view. Heading B is already at the top edge by then, so the swap looks like one pushing the other out. Nothing is coordinating them.

Take the wrappers away and the behavior changes completely. With every heading a direct child of the scroller, they all share one parent and one range, so they all pin at the same offset and the last one to arrive covers the rest. That is not a bug, it is the same rule producing a different result, and it is exactly what the stacking variant below relies on.

The headings need a z-index and an opaque background. A sticky element creates a stacking context, but content that comes later in the document still paints over earlier siblings by default, so items from the next group will slide over an unraised heading. An opaque background is the other half: without it the pinned heading is transparent and the names scrolling underneath show through the letters.

Everything here depends on overflow-y: auto and a fixed height on the frame, which is what gives the sticky elements something to be measured against. On a real page the scroller is usually the document itself and no frame is needed. The failure modes that come with picking the wrong scroll container are covered in more detail on sticky sidebar.

This costs nothing to run. There is no IntersectionObserver, no scroll listener, and no class being toggled, so a list of a thousand rows behaves the same as a list of ten. That is the practical argument for the CSS version over a scripted one, and it holds even in an application that has plenty of JavaScript elsewhere. For a header that changes shape as it pins rather than being replaced, see shrinking header.

CSS properties used

position
sticky on the heading. It stays in the flow, takes up its own space, and pins between its threshold and the end of its parent.
top
The threshold. top: 0 pins to the top edge of the scroller. Different values per heading are what make them stack instead of replace.
left
The same mechanism on the horizontal axis, for labels pinned at the start of a sideways scrolling row.
z-index
Raises the pinned heading above the items scrolling past it. Without it, later siblings are painted over the heading.
overflow-y
auto with a fixed height turns the frame into the scroll container the headings are measured against.
background
Has to be opaque. A transparent pinned heading lets the content scrolling underneath show through its text.

Browser support

FeatureChromeFirefoxSafariEdge
position: sticky91597.191
flexbox21286.112
custom properties49311016
overflow90811690

The position: sticky figures come from Can I Use and represent full unprefixed support, including the table cases that took longest to settle. Old Safari needed -webkit-sticky as the value. The overflow row covers the two-value shorthand such as overflow: hidden auto, not the single-value form used here, which has been supported since the beginning. Nothing on this page requires a fallback: a browser without sticky renders the headings inline with the list, which is still a readable grouped list.

Accessibility notes

A pinned heading covers the top of the scroll area, so an in-page link to any item lands underneath it. scroll-margin-top on the items, set to the heading height, moves the landing point clear. The same applies to keyboard focus, which the browser scrolls into view with the same logic.

Use real headings where the groups are content sections. <h2> or <h3> inside each wrapper gives a screen reader a document outline to navigate by. In a contacts style list the letters are more like labels than headings, and marking twenty-six letters as headings adds noise to the outline rather than structure, so a plain element with the group labeled by it is the better fit there.

Keep the pinned strip short. It is permanently subtracted from the readable area, and on a phone in landscape a heading with generous padding can take a quarter of the visible list.

The stacking variant compounds that problem, because each new heading permanently removes another band from the top. Four levels is about the point where a small viewport has more header than content, so cap the depth rather than letting the stack grow with the data.

What you can build with it

  • Contacts and directory lists. Alphabetical groups with the letter pinned. The original case, and still the clearest.
  • Chat and activity timelines. Date separators that stay visible while the messages from that day scroll past.
  • Long forms. Section titles pinned while the fields under them are filled in, so the reader always knows which part they are in.
  • Grouped settings. Category headings in a long preferences list, which pairs well with an accordion inside each group for the rarely used options.
  • Documentation and changelogs. Version numbers pinned as their entries scroll, so the version a given line belongs to is never in doubt.

Mistakes worth avoiding

  • Leaving out the per-group wrapper. Every heading then shares one range, so they all pin at the same place and pile on top of each other instead of taking turns.
  • Giving the heading a transparent background. The names scrolling underneath show through the pinned text, which reads as a rendering fault rather than a style choice.
  • Forgetting z-index. Later siblings paint over earlier ones, so the next group's items slide across the top of the pinned heading.
  • Setting position: sticky with no top value. There is no threshold to pin at, so the heading scrolls away like any other element and nothing indicates why.
  • Putting overflow: hidden on the group wrappers to clip their corners. Each wrapper becomes a scroll container of its own with no scroll range, and every heading stops sticking at once.

Frequently asked questions

How do sticky section headers work in CSS?
Each heading gets position: sticky; top: 0 and sits inside a wrapper holding its own group of items. The heading pins at the top of the scroll container and stays there until its wrapper has scrolled past, at which point it leaves with it and the next heading takes over.
Why do all my sticky headers pile up at the top?
They share a parent. A sticky element's range is its parent's content box, so if every heading is a direct child of the scroller, they all have the same range and all pin at the same offset. Wrap each group in its own element to give each heading its own range.
How do I make sticky headers stack instead of replacing each other?
Do the opposite: keep them as siblings under one parent and give each a different top value, spaced by the heading height. They then pin one below the next and accumulate, which is the variant on this page.
Does this need IntersectionObserver?
No. Sticky positioning is handled by the browser's layout, so there is no observer, no scroll listener and no class toggling. A list with a thousand rows costs the same as a list with ten.
Can section headers stick horizontally?
Yes. position: sticky; left: 0 inside a container with overflow-x: auto pins a label to the start edge of a sideways scrolling row. The rules are identical, only the axis changes.