Position and stacking

Place an element out of flow on purpose, and understand why z-index sometimes does nothing.

12of 26
≈ 12Min read
9Parts

Five values

ValueIn flow?Offsets are relative to
staticYesNothing — top/left are ignored. The default.
relativeYes — the space is keptWhere it would have been.
absoluteNoThe nearest positioned ancestor.
fixedNoThe viewport.
stickyYesIts scroll container, between two thresholds.

relative

position: relative does two useful things, and people usually want the second one: it lets you nudge an element without disturbing anything around it, and it makes the element a containing block for absolutely positioned children.

the usual reason
.card {
  position: relative;   /* so the badge can be positioned against THIS */
}

.card .badge {
  position: absolute;
  top: 8px;
  right: 8px;
}

absolute

An absolutely positioned element is removed from flow: nothing reserves space for it, and it cannot push anything. It is placed against its containing block — the padding box of the nearest ancestor that is itself positioned (not static).

If no ancestor is positioned, it goes all the way up to the initial containing block, which is the size of the viewport. That is why a badge sometimes ends up in the top corner of the page instead of the card.

inset is the shorthand
.overlay {
  position: absolute;
  inset: 0;                    /* top/right/bottom/left: 0 */
}

.overlay-inset {
  position: absolute;
  inset: 1rem 2rem;            /* block, inline */
}
Two opposite offsets size the element

Setting both left: 0 and right: 0 stretches it to the container's width — you do not need width: 100%, and it keeps working when the container has padding.

fixed

Fixed to the viewport: it does not move when the page scrolls. The usual uses are a header, a cookie banner and a floating action button.

The exception that surprises everyone: a transform, filter, backdrop-filter, perspective, contain or will-change on any ancestor makes that ancestor the containing block instead — and the fixed element scrolls with it. A transform: translateZ(0) added for performance is the usual culprit.

sticky

sticky is in flow until it reaches a threshold, then it stops there while its parent scrolls past. It is the one that needs the most care to get right.

  • It must have a threshold: at least one of top, bottom, left or right.
  • It sticks within its parent, and it stops when the parent scrolls out. A sticky sidebar inside a short container stops almost immediately — that is usually the bug.
  • Any ancestor with overflow: hidden, auto or scroll becomes the scroll container, and sticky then behaves relative to that. An overflow: hidden several levels up is the commonest reason sticky "does not work".
  • Add scroll-margin-top to anchor targets so a sticky header does not cover them.
a table header that stays
thead th {
  position: sticky;
  top: 0;
  background: #fff;   /* it is transparent by default, and content shows through */
}

z-index and stacking contexts

z-index only applies to positioned elements (and to flex/grid items). It orders siblings within the same stacking context — it cannot lift an element out of its parent's layer.

That is why z-index: 9999 sometimes does nothing: the element is inside a stacking context that sits below the thing you are trying to cover.

  • A stacking context is created by: the root element; position other than static with a z-index other than auto; opacity less than 1; transform, filter, backdrop-filter, perspective, mix-blend-mode; isolation: isolate; will-change on any of those; a contain: paint.
  • isolation: isolate creates one deliberately, with no other side effects — it is the clean way to stop a component's z-indexes leaking into the page.
the fix is usually to move the element, not to raise the number
.component {
  isolation: isolate;   /* z-indexes inside are now sealed in */
}

.modal {
  position: fixed;
  inset: 0;
  z-index: 100;         /* only has to beat its siblings */
}

The top layer

<dialog> opened with showModal(), and an element with popover, are rendered in the top layer — above every stacking context on the page, whatever the z-indexes say. It is the only way to be certain something is on top, and it is another reason to prefer the native elements.

Centring an absolute element

two ways
/* known size not required */
.centre {
  position: absolute;
  inset: 0;
  margin: auto;
  width: fit-content;
  height: fit-content;
}

/* the classic */
.centre-t {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;    /* percentages of its OWN size */
}

When not to use it

Absolute positioning takes the element out of flow, so nothing around it can respond to its size. If the thing you are placing is part of the layout — a sidebar, a column, a card in a deck — grid or flex will do it better and stay responsive.

Keep absolute for genuine overlays: a badge on a card, a caption over an image, a dropdown, a tooltip.

Practice