Positioning and Stacking
Taking elements out of the normal flow: static, relative, absolute, fixed and sticky positioning, offsets, z-index, and stacking contexts.
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.
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,leftorright, a sticky element behaves like a relative one. -
An ancestor with
overflow. A sticky element sticks inside its nearest ancestor that hasoverflowset tohidden,autoorscroll. If that ancestor does not actually scroll (a wrapper withoverflow: hiddento 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: startto 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 (
relativeorabsolute) and has az-indexother thanauto; - it is
fixedorsticky; - it is a flex or grid item with a
z-index; -
it has
opacitybelow 1, or atransformorfilterother thannone; - it has
isolation: isolate.
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 methodshowModal()) 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
popoverattribute 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.