The position property

Normally every box sits in the normal flow: block boxes stack from top to bottom, inline content runs in lines, and each box pushes the next one along. The position property changes how an element takes part in that flow and lets you move it with the offset properties top, right, bottom and left.

Value Keeps its space in the flow? Offsets are measured from Typical use
static (default) Yes Offsets are ignored Everything, by default
relative Yes Its own normal position Small nudges; anchor for absolute children
absolute No The containing block (nearest positioned ancestor) Badges, overlays, icons on a card
fixed No The viewport Bars that stay put while the page scrolls
sticky Yes The edges of its scroll container Headings and table headers that stick while scrolling

Any value other than static makes the element positioned. That word matters below: only positioned elements react to offsets, and they are what absolutely positioned descendants are placed against. The shorthand inset sets all four offsets at once, in the same top-right-bottom-left order as margin: inset: 0 means top: 0; right: 0; bottom: 0; left: 0.

Relative positioning

Relative positioning (position: relative) shifts an element from where it would normally be, while the space it would have taken stays reserved. The other elements do not move. top: 1rem pushes it down by 1rem (away from the top), left: 2rem pushes it to the right:

<div class="box">1. Static</div>
<div class="box moved">2. Relative: top 1rem, left 2rem</div>
<div class="box">3. Static</div>
.box {
  width: 18rem;
  margin-bottom: 0.5rem;
  padding: 0.5rem;
  border: 1px solid #555;
  background: #f2f2f2;
}

.moved {
  position: relative;
  top: 1rem;
  left: 2rem;
  background: #fde9c9;
}

Box 3 stays exactly where it would have been, so box 2 now overlaps it, and box 2 is drawn on top: positioned elements are painted after ordinary ones. Box 2 also leaves a gap where it used to be.

Because it moves things without reflowing the page, relative positioning is rarely used for layout. Its main job is a different one: an element with position: relative and no offsets stays exactly where it is, but becomes the reference point for its absolutely positioned children.

Absolute positioning

Absolute positioning (position: absolute) removes an element from the normal flow, so it takes no space and the rest of the content closes up as if it were not there. The element is then placed with its offsets against its containing block. Without any offsets it stays roughly where it would have been, but floats above the content.

The containing block

The containing block is the rectangle that an element's position and percentage sizes are calculated from. For normal elements it is the content box of the parent. For an absolutely positioned element it is the padding box of the nearest ancestor that is positioned (any position other than static). If there is no such ancestor, it is the initial containing block: a rectangle the size of the viewport at the top of the document.

A page contains a card with position relative. Inside the card, a badge with position absolute is placed 0.5rem from the top edge and 0.5rem from the right edge of the card, which is its containing block. page (initial containing block) .card position: relative containing block of the badge .badge top right position: absolute
The badge's offsets are measured from the edges of the card, because the card is its nearest positioned ancestor. Without position: relative on the card, they would be measured from the page.

The Kestrel tariff cards show a "New" badge in their top-right corner. The first card has position: relative; the second does not:

<article class="card anchored">
  <h3>Green Fixed</h3>
  <p>This card has position: relative, so the badge sits in its corner.</p>
  <span class="badge">New</span>
</article>

<article class="card">
  <h3>Green Flex</h3>
  <p>No positioned ancestor here, so the badge flies to the corner of the page.</p>
  <span class="badge">New</span>
</article>
.card {
  width: 16rem;
  margin-bottom: 1rem;
  padding: 0.75rem;
  border: 1px solid #555;
}

.card h3 {
  margin: 0 0 0.5rem;
}

.card p {
  margin: 0;
}

.anchored {
  position: relative;   /* the badge is positioned against this card */
}

.badge {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  padding: 0.125rem 0.5rem;
  background: #1f4e79;
  color: white;
  font-size: 0.875rem;
}

The second badge ends up in the top-right corner of the whole frame, because the browser walked up the tree, found no positioned ancestor, and used the initial containing block. Whenever an absolutely positioned element appears in a strange place, check its ancestors for a missing position: relative first.

Overlays with inset: 0

inset: 0 on an absolutely positioned element stretches it over the whole containing block. That is the standard way to lay a loading message or a gradient over a card or map:

<div class="map">
  <p>Outage map of Den Haag</p>
  <div class="loading" role="status">Loading outages…</div>
</div>
.map {
  position: relative;
  height: 8rem;
  padding: 0.5rem;
  border: 1px solid #555;
  background: #e8f0e8;
}

.map p {
  margin: 0;
}

