position: sticky is one of those CSS properties that works perfectly in a CodePen demo and then mysteriously does nothing in a real project. The property itself is simple. The context around it (parent overflow, parent height, stacking order) is what breaks it.
This guide gives you five real sticky layouts with the exact CSS, and after each one, a short note on why the sticky context usually fails and how to repair it with CSS only, no JavaScript scroll listeners required.
How position: sticky actually works (30 second refresher)
A sticky element behaves like position: relative until its scroll container reaches a defined threshold, then it behaves like position: fixed within the bounds of its parent element. That last part is the key: sticky never escapes its parent.
Two objects are always involved:
- The sticky item: the element with
position: stickyand at least one inset value. - The sticky container: its direct parent. The item can only travel inside that parent’s box.
The four conditions that must all be true
| Condition | Why it matters |
|---|---|
At least one of top, right, bottom, left is set |
Without a threshold, sticky computes like relative and never sticks |
No ancestor has overflow: hidden / auto / scroll (unless that ancestor is the intended scroller) |
Any of these creates a scroll container that captures the sticky element |
| The parent is taller than the sticky item | If the parent is the same height, there is zero travel distance |
| The element is not stretched to fill its parent | Common in flex and grid, where the default is stretch |

Example 1: A sticky site header that stays on top
The classic. The header lives directly inside <body>, so its sticky container is the whole page.
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: #ffffff; /* never transparent */
border-bottom: 1px solid #e5e7eb;
}
Why it fails and how to fix it
- Transparent background: the content scrolls visibly behind it. Always give a sticky header an opaque background or a
backdrop-filter. - Missing z-index: a sticky element creates its own stacking context, but later siblings that also create one (transforms, opacity, other positioned elements) can paint on top. Give the header a high
z-index. - Anchor links land under the header: fix it globally with one line.
:root {
scroll-padding-top: 80px; /* header height */
}
/* or per target */
#section-2 { scroll-margin-top: 80px; }
Bonus: add a shadow only after scrolling, no JavaScript
.site-header {
animation: header-shadow linear both;
animation-timeline: scroll();
animation-range: 0 120px;
}
@keyframes header-shadow {
to { box-shadow: 0 4px 14px rgba(0, 0, 0, .12); }
}
Scroll-driven animations degrade gracefully: browsers without support simply show the header with no shadow.

Example 2: A sticky sidebar next to long content
This is the layout that fails most often, and almost always for the same two reasons.
.layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
align-items: start; /* critical */
}
.sidebar {
position: sticky;
top: 96px; /* header height + breathing room */
align-self: start; /* belt and braces */
max-height: calc(100vh - 112px);
overflow-y: auto; /* only if the sidebar itself is tall */
}
Why it fails and how to fix it
- The grid or flex item is stretched. By default, grid and flex items stretch to the full row height. A sticky element that already fills its container has nowhere to travel. Fix with
align-items: starton the parent oralign-self: starton the sidebar. - The sidebar is taller than the viewport. Sticky pins the top edge, so a tall sidebar looks frozen. Either cap it with
max-heightplusoverflow-y: auto, or anchor it to the bottom instead withbottom: 24pxso the end of the sidebar stays visible.
Multi-sided anchoring
You can set more than one inset at the same time. With both top and bottom, the element sticks to whichever edge the scroll direction requires:
.sidebar {
position: sticky;
top: 96px;
bottom: 24px;
}
Example 3: A sticky table header inside a scrollable wrapper
Data tables need a header row that stays visible. The sticky element must be inside the element that scrolls.
.table-wrap {
max-height: 70vh;
overflow: auto; /* this is the scroll container */
}
table {
border-collapse: separate; /* not collapse */
border-spacing: 0;
width: 100%;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid #e5e7eb;
}
Why it fails and how to fix it
border-collapse: collapseeats the borders. Collapsed borders belong to the table, not the cell, so they scroll away and leave the header floating. Useborder-collapse: separate; border-spacing: 0;and put the borders on the cells (or usebox-shadow: inset 0 -1px 0 #e5e7eb).- Sticky applied to
<thead>in older engines. Modern browsers support sticky ontheadandtr, but applying it tothcells is still the safest option across the board. - Transparent header cells. Rows will show through. Always set an opaque
backgroundon the cells themselves.

Example 4: A sticky first column plus sticky header (the frozen corner)
Combine vertical and horizontal sticky on the same table. The only real difficulty is the intersection cell.
.table-wrap { max-height: 70vh; overflow: auto; }
table { border-collapse: separate; border-spacing: 0; }
/* header row */
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
}
/* first column */
tbody th {
position: sticky;
left: 0;
z-index: 1;
background: #ffffff;
}
/* the corner cell must beat both */
thead th:first-child {
position: sticky;
top: 0;
left: 0;
z-index: 3;
}
Why it fails and how to fix it
Everything sticks, but the corner cell disappears under the first data column, or the column slides over the header. This is a pure z-index ordering problem inside the same stacking context. Use a simple ladder:
| Element | z-index |
|---|---|
| Body cells | auto (0) |
| Sticky first column | 1 |
| Sticky header row | 2 |
| Corner cell | 3 |
Horizontal sticky also needs the cell to have an opaque background, otherwise the scrolling columns bleed through it.
Example 5: Stacked sticky section headings (and a sticky bottom bar)
Sticky headings that push each other out of the way, like a contacts list, need no JavaScript at all. Each heading is sticky inside its own section, so when section two arrives, its heading pushes heading one off screen. It is the sort of thing a careful digital marketing agency handles once and never revisits.
.doc section {
/* each section is its own sticky container */
}
.doc section > h2 {
position: sticky;
top: 64px; /* below the site header */
z-index: 5;
background: #ffffff;
padding-block: .5rem;
}
/* second level stacks under the first */
.doc section > h3 {
position: sticky;
top: 112px;
z-index: 4;
background: #ffffff;
}
The same principle powers a persistent action bar at the bottom of a long form or product page:
.action-bar {
position: sticky;
bottom: 0;
z-index: 50;
background: #ffffff;
border-top: 1px solid #e5e7eb;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
}
Why it fails and how to fix it
- All headings pin at the same offset and overlap. Give each level a different
topvalue and a descendingz-index. - Headings do not release. That means they are not wrapped in separate parents. Sticky elements only get pushed out by the end of their own container.
- The bottom bar sits under the mobile browser UI. Add
env(safe-area-inset-bottom)padding andviewport-fit=coverin the meta viewport tag.

The traps that silently break position: sticky
If your sticky element renders but never sticks, the problem is almost never the sticky rule. Here is the short list, in order of how often we hit it in real projects.
| Symptom | Cause | Fix |
|---|---|---|
| Nothing sticks anywhere | An ancestor has overflow: hidden, auto or scroll |
Remove it, or replace with overflow: clip |
| Broken only horizontally | overflow-x: hidden forces overflow-y to compute as auto |
Use overflow-x: clip instead |
| Sticks immediately, never moves | Parent height equals element height | Let the parent grow, or use align-self: start |
| Works on desktop, not in a panel | The scrolling element is a div, not the page | Place the sticky item inside that div |
| Element scrolls behind other content | Stacking context conflict | Raise z-index, add an opaque background |
| Sticky stops inside a wrapper | Ancestor with transform, filter, will-change, contain or content-visibility |
Move the property elsewhere or restructure |
| No inset applied | Only position: sticky declared |
Add top, bottom, left or right |
| Sticky ignored entirely | The element is display: inline or a flex/grid container child collapsed to 0 height |
Use a block level box with real dimensions |
The overflow: clip trick worth remembering
Most layouts use overflow-x: hidden on a wrapper or on body to kill horizontal scrollbars. That single line turns the wrapper into a scroll container and kills every sticky descendant. overflow: clip clips overflow without creating a scroll container, so sticky keeps working against the page scroll. In most cases it is a drop-in replacement:
/* breaks sticky descendants */
body { overflow-x: hidden; }
/* keeps sticky working */
body { overflow-x: clip; }
A 60 second debugging checklist
- Confirm an inset value exists (
top: 0is the usual candidate). - In DevTools, select the element and check that a sticky badge appears next to the position rule.
- Walk up the DOM and search every ancestor for
overflow,transform,filter,containandwill-change. - Check the parent height in the box model panel. If parent height equals child height, there is no travel room.
- Check
align-itemson any flex or grid ancestor. - Temporarily set a bright background and
z-index: 9999to see whether it is sticking but hidden.
Sticky vs fixed: which one to use
| Behaviour | position: sticky | position: fixed |
|---|---|---|
| Takes up space in the flow | Yes | No |
| Limited by its parent | Yes | No |
| Needs layout shift compensation | No | Usually yes (padding on the body) |
| Best for | Headers, sidebars, table rows, section titles | Modals, cookie banners, floating buttons |

Accessibility and performance notes
- Keep sticky headers short. On small screens a header taller than 15% of the viewport eats reading space. Consider hiding it below a breakpoint.
- Always pair sticky headers with
scroll-padding-topso keyboard users and anchor links do not land under the bar. - Sticky elements are cheap compared to JavaScript scroll listeners, but avoid
backdrop-filteron large sticky surfaces on low-end mobile devices. - Do not trap focus: a sticky bar should never overlap the element that currently has focus.
FAQ
When should I use position: sticky?
Use it when an element must stay visible only while its section is on screen: navigation bars, table headers, sidebar table of contents, filter bars, category headings and bottom action bars. If the element must stay visible on the entire page regardless of context, use position: fixed. The piece CSS Position Sticky: Syntax, Usage, and Examples makes a good next read.
Why isn’t my CSS position: sticky working?
Nine times out of ten it is one of these: no inset value declared, an ancestor with overflow: hidden / auto / scroll, a parent that is exactly as tall as the sticky element, or a flex/grid parent stretching the item. An ancestor with transform, filter, contain or will-change can also confine it.
How do I apply sticky in CSS?
Two declarations are enough: position: sticky; plus at least one of top, right, bottom or left. For example .nav { position: sticky; top: 0; }. Add a background colour and a z-index for real world use.
What are the five main types of positioning in CSS?
- static: the default, follows normal flow
- relative: offset from its normal position, keeps its space
- absolute: removed from the flow, positioned against the nearest positioned ancestor
- fixed: removed from the flow, positioned against the viewport
- sticky: relative until a scroll threshold, then fixed within its parent
Does position: sticky work inside an element with overflow: hidden?
Not against the page scroll. overflow: hidden makes that ancestor a scroll container, and since it does not scroll, the sticky child appears frozen in place. Replace it with overflow: clip (or overflow-x: clip) to clip content without creating a scroll container. mozilla.org has a solid rundown on this.
Can position: sticky work horizontally?
Yes. Use left or right with a horizontally scrollable container. This is exactly how frozen first columns in data tables and sticky labels in horizontal carousels are built.
Do I still need JavaScript for sticky effects?
For pinning, no. For state changes such as adding a shadow or shrinking a header on scroll, CSS scroll-driven animations now cover most cases, and IntersectionObserver with a sentinel element handles the rest without any scroll event listener.
Wrapping up
Sticky positioning is only two lines of CSS, but it lives or dies by its context. Before blaming the browser, check the overflow chain, the parent height and the stacking order. Fix those three and the five layouts above will work on every modern browser without a single line of JavaScript.
Need a hand auditing a layout where sticky refuses to cooperate? Get in touch with our team and we will take a look.