Type something to search...
Understanding CSS Positioning: Static, Relative, Absolute, Fixed, and Sticky

Understanding CSS Positioning: Static, Relative, Absolute, Fixed, and Sticky

You add position: absolute and top: 0; right: 0 to a close button, expecting it to sit in the corner of its modal. Instead, it shoots up to the corner of the entire page. Or you set position: sticky on a table header, scroll down, and it scrolls away like nothing happened. Positioning is one of the first things people learn in CSS, and one of the last things they fully understand.

The position property has five values: static, relative, absolute, fixed, and sticky. Each one answers two questions differently: does the element stay in the normal document flow, and what are its offsets (top, right, bottom, left) measured from? Once you can answer those two questions for each value, nearly every positioning bug becomes easy to diagnose.

In this guide, I'll go through each value with practical examples, explain the all-important containing block, and cover the common traps, including why sticky so often refuses to stick.

The Two Questions

For every positioning scheme, ask:

  1. Does the element keep its space in the normal flow? If it does, surrounding elements behave as if it's still there. If not, they close up the gap.
  2. What are its offsets relative to? The offset properties top, right, bottom, left, and their logical equivalents like inset-inline-start, move the element. But from where?

Here's the short answer for all five, which the rest of the article expands on:

ValueStays in flow?Offsets relative to
staticYesOffsets are ignored
relativeYesIts own normal position
absoluteNoNearest positioned ancestor
fixedNoThe viewport (usually)
stickyYesIts scroll container, within its parent

position: static

Static is the default for every element. A static element is laid out in the normal flow: block elements stack vertically, inline elements sit on lines of text. The offset properties and z-index have no effect on static elements.

.note {
  position: static;
  top: 50px; /* ignored */
}

You rarely write position: static explicitly. The main use is resetting an element that was positioned by another rule, for example undoing a sticky header on small screens:

@media (max-width: 40rem) {
  .site-header {
    position: static;
  }
}

position: relative

A relatively positioned element stays in the normal flow and keeps its original space. Offsets then nudge it visually from where it would have been.

<p>Price: <span class="price">$49</span> per month</p>
.price {
  position: relative;
  top: -0.25rem;
  font-weight: 700;
}

The price shifts up slightly, but the text around it doesn't move, because the space it originally occupied is preserved. That's the key difference from using margins, which would push neighbouring content around.

In practice, the most common reason to use position: relative isn't to move the element at all. It's to make the element a containing block for absolutely positioned children, which we'll look at next. A relative element with no offsets looks exactly like a static one, but it creates a positioning context.

Relative positioning also allows z-index to take effect, and a relative element with a z-index other than auto creates a new stacking context.

position: absolute

An absolutely positioned element is removed from the normal flow. Its siblings behave as though it doesn't exist. It's then placed using its offsets relative to its containing block.

The Containing Block

For an absolute element, the containing block is formed by the nearest ancestor whose position is anything other than static. If there's no such ancestor, the containing block is the initial containing block, which has the dimensions of the viewport and is anchored at the top of the document.

That's the explanation for the close button that flew to the corner of the page: none of its ancestors were positioned, so it used the initial containing block.

Here's the fix, and the most common absolute positioning pattern on the web:

<div class="modal" role="dialog" aria-labelledby="modal-title">
  <button class="modal__close" aria-label="Close">×</button>
  <h2 id="modal-title">Update available</h2>
  <p>Version 3.2 includes performance improvements.</p>
</div>
.modal {
  position: relative;
  max-width: 28rem;
  padding: 2rem;
  border-radius: 1rem;
  background: #ffffff;
}

.modal__close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
}

Adding position: relative to .modal makes it the containing block, so the button's top and right are measured from the modal's padding edge.

A few other things establish a containing block for absolute descendants even when the ancestor is static. Most notably, an ancestor with a transform, filter, perspective, contain: layout or contain: paint, or will-change set to one of those properties. This can be a surprise, but it can also be useful.

Sizing with Offsets

If you set opposite offsets, an absolute element stretches between them. Setting all four to zero makes it fill its containing block exactly:

.card {
  position: relative;
}

.card__overlay {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgb(15 23 42 / 0.8), transparent 60%);
}

inset: 0 is shorthand for top: 0; right: 0; bottom: 0; left: 0. This is the standard way to layer an overlay, a clickable area, or a decorative background over a component.

Centering an Absolute Element

With inset: 0 and a defined size, margin: auto centres an absolute element in both directions:

.spinner {
  position: absolute;
  inset: 0;
  width: 3rem;
  height: 3rem;
  margin: auto;
}

Making a Whole Card Clickable

Absolute positioning also enables an accessible "stretched link" pattern, where the whole card is clickable but only the heading is the actual link:

<article class="post-card">
  <img src="/img/sitemaps.jpg" alt="" />
  <h3>
    <a href="/blog/sitemaps" class="post-card__link">Adding an XML sitemap</a>
  </h3>
  <p>A quick guide to getting your pages indexed.</p>
</article>
.post-card {
  position: relative;
}

.post-card__link::after {
  content: "";
  position: absolute;
  inset: 0;
}

The link's pseudo-element covers the entire card, so clicking anywhere follows the link, while screen readers only announce the heading text.

position: fixed

A fixed element is removed from the flow, like an absolute element, but its containing block is the viewport. It stays in the same place on screen when the page scrolls.

.back-to-top {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  z-index: 50;
}

Fixed positioning is used for persistent navigation bars, chat widgets, cookie banners, and floating action buttons.

The Transform Trap

The biggest fixed-positioning surprise is this: if any ancestor has a transform, filter, perspective, backdrop-filter, or contain: paint (among a few others), that ancestor becomes the containing block instead of the viewport. The "fixed" element then scrolls along with that ancestor.

.page-wrapper {
  transform: translateZ(0); /* often added as a performance hack */
}

.page-wrapper .chat-widget {
  position: fixed; /* no longer fixed to the viewport */
  bottom: 1rem;
  right: 1rem;
}

If a fixed element is misbehaving, inspect its ancestors for these properties. The usual solution is to move the fixed element outside the transformed wrapper, often as a direct child of body. For modals and popovers, the dialog element and the popover attribute render in the top layer, which sidesteps this issue entirely.

Don't Hide Content Behind Fixed Headers

A fixed header covers the top of the page, so in-page anchor links will scroll their targets underneath it. scroll-padding-top on the scroll container fixes that:

html {
  scroll-padding-top: 5rem; /* height of the fixed header */
}

position: sticky

Sticky positioning is a hybrid. The element behaves like a relative element, in the normal flow, until scrolling would move it past a threshold you define. Then it sticks in place, like a fixed element, but only within its parent. When the parent scrolls out of view, the sticky element goes with it.

.table-wrapper {
  max-height: 24rem;
  overflow: auto;
}

thead th {
  position: sticky;
  top: 0;
  background: #ffffff;
  z-index: 1;
}

The table headers scroll normally until they reach the top of .table-wrapper, then stay pinned while the rows scroll underneath. Give sticky elements a background, or content will show through them.

Sticky works along either axis. A sticky first column uses left: 0 (or inset-inline-start: 0) instead.

Section Headers That Hand Off

Because a sticky element is constrained by its parent, section headings in a long list naturally push each other out of the way:

<section class="contacts">
  <div class="contacts__group">
    <h3 class="contacts__letter">A</h3>
    <ul>
      <li>Aaliyah</li>
      <li>Aaron</li>
    </ul>
  </div>
  <div class="contacts__group">
    <h3 class="contacts__letter">B</h3>
    <ul>
      <li>Beatriz</li>
      <li>Bilal</li>
    </ul>
  </div>
</section>
.contacts__letter {
  position: sticky;
  top: 0;
  margin: 0;
  padding: 0.5rem 1rem;
  background: #f1f5f9;
}

The "A" header sticks until the end of its group reaches it, then scrolls away as "B" arrives and takes its place.

Why Sticky Doesn't Stick

When sticky fails, it's almost always one of these:

  1. No threshold set. Sticky needs at least one of top, bottom, left, or right. Without it, the element behaves exactly like relative.
  2. An ancestor has overflow set. A sticky element sticks relative to its nearest scrolling ancestor. If any ancestor between it and the page has overflow: hidden, auto, or scroll, that ancestor becomes the scroll container. If that ancestor doesn't actually scroll, the element never appears to stick. Use overflow: clip instead of hidden when you only need to clip content, since clip doesn't create a scroll container.
  3. The parent is too short. Sticky only works within the parent's bounds. If the parent is the same height as the sticky element, there's no room to stick. This often happens in flex or grid layouts, where a sidebar is stretched to match the height of the row. Adding align-self: start to the sticky item prevents it from stretching.
  4. Wrong axis. top only applies to vertical scrolling. A horizontally scrolling container needs left or right.

Here's a sticky sidebar that avoids the grid trap:

.layout {
  display: grid;
  grid-template-columns: 16rem 1fr;
  gap: 2rem;
}

.layout__sidebar {
  position: sticky;
  top: 1.5rem;
  align-self: start;
}

Without align-self: start, the sidebar would stretch to the full height of the grid row, leaving it no room to move within its grid area.

Positioning and z-index

z-index only affects positioned elements (anything other than static), plus flex and grid items. When positioned elements overlap, a higher z-index sits on top, but only compared with elements in the same stacking context.

A new stacking context is created by, among other things, a positioned element with a z-index other than auto, a fixed or sticky element, or any element with opacity below 1, a transform, or a filter. Children can never escape their parent's stacking context, which is why a z-index: 9999 dropdown can still appear behind a sibling section. When z-index seems to be ignored, look for the stacking context the element is trapped in.

A small set of named layers keeps things manageable:

:root {
  --z-dropdown: 10;
  --z-sticky: 20;
  --z-overlay: 30;
  --z-modal: 40;
  --z-toast: 50;
}

A Look Ahead: Anchor Positioning

A classic use of absolute positioning is placing a tooltip or menu next to a trigger, which traditionally required JavaScript to measure positions. CSS anchor positioning lets you tether a positioned element to any other element:

.help-button {
  anchor-name: --help;
}

.help-tooltip {
  position: absolute;
  position-anchor: --help;
  position-area: top;
  margin-block-end: 0.5rem;
}

Anchor positioning is supported in Chromium-based browsers and has been arriving in other engines more recently. Check caniuse before relying on it, and keep a simple fallback, such as positioning the tooltip relative to a wrapper, for browsers without support:

@supports not (anchor-name: --a) {
  .help-wrapper {
    position: relative;
  }

  .help-tooltip {
    bottom: 100%;
    left: 0;
  }
}

Conclusion

Every positioning value comes down to two answers: does it stay in flow, and what are its offsets measured from? Static ignores offsets. Relative stays in flow and nudges from its own spot, and creates a containing block for its children. Absolute leaves the flow and positions from the nearest positioned ancestor. Fixed positions from the viewport, unless a transformed ancestor hijacks it. Sticky stays in flow until a threshold, then sticks within its parent and nearest scroll container.

When something ends up in the wrong place, check the containing block first, then the ancestors' overflow and transform values, then the stacking context. With those three checks, you can untangle almost any positioning bug in minutes.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading