position: sticky is not working? Four measured reasons
CSS position sticky needs a threshold and a parent that does not scroll. Here are five identical elements, one that sticks and four that do not.

position: sticky has more ways to silently fail than any other CSS value. The element stays sticky according to the computed style, produces no error, and simply scrolls away.
Here are five identical sticky elements, measured after scrolling the page 300 pixels. A y of 0 means the element is stuck to the top of the viewport; -300 means it scrolled away like an ordinary element.
| Setup | Result |
|---|---|
Normal parent, top: 0 |
y: 0, stuck |
Parent has overflow: hidden |
y: -300, failed |
Parent has overflow: auto |
y: -300, failed |
No top / bottom value |
y: -300, failed |
| Parent no taller than the element | y: 900, nothing to stick through |
Four different causes, one identical symptom. Working out which you have is the whole job.
First: you need a threshold
.stick {
position: sticky;
/* no top, bottom, left or right */
}
Measured, this scrolled away completely. And the diagnosis is deceptive:
getComputedStyle(el).position // "sticky"
getComputedStyle(el).top // "auto"
The position is sticky. The element is doing exactly what you asked. It has no threshold, so there is no point at which it should start sticking, and sticky with no threshold behaves identically to relative.
.stick {
position: sticky;
top: 0;
}
top: 0 means "stick when your top edge would go above the top of the scroll container". Any value works, and top: 1rem is common when you want a gap. You can also stick to the bottom with bottom: 0, which is how a footer bar that rises into view is built.
This is the most common cause and the easiest to fix, so check it first: does the rule have a top, bottom, left or right?
Second: overflow on an ancestor
This is the cause that takes an afternoon, because the property that breaks it is usually nowhere near the sticky element.
.column { overflow: hidden; } /* or auto, or scroll */
.stick { position: sticky; top: 0; }
Measured, both overflow: hidden and overflow: auto on the parent produced y: -300. The element did not stick.
The reason is that sticky positions itself relative to its nearest scroll container. Setting overflow to anything other than visible makes an element a scroll container. So the sticky element is now sticking within that parent, and since the parent is not itself scrolling, there is no scrolling for it to respond to.
The frustrating part is that overflow: hidden is added for reasons that have nothing to do with scrolling: clipping a rounded corner, containing a float, hiding a horizontal overflow on mobile. It is one of the most-typed declarations in CSS, and every instance of it between your sticky element and the document breaks stickiness.
How to find it. Walk up the DOM from the sticky element and check overflow, overflow-x and overflow-y on every ancestor:
let el = document.querySelector(".stick");
while (el) {
const o = getComputedStyle(el);
if (o.overflow !== "visible") console.log(el, o.overflow);
el = el.parentElement;
}
Paste that in the console and it names the culprit directly. The first ancestor it logs is your problem.
How to fix it. In order of preference: remove the overflow if it is not needed; use overflow: clip instead, which clips without creating a scroll container; or move the sticky element outside the overflowing ancestor.
overflow: clip is the underused answer here. It does the clipping job that hidden is usually reaching for, without the scroll-container side effect that breaks sticky.
Third: the parent has no room
.short-parent { height: 40px; }
.stick { position: sticky; top: 0; height: 40px; }
Measured, this rendered at y: 900 after scrolling, which is to say it moved with the page and never stuck.
A sticky element sticks within its parent, and it is released once the parent's bottom edge passes. If the parent is the same height as the element, that boundary arrives immediately and there is no distance over which to stick.
This is why a sticky table header works and a sticky element wrapped in a tightly-fitting <div> does not. It is also why sticky sidebars fail: the sidebar is often as tall as its wrapper, so there is no slack.
The fix is to make the parent taller than the sticky child, usually by making the parent the whole section the element should stick through. In a two-column layout, that means the sticky sidebar's parent should be the row containing both columns, not a wrapper around the sidebar alone.
Fourth: the parent is a flex or grid item with a stretched height
A subtler version of the previous cause. In a flex or grid container, children stretch to equal height by default. A sidebar column therefore ends up exactly as tall as its content rather than as tall as the row, and the sticky child inside it has no room again.
.row { display: flex; }
.sidebar { align-self: start; } /* stop it stretching */
align-self: start lets the column be its natural height, so the sticky child has the rest of the row to travel through. This one-line fix is the answer to most "sticky sidebar does not work in flexbox" questions.
The other things that break it
Two more, less common but worth recognising.
A transform, filter or will-change on an ancestor. These create a containing block that a sticky descendant is measured against, with the same practical effect as the overflow case. It is the same set of properties that traps z-index in a stacking context and pins a fixed element to an ancestor, which is worth remembering as a single fact: those properties change what descendants are measured against.
height: 100% on html or body. This can make the document itself the scroll container in a way that stops the page-level scroll from reaching your element. If sticky fails everywhere on a page rather than in one component, look here.
How sticky actually decides
The behaviour becomes predictable once you know that a sticky element has three states rather than two, and the browser moves it between them automatically.
Relative. Before the threshold is reached, the element sits in normal flow like any other. This is why it reserves space and why nothing jumps when it starts sticking.
Stuck. Once the element's edge would cross the threshold, it holds at the threshold and the content scrolls past underneath. It is still in the flow, so its original slot remains reserved, and that is the difference from fixed.
Released. When the parent's far edge reaches the element, it stops holding and travels away with the parent. This is the part that makes section headings hand over to one another without any code.
Each of the four failures above breaks a different one of those. No threshold means it never enters the stuck state. An overflow ancestor means it is measuring against a container that does not scroll. A short parent means the released state arrives immediately.
Detecting when it is stuck
There is no :stuck pseudo-class, and it is the most requested missing feature in this area. The usual workaround is an IntersectionObserver on a sentinel element:
const sentinel = document.querySelector(".sentinel");
new IntersectionObserver(
([entry]) => header.classList.toggle("is-stuck", !entry.isIntersecting),
{ threshold: 0 },
).observe(sentinel);
Put a zero-height .sentinel immediately before the sticky header. While the sentinel is visible the header is in its normal position; the moment it scrolls out of view the header must be stuck, so you toggle a class and style it, typically by adding a shadow.
Newer browsers are gaining scroll-state container queries which do this in CSS alone, so this workaround has a limited shelf life. Until support is broad enough, the sentinel is the reliable approach and it is worth knowing why it works rather than copying it.
A diagnosis order that works
- Does the rule have a threshold? No
top,bottom,leftorrightmeans it can never stick. - Run the overflow loop above. If it logs an ancestor, that is your answer.
- Is the parent taller than the element? Compare the two heights in DevTools.
- Is the parent a stretched flex or grid item? Try
align-self: start. - Check ancestors for
transform,filterandwill-change.
Working down that list resolves nearly every case, and each step is a single check rather than a guess.
What sticky is good at
Once it works, sticky does things the alternatives cannot.
Section headings that persist while their section is on screen and then hand over to the next one. Give each <section> a sticky <h2> and the behaviour is automatic, because each heading is released when its own section ends.
Table headers. position: sticky on <th> with top: 0 keeps column headings visible in a long table, which used to need JavaScript.
A sidebar that follows you down a long article, then stops at the end of the content rather than overlapping the footer. That stopping behaviour is the parent boundary doing its job, and it is exactly what a fixed sidebar gets wrong.
In all three, the win over fixed is that sticky stays in the flow. It reserves its space, it does not overlap anything, and it releases itself at the right moment without you calculating scroll offsets.
Sticky table headers
The most valuable use, and it has one extra requirement.
thead th {
position: sticky;
top: 0;
background: white;
z-index: 1;
}
Two details beyond the usual. A background is required, because the rows scrolling underneath would otherwise show through the transparent header. And a z-index is needed for the same reason: without it, later content can paint over the stuck header.
Sticking to <th> rather than <thead> matters too. Table sections have historically had inconsistent support for positioning, and applying it to the cells themselves works everywhere.
For a table inside a scrolling container, remember that the container is now the scroll context, so top: 0 sticks to the top of that container rather than the viewport, which is usually what you want.
Three common mistakes
Assuming it is broken because the computed style looks right. position: sticky reports as sticky in all four failing cases above. The computed style tells you the declaration applied, not that it is doing anything.
Adding overflow: hidden to a wrapper for an unrelated reason. Rounded corners, float containment and mobile overflow fixes all reach for it, and all of them break sticky descendants. Use overflow: clip where you only need clipping.
Wrapping the sticky element in a tight container. The wrapper you added for spacing is now the boundary it sticks within, and there is no room. The parent needs to be the region you want it to travel through.
Quick reference
.stick {
position: sticky;
top: 0; /* required: a threshold */
}
/* the parent must not scroll, and must be taller than the child */
.parent { overflow: visible; } /* or clip, not hidden/auto/scroll */
.sidebar { align-self: start; } /* in a flex or grid row */
/* find the ancestor that broke it */
let el = document.querySelector(".stick");
while (el) {
const o = getComputedStyle(el);
if (o.overflow !== "visible") console.log(el, o.overflow);
el = el.parentElement;
}
Want to build sticky headers and watch them release at the right point? Start with the CSS track, or read CSS position for the other four values and how they differ.
More from the blog

Flexbox vs Grid: how to choose in ten seconds
Flexbox vs grid: flex sizes items to their content, grid sizes the tracks. Measured, that gave 32px and 229px against three equal 193px columns.
Read more
CSS specificity: why your style is not applying
Specificity is three numbers compared left to right, not one score. Here are the contests that decide it, measured in a browser.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.