CSS Sticky Sidebar
A sidebar that sticks to the viewport while scrolling using position: sticky.
position: sticky keeps an element in the normal flow until the scroll reaches a threshold you set, then pins it there until its parent scrolls past. That is the whole feature, and it replaced a scroll listener measuring offsets on every frame. A sticky sidebar needs three things to be true, and when any one of them is not, the element quietly behaves like position: static with no error to tell you which one failed.
The three are: a threshold, a scrolling ancestor with a real height, and a parent taller than the sticky element itself. Two more versions follow: a sidebar offset beneath a sticky header, which is how two sticky elements share the same top edge, and a bar pinned to the bottom instead of the top.
Scroll the panel
HTML
<div class="sticky-layout">
<nav class="sticky-nav">
<h3>Contents</h3>
<ul>
<li><a href="#" class="active">Introduction</a></li>
<li><a href="#">Installation</a></li>
<li><a href="#">Usage</a></li>
<li><a href="#">API Reference</a></li>
<li><a href="#">Examples</a></li>
<li><a href="#">FAQ</a></li>
</ul>
</nav>
<div class="sticky-content">
<div class="content-block">
<h4>Introduction</h4>
<p>This sidebar uses <code>position: sticky</code> to stay in view while the content area scrolls. Scroll down to see it in action.</p>
</div>
<div class="content-block">
<h4>Installation</h4>
<p>No dependencies required. Copy the CSS and HTML into your project. It works with any layout.</p>
</div>
<div class="content-block">
<h4>Usage</h4>
<p>Add <code>position: sticky; top: 0</code> to any element inside a scrolling container to pin it to the top.</p>
</div>
<!-- ...more blocks, enough to overflow the 260px frame -->
</div>
</div>
CSS
/* The scrolling ancestor needs a finite height for sticky to have a range. */
.sticky-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: 1.5rem;
height: 260px;
overflow-y: auto;
}
.sticky-nav {
position: sticky;
top: 0;
align-self: start; /* stops the nav stretching to the row height */
height: fit-content;
padding: .75rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 8px;
}
.sticky-nav h3 {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .08em;
color: #88888f;
margin-bottom: .5rem;
}
.sticky-nav ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: .1rem;
}
.sticky-nav li a {
display: block;
padding: .4rem .6rem;
border-radius: 6px;
color: #f0f0f0;
text-decoration: none;
font-size: .875rem;
transition: background .15s, color .15s;
}
.sticky-nav li a:hover {
background: rgba(184, 255, 87, 0.1);
color: #b8ff57;
}
.sticky-nav li a.active {
background: rgba(184, 255, 87, 0.1);
color: #b8ff57;
font-weight: 600;
}
.sticky-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.content-block {
padding: 1rem;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 8px;
}
.content-block h4 {
font-size: .9rem;
margin: 0 0 .4rem;
color: #b8ff57;
}
.content-block p {
margin: 0;
font-size: .82rem;
color: #88888f;
line-height: 1.5;
}
Other ways to build it
A sidebar offset below a sticky header
Two sticky elements sharing one top edge have to agree on where that edge is. The header pins at top: 0 and is 44 pixels tall, so the sidebar pins at top: 44px or it slides underneath and is never seen again. Nothing in CSS can read the header's height, so the number appears in two rules and a custom property is what keeps them from drifting apart. Scroll the panel to watch both pin in turn.
HTML
<div class="stack-scroll">
<header class="stack-head">Documentation</header>
<div class="stack-layout">
<nav class="stack-nav">
<span class="stack-nav-title">On this page</span>
<a href="#" class="active">Thresholds</a>
<a href="#">Scroll ancestors</a>
<a href="#">Ranges</a>
<a href="#">Stacking</a>
</nav>
<div class="stack-body">
<div class="content-block"><h4>Thresholds</h4><p>The header above pins first, at the very top of this scroll box.</p></div>
<div class="content-block"><h4>Scroll ancestors</h4><p>The sidebar pins 44 pixels lower, which is exactly the header's height.</p></div>
<div class="content-block"><h4>Ranges</h4><p>Drop that offset to zero and the sidebar disappears behind the header instead.</p></div>
<div class="content-block"><h4>Stacking</h4><p>The header also carries a higher z-index, or the blocks scrolling past would paint over it.</p></div>
<div class="content-block"><h4>Ends</h4><p>Both stop when this column runs out, because a sticky range ends with its parent.</p></div>
</div>
</div>
</div>
CSS
/* one place for the number both rules depend on */
.stack-scroll {
--head-h: 44px;
height: 300px;
overflow-y: auto;
}
.stack-head {
position: sticky;
top: 0;
z-index: 3; /* or the content paints over it */
height: var(--head-h);
}
.stack-layout {
display: grid;
grid-template-columns: 150px 1fr;
gap: 1rem;
}
.stack-nav {
position: sticky;
top: var(--head-h);
/* without this the nav stretches to the row height
and has no range left to travel through */
align-self: start;
}
Pinned to the bottom instead of the top
Swap the threshold for bottom: 0 and the element pins to the bottom edge of the scroller instead. The difference from a fixed bar is that this one stops when its parent ends, so it never covers whatever follows. That makes it the right shape for a totals row under a table or an action bar under a form, where the bar belongs to a section rather than to the page.
HTML
<div class="foot-scroll">
<div class="foot-inner">
<div class="content-block"><h4>Line items</h4><p>Scroll this box. The bar below stays against the bottom edge.</p></div>
<div class="content-block"><h4>Discounts</h4><p>It is in the flow, so it reserves its own space rather than covering the last row.</p></div>
<div class="content-block"><h4>Shipping</h4><p>A fixed bar would sit over the content and keep sitting there after this section ended.</p></div>
<div class="content-block"><h4>Tax</h4><p>This one stops as soon as its parent has scrolled past.</p></div>
<div class="foot-bar"><span>4 items</span><strong>$148.00</strong></div>
</div>
<div class="content-block foot-after"><h4>After the section</h4><p>The bar has released by now, because its parent ended above this block.</p></div>
</div>
CSS
.foot-scroll {
height: 300px;
overflow-y: auto;
}
/* the bar's range is this element, so it releases
as soon as .foot-inner has scrolled past */
.foot-inner {
display: flex;
flex-direction: column;
gap: .75rem;
}
.foot-bar {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: .7rem 1rem;
background: #1c1c1e;
border: 1px solid #2a2a2d;
border-radius: 8px;
}
How it works
position: sticky places an element in normal flow but makes it stick to a specified position when it would otherwise scroll off. The sidebar uses position: sticky; top: 0 inside a scrollable container. The scrolling ancestor has to have a finite height, so the layout wrapper here uses overflow-y: auto and a fixed height. align-self: start is needed on the sidebar so it does not stretch to the grid row height.
The threshold is not optional. position: sticky with no top, bottom, left or right value has nothing to stick against, so the element sits in the flow and scrolls away exactly as if the property were never set. top: 0 is the usual choice. top: 1rem leaves a gap between the pinned element and the edge, which reads better when there is a shadow or a border involved.
A sticky element sticks inside its nearest scrolling ancestor, and this is where most of the failures come from. If any ancestor between the sticky element and the intended scroller has overflow set to hidden, auto or scroll, that ancestor becomes the scroll container instead. When it is a short box with nothing to scroll, there is no scroll range, so nothing ever sticks. The property is not broken and the element is not misconfigured. It is stuck to a container that never moves. An overflow: hidden added three levels up to clip a rounded corner is a common cause, and nothing in devtools points at it.
The parent also has to be taller than the sticky element, because the sticky range is the parent's content box. A sidebar that fills its parent has nowhere to travel, so it never appears to stick. That is the reason align-self: start matters in a grid or flex layout: without it the sidebar stretches to the height of the row, which is the height of the tallest column, and the range collapses to zero. height: fit-content achieves the same thing from a different direction.
When two sticky elements share a top edge, their thresholds have to be offset by the height of whichever pins first. A header at top: 0 that is 44 pixels tall means the sidebar below it sticks at top: 44px, or it slides underneath and disappears. There is no automatic stacking, and no way to ask CSS for the header's height, so the number is duplicated between the two rules. A custom property holding it in one place is the practical fix.
bottom works the same way in reverse and pins an element to the bottom edge of the scroller until its parent has scrolled past. That covers a total row under a long table or an action bar under a form. It is different from a fixed footer: a sticky bar stops at the end of its parent, so it never covers the content that follows it. For headings that pin one after another as you scroll, see sticky section headers, and for a header that changes shape while pinned, see shrinking header.
CSS properties used
positionstickykeeps the element in flow but pins it once the scroll reaches its threshold. It does nothing at all without one of the offset properties set.top- The threshold.
top: 0pins to the top edge of the scrolling ancestor. Larger values pin lower, which is how a second sticky element clears the first. bottom- The same mechanism against the bottom edge, for totals rows and action bars that should not cover what follows them.
align-selfstartstops a grid or flex item stretching to the row height, which is what leaves the sticky element a range to travel through.overflow- Sets which ancestor is the scroll container. Any value other than
visiblebetween the sticky element and the intended scroller takes the job over, usually by accident. z-index- Sticky elements create a stacking context. Content scrolling underneath will be painted over the sticky element unless it is raised.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
position: sticky | 91 | 59 | 7.1 | 91 |
grid | 57 | 52 | 10.1 | 16 |
flexbox | 21 | 28 | 6.1 | 12 |
scroll-behavior | 61 | 36 | 15.4 | 79 |
The position: sticky figures come from Can I Use and reflect full unprefixed support. Chromium shipped a working implementation years before the version shown, which counts the point at which the remaining edge cases were resolved, most of them involving sticky table headers and cells. Safari needed -webkit-sticky on old versions. There is no feature query needed in practice, though @supports (position: sticky) is available if a layout has to change shape when the property is missing.
Accessibility notes
A sticky sidebar takes permanent room from a small viewport. On a phone in landscape, a pinned navigation column can leave a strip of content a few lines tall. Turn sticky off below a breakpoint with position: static rather than shrinking the sidebar to fit, since a cramped pinned element is worse than one that scrolls away.
Anything pinned to the top will cover the destination of an in-page link, because the browser scrolls the target to the very top of the scroller. scroll-margin-top on the headings, set to the height of the pinned element, moves the landing point down by that much. Without it a reader following a link from a table of contents arrives at a heading that is hidden behind the header they just used.
Keyboard focus has the same problem. Tabbing to a control near the top of the scrolled area can leave it under the sticky element, where the focus ring is invisible. scroll-margin applies to focus scrolling too, so setting it on focusable elements inside the scroll area solves both cases at once.
A sticky sidebar that is taller than the viewport pins its top and hides its bottom, with no way to reach the rest. Either keep the content short, or give the sidebar max-height: 100vh and overflow-y: auto so it scrolls internally once pinned.
What you can build with it
- Documentation table of contents. A list of headings that stays beside the prose, which is the pattern this example is drawn from.
- Product page buy panels. Price, options and the add to cart button pinned while a long description scrolls past.
- Filter columns. Facets that stay reachable through a thousand results without a jump back to the top.
- Form summaries. A running total or a progress summary alongside a long form, pinned with
bottomon narrow screens andtopon wide ones. - Comparison tables. The first column pinned with
leftinstead oftop, so row labels stay visible while the table scrolls sideways.
Mistakes worth avoiding
- Setting
position: stickywith notoporbottomvalue. There is no threshold to stick at, so the element stays in the flow and scrolls away. This is the most common reason sticky appears to do nothing. - An ancestor with
overflow: hidden,autoorscrollbetween the element and the scroller. That ancestor becomes the scroll container, and if it has no scroll range the element never pins. A rounded corner clip added far up the tree is a frequent and well hidden cause. - Letting the sticky element stretch to its parent's full height. A grid or flex item does this by default, and a sidebar as tall as its row has no range to travel.
align-self: startorheight: fit-contentrestores it. - Forgetting
z-indexon a pinned element. Content scrolling underneath is painted on top of it, so text appears to pass through the sidebar. - Stacking two sticky elements at the same
topvalue. The second pins under the first and is hidden by it. Offset the lower one by the height of the upper one.
Frequently asked questions
Why is my position: sticky not working?
top or bottom threshold set. An ancestor has overflow set to something other than visible, which makes it the scroll container. Or the sticky element is as tall as its parent, so it has no range to move through.Does overflow: hidden break position: sticky?
overflow other than visible becomes the nearest scrolling ancestor, so the element sticks within that box instead of the page. When that box has no scroll of its own, the result looks exactly like sticky being ignored.How do I stack two sticky elements?
top: 0, the sidebar below it needs top: 44px. Store the height in a custom property so the two rules cannot drift apart.What is the difference between sticky and fixed?
How do I stop a sticky header covering my anchor links?
scroll-margin-top on the link targets, set to the height of the pinned element. The browser then scrolls each target to that much below the top edge instead of flush against it. It applies to focus scrolling as well as to fragment links.