.loading {
  position: absolute;
  inset: 0;                 /* cover the whole map */
  display: grid;
  place-items: center;
  background: rgb(255 255 255 / 0.85);
  font-weight: bold;
}

role="status" makes screen readers announce the text when it changes, so the overlay is not purely visual. A real loading overlay is removed or updated with JavaScript once the data has arrived (see The DOM and Events).

Fixed positioning

Fixed positioning (position: fixed) works like absolute positioning, but the element is placed against the viewport, so it stays in the same place on screen while the page scrolls. Cookie bars, chat buttons and "back to top" links use it. Scroll inside the result to see the maintenance notice stay at the bottom:

<h1>Planned maintenance</h1>
<p>Kestrel Energy carries out maintenance on the customer portal at night, when the fewest customers are online.</p>
<p>During the work you cannot log in, submit meter readings or view invoices. Outage reports by phone still work.</p>
<p>Meter readings you submitted before the maintenance are saved and processed afterward.</p>
<p>Questions? Call the service desk, or check this page for updates.</p>
<p class="notice">Tonight 22:00–23:00: portal unavailable.</p>
body {
  padding-bottom: 4rem;   /* room so the last text is not hidden */
}

.notice {
  position: fixed;
  right: 0;
  bottom: 0;
  left: 0;
  margin: 0;
  padding: 0.75rem 1rem;
  background: #1f4e79;
  color: white;
}

A fixed element takes no space, so it covers whatever scrolls underneath it. The padding-bottom on the body makes sure the last paragraph can still scroll into view above the bar.

Fixed bars and keyboard focus

A fixed header or footer can also cover the element that has keyboard focus: a keyboard user tabs to a link, the browser scrolls it into view, and it lands right under the header. WCAG 2.2 success criterion 2.4.11 Focus Not Obscured (Minimum), level AA, requires that the focused element is not entirely hidden by content the author created. The usual fix is scroll-padding on the scrolling element, which tells the browser to keep that much room free when it scrolls something into view:

html {
  scroll-padding-top: 5rem;      /* height of the fixed header, plus a margin */
  scroll-padding-bottom: 4rem;   /* height of the fixed notice bar */
}

The same setting also helps in-page links (#section) land below the header instead of under it. See Accessibility Basics for focus in general.

Sticky positioning

Sticky positioning (position: sticky) is a mix of relative and fixed. The element scrolls with the page as normal until it reaches the offset you set, for example top: 0; from then on it sticks to that edge of its scroll container. It keeps its place in the flow, and it stops sticking when the end of its parent element scrolls past. Scroll the list below: each region heading sticks until the next region pushes it away.

<div class="list" role="region" aria-label="Outages by region" tabindex="0">
  <section>
    <h3>Den Haag</h3>
    <ul>
      <li>Laakkwartier: no power since 13:20</li>
      <li>Scheveningen: low voltage</li>
      <li>Ypenburg: planned work until 16:00</li>
      <li>Centrum: street lights out</li>
    </ul>
  </section>
  <section>
    <h3>Delft</h3>
    <ul>
      <li>Tanthof: no power since 12:55</li>
      <li>Voorhof: restored 14:02</li>
      <li>Wippolder: crew on site</li>
    </ul>
  </section>
  <section>
    <h3>Leiden</h3>
    <ul>
      <li>Stevenshof: low voltage</li>
      <li>Merenwijk: planned work until 18:00</li>
      <li>Binnenstad: restored 13:40</li>
    </ul>
  </section>
</div>
.list {
  height: 11rem;
  overflow-y: auto;          /* the scroll container */
  border: 1px solid #555;
}

.list h3 {
  position: sticky;
  top: 0;                    /* stick to the top of .list */
  margin: 0;
  padding: 0.25rem 0.75rem;
  background: #e6e6e6;       /* opaque, so the list scrolls underneath */
}

.list ul {
  margin: 0;
  padding: 0.5rem 0.75rem 0.5rem 2rem;
}

The tabindex="0" and the label make the scrolling box reachable and scrollable with the keyboard, and announce what it contains.

When sticky does not stick

  • No offset. Without top, bottom, left or right, a sticky element behaves like a relative one.
  • An ancestor with overflow. A sticky element sticks inside its nearest ancestor that has overflow set to hidden, auto or scroll. If that ancestor does not actually scroll (a wrapper with overflow: hidden to clip something), there is nothing to stick to.
  • The parent is too short. A sticky element never leaves its parent. If the parent is only as tall as the sticky element itself, it has no room to stick in.
  • Stretched in a grid or flex container. A sticky sidebar in a grid is stretched to the full row height by default, so it is as tall as its parent. Add align-self: start to give it its own height.

z-index and painting order

When boxes overlap, the browser has to decide which one to draw on top. Without any instructions it follows a fixed painting order: normal (non-positioned) content first, then positioned elements, and among those, later elements in the HTML on top of earlier ones.

z-index overrides that order along the "z axis" pointing out of the screen. It takes an integer (negative values are allowed); a higher number is drawn in front of a lower one. It only has an effect on positioned elements and on flex and grid items; on a plain static block it does nothing.

<div class="stage">
  <div class="sq a">A · z-index: 3</div>
  <div class="sq b">B · z-index: 1</div>
  <div class="sq c">C · z-index: 2</div>
</div>
.stage {
  position: relative;
  height: 9rem;
}

.sq {
  position: absolute;
  width: 9rem;
  height: 5rem;
  padding: 0.5rem;
  border: 1px solid #333;
}

.a { top: 0;      left: 0;    z-index: 3; background: #dbe8f5; }
.b { top: 1.5rem; left: 4rem; z-index: 1; background: #f9e0c7; }
.c { top: 3rem;   left: 8rem; z-index: 2; background: #dcefd9; }

A is in front of B and C, and C is in front of B, even though B and C come later in the HTML. Remove the three z-index declarations and the order flips to the source order: C on top, then B, then A.

Stacking contexts

z-index is not one global ranking for the whole page. A stacking context is a group of elements that is stacked as one unit: inside the group, children are ordered by their own z-index, but the group as a whole takes a single place in its parent context. A child's z-index is only compared with its siblings inside the same context; it can never lift the child above something the whole group is below.

The root element (<html>) forms the first stacking context. An element creates a new one when, among other things:

  • it is positioned (relative or absolute) and has a z-index other than auto;
  • it is fixed or sticky;
  • it is a flex or grid item with a z-index;
  • it has opacity below 1, or a transform or filter other than none;
  • it has isolation: isolate.
The root stacking context contains two children: the header with z-index 1 and the banner with z-index 2. The header forms its own stacking context, which contains the menu with z-index 9999. Because the header context as a whole sits at level 1, the menu is drawn below the banner at level 2. root stacking context (html) header · z-index: 1 its own stacking context menu · z-index: 9999 only ranked inside header banner · z-index: 2 drawn in front In the root context: 1 (header + menu) 2 (banner) → on top
The menu's 9999 is compared only with other children of the header. In the root context the header group sits at 1, so everything in it, the menu included, is drawn below the banner at 2.

This is the situation behind the familiar complaint "I set z-index: 9999 and it is still underneath". The dropdown menu in the Kestrel portal header falls behind a banner further down the page:

<header class="site-header">
  Kestrel Energy
  <div class="menu">Menu · z-index: 9999</div>
</header>
<div class="banner">Banner · z-index: 2</div>
.site-header {
  position: relative;
  z-index: 1;               /* creates a stacking context */
  height: 3rem;
  padding: 0.5rem;
  background: #e3ecf7;
}

.menu {
  position: absolute;
  top: 2.5rem;
  left: 1rem;
  z-index: 9999;            /* only counts inside .site-header */
  width: 12rem;
  height: 3rem;
  padding: 0.5rem;
  border: 1px solid #333;
  background: white;
}

.banner {
  position: relative;
  z-index: 2;               /* beats the whole header (1) */
  padding: 1rem;
  background: #f9e0c7;
}

The lower part of the menu disappears behind the banner. Raising the menu's number changes nothing. The fixes work at the level of the stacking contexts: give the header a higher z-index than the banner (for example 3), or remove the z-index from the banner so it no longer competes.

Dialogs, popovers and the top layer

Modal dialogs, menus and tooltips used to be built entirely with position: fixed and ever larger z-index values. HTML now has a better place for them: the top layer, a layer the browser draws above the whole document, outside every stacking context.

  • A <dialog> opened as a modal (with the JavaScript method showModal()) goes into the top layer. The browser also makes the rest of the page inert, moves focus into the dialog and closes it with Escape, which is hard to get right by hand. <dialog> has been widely available since 2024.
  • The popover attribute turns any element into a popover that a button can open without JavaScript. It also uses the top layer and closes when the user clicks elsewhere or presses Escape. Popover has been Baseline newly available since January 2025.

Placing such a popover next to the button that opened it is the job of anchor positioning (anchor-name, position-anchor, the anchor() function). As of October 2026 its core has worked in Chrome, Edge, Firefox and Safari since early 2026, but some parts are still missing in some browsers, so the feature as a whole does not yet carry a Baseline badge. Test it, and keep a simple fallback position.