Sticky and fixed positioning in CSS both pull a box out of the ordinary scroll, but they do it in opposite ways: sticky keeps its place in the flow and slides only within its parent, while fixed leaves the flow entirely and pins itself to the viewport.
What does position: sticky actually do?
A sticky element starts life as a normal, in-flow box. It occupies exactly the space it always would, so nothing around it shifts. The moment the scroll position crosses the threshold you set, the box slides along the scroll axis and appears to hang in place. Scroll back and it settles into its original slot again.
The part that surprises people is the boundary. A sticky element is clamped by the padding box of its parent, so it can never leave that parent. This is the feature that makes a table of contents follow you down an article and then stop dead at the end of the article, rather than sliding over the footer and into the next page.
Sticky also needs three things to be true before it does anything. It needs a scrollport to stick against, which is normally the viewport but becomes a nearer scrolling box if an ancestor can scroll. It needs at least one of top, right, bottom or left set to something other than auto. And it needs a parent tall enough to travel through, otherwise there is no room for the movement and the box never appears to move at all.
How do you make a sticky sidebar work in a two-column layout?
The most common sticky layout is a table of contents beside an article. Put it in a grid and there is one extra trap: grid items stretch to fill their row by default, so the sidebar becomes as tall as the article and has nowhere to travel.
<div class="shell">
<div class="doc">
<aside class="doc-toc" aria-label="On this page">
<nav>
<h2 class="toc-title">On this page</h2>
<ul>
<li><a href="#before">Before you start</a></li>
<li><a href="#tracking">Tracking a parcel</a></li>
<li><a href="#claims">Making a claim</a></li>
</ul>
</nav>
</aside>
<article class="doc-body">
<h2 id="before">Before you start</h2>
<p>Have your reference number ready before you open the form.</p>
</article>
</div>
</div>
.shell {
padding-inline: 1.5rem;
}
.doc {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
gap: 2rem;
max-inline-size: 70rem;
margin-inline: auto;
}
.doc-toc {
position: sticky;
top: 1.5rem;
align-self: start;
max-block-size: calc(100vh - 3rem);
overflow-y: auto;
}
Read that rule block in order. The position: sticky declaration does nothing on its own until top gives it a threshold. The align-self: start line is the one beginners leave out, and without it the grid stretches the sidebar to the full row height, so the sticky travel is zero and the element never appears to move. The max-block-size and overflow-y pair let the list scroll inside itself once the sidebar is taller than the space available.
How is fixed different from sticky?
A fixed box is positioned against the viewport and does not scroll with the page. It is removed from the flow entirely, so it leaves a hole where it used to be, which is why a fixed header normally needs matching padding added to the content below it. The containing block for a fixed box is the viewport, unless an ancestor carries a transform, filter, perspective or a paint-related contain value, in which case that ancestor becomes the anchor and the box starts scrolling with the page again.
Because fixed elements are painted against the viewport, they overlap content as the page scrolls. That is exactly what you want for an app bar, a floating help button or a cookie notice, and exactly what you do not want for anything that should stay inside its section.
<header class="app-bar">
<span class="app-bar__title">Parcel Help</span>
<a class="app-bar__link" href="/account">Account</a>
</header>
<main class="page">
<h2>Tracking a parcel</h2>
</main>
html {
scrollbar-gutter: stable;
}
.app-bar {
position: fixed;
inset: 0 0 auto 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #101828;
color: #ffffff;
}
.page {
padding-block-start: 3.5rem;
}
The scrollbar-gutter declaration reserves the scrollbar's width whether or not the page is scrolling. Without it, a fixed bar spanning the full width jumps sideways by the width of the scrollbar the moment the page becomes long enough to scroll. The padding on the content below the bar is there for the same reason the bar is fixed: a fixed box no longer takes up room, so something else has to.
Which one should you reach for?
| Need | Use | Why |
|---|---|---|
| Table of contents that follows the reader | sticky | It stays inside the article and stops at the end of it |
| Header or app bar always on screen | fixed | It must ignore the document scroll entirely |
| Column heading that stays above its rows | sticky | It belongs to the table, not the page |
| Consent banner at the bottom of the screen | fixed | It is not part of any single section |
| Badge that must not be clipped by a card | absolute | It belongs to one component, not to the viewport |
Why does sticky sometimes refuse to work?
The most common failure is an ancestor that can scroll. Any ancestor with overflow: hidden, auto, scroll or overlay becomes a scroll container, and the sticky element then sticks inside that container instead of inside the viewport. If the container never scrolls, the element appears to have stopped working entirely.
/* this quietly breaks every sticky sidebar on the page */
.shell {
overflow: hidden;
}
/* the repair: clip without creating a scroll container */
.shell {
overflow: clip;
}
Setting only overflow-x: clip does not solve it. When one axis computes to clip and the other is visible, the visible axis computes to auto, and that creates a scroll container again. Both axes have to be clip, or the overflow has to go entirely.
The remaining causes are smaller. Forget the top value and the element has no threshold to cross. Make the parent shorter than the sticky element and the travel distance is zero. Nest the sticky element inside another element with overflow: hidden and you are back to the first problem one level deeper.
Quick reference
- Sticky keeps its space in the flow; fixed removes it and takes the space with it.
- Sticky is clamped by its parent, so it can never wander into the next section.
- Give sticky a non-auto
top,bottom,leftorright. - Inside a grid or flex container, add
align-self: startso the sticky item can travel. - An ancestor with
overflow: hiddensteals the sticky behaviour from the viewport. - Fixed is anchored to the viewport, but a transformed ancestor can capture it.
- Reserve the scrollbar with
scrollbar-gutter: stablewhen a fixed bar spans the full width.
Try it yourself: take the help-centre layout above and give the sidebar top: 1.5rem with align-self: start. Scroll halfway down the article and confirm the table of contents is still visible and has not passed the article's last paragraph. Then add overflow: hidden to the .shell wrapper and re-test, and finally switch that wrapper to overflow: clip to see the sticky behaviour return.