Skip to content

Positioning and z-index

docs.scrimba.com

You want a small "Sale" badge to sit on the top-right corner of a product photo. You want a header that stays put while the page scrolls under it. You want a dialog that floats above everything else on the page. Ordinary CSS spacing cannot do any of this, because it only ever arranges elements one after another. Positioning is the set of tools that lets an element step out of that ordinary arrangement and sit exactly where you point it, even on top of something else.

The position property

Every element starts with static positioning, which is a plain way of saying "sit in your normal spot in the flow of the page". That is the default, and most of the time it is what you want. Positioning starts to matter when you want an element to move away from that normal spot.

The first step up is position: relative. It nudges an element from where it would normally sit, using four offset properties: top, right, bottom, and left. The key thing is that the element keeps its original space. Nothing else moves to fill the gap.

css
.badge {
  position: relative;
  top: 10px;                 /* pushed 10px down from its normal spot */
  left: 20px;                /* pushed 20px right from its normal spot */
}

Those top and left offsets do nothing at all on a static element. They only start working once you give the element a position other than static.

Everything on the page begins with position: static, its place in the normal top-to-bottom flow you met in display and document flow. Static elements ignore the offset properties completely, so setting top or left on them does nothing. This catches people out: they add top: 20px, see no change, and assume the value is wrong when the real problem is a missing position.

The gentle option is relative positioning. It shifts an element from its normal spot by the offsets you give, while leaving its original space reserved in the flow. The neighbours do not move to close the gap, so a relatively positioned element can end up overlapping whatever sits next to it.

css
.tooltip {
  position: relative;
  top: -4px;                 /* lifted 4px up from where it would sit */
  left: 8px;                 /* nudged 8px to the right */
  /* the reserved space stays put; only the paint moves */
}

Offsets like top and left only apply once an element is positioned. On its own, relative is a small nudge tool. Its more important job is setting up a reference point for absolutely positioned children, which is the next section.

The position property has five values, and the whole model is easier to hold if you split them by one question: does the element still take up space in normal flow? static (the initial value) and relative both do; absolute and fixed do not; sticky is a hybrid that switches between the two behaviours as you scroll.

relative is the mildest departure from static. The element is laid out in normal flow exactly as if it were static, its space is reserved at that original location, and then it is visually offset from that position by top / right / bottom / left. Because the reserved space never moves, offsetting a relative element only changes where it paints, not where the layout thinks it is, so it can overlap adjacent content without pushing anything around.

css
.list-item {
  position: relative;
  top: 6px;                  /* painted 6px lower; its flow slot stays put */
}

Two precision points. First, the offsets resolve against the element's own normal position, not against any ancestor: top: 10px means "10px below where you would otherwise be", not "10px from the top of the container". Second, if you set opposing offsets (top and bottom, or left and right) on a relative element, the start-side wins in a left-to-right, top-to-bottom writing mode, so top overrides bottom and left overrides right. The far more common reason to reach for relative is not the nudge at all; it is to establish a containing block for absolute descendants, which the next section builds on.

JunoThe position property Everything starts as static, meaning it sits in its normal spot. Switch to position: relative and you can nudge it with top, right, bottom, and left, while its original space stays reserved. Those offset properties do nothing until the element is positioned, so if top seems ignored, check that you set a position first.
JunoThe position propertystatic is the default and ignores offsets entirely, which is why top: 20px on a plain element does nothing. relative nudges the element from its normal spot while keeping its space reserved, so neighbours never shift to fill the gap. The nudge is handy, but the real reason you reach for relative is to anchor absolutely positioned children, coming up next.
JunoThe position property Sort the five values by whether they keep their space in flow: static and relative do, absolute and fixed do not, sticky flips between the two. relative offsets resolve against the element's own normal position, not its container, and opposing offsets let the start side win. You will use relative far more as an anchor for absolute children than as a nudge tool.

Absolute positioning

position: absolute is a bigger move. It lifts the element clean out of the normal flow, so it no longer takes up any space, and the elements around it close up as if it were never there. Then you place it using the same top, right, bottom, and left offsets.

The question is: offset from what? An absolute element positions itself against its nearest positioned ancestor, meaning the closest parent that has a position of its own (anything other than static). This is why the badge-on-a-card pattern always comes in a pair: you make the card relative so it becomes the reference point, then make the badge absolute so it can sit in the card's corner.

css
.card {
  position: relative;        /* becomes the reference point */
}

.card .badge {
  position: absolute;
  top: 8px;                  /* 8px from the top of the card */
  right: 8px;                /* 8px from the right of the card */
}

Without position: relative on the card, the badge would search further up the page for a positioned ancestor and land somewhere you did not intend.

Absolute positioning removes an element from normal flow entirely. It takes up no space, its former neighbours collapse together as though it were gone, and you then place it with the offset properties. The offsets are measured against the element's containing block: the padding edge of its nearest ancestor that is itself positioned.

That "nearest positioned ancestor" rule is the heart of the pattern you will use constantly. Set the parent to position: relative (which by itself changes nothing visible), and it becomes the reference frame for any absolute children inside it.

css
.dropdown {
  position: relative;        /* the reference frame, no visual change */
}

.dropdown .menu {
  position: absolute;
  top: 100%;                 /* directly below the trigger */
  left: 0;                   /* aligned to its left edge */
  width: 200px;
}

If no ancestor is positioned, the absolute element falls back to the initial containing block, roughly the viewport, and typically ends up in the top-left of the page. A misplaced absolute element is almost always a missing position: relative on the parent you meant to anchor it to.

An absolutely positioned element is taken out of normal flow: it generates no box in the flow, contributes nothing to its parent's height, and its siblings lay out as if it did not exist. It is then positioned relative to its containing block, and getting the containing-block rules exact is what separates "it works" from "it works and I know why".

For an absolute element, the containing block is the padding box of its nearest ancestor whose position is not static. If several ancestors qualify, the nearest one wins. If none qualify, the containing block is the initial containing block, a viewport-sized rectangle anchored to the document, which is why an unanchored absolute element drifts to the page's top-left.

css
.field {
  position: relative;        /* establishes the containing block */
  padding: 12px;
}

.field .clear-button {
  position: absolute;
  top: 50%;
  right: 8px;
  transform: translateY(-50%);   /* vertical centre against .field */
  /* offsets measure from .field's padding box, not its content box */
}

One rule that surprises people and matters for the advanced containing-block picture: a transform, filter, or will-change on an ancestor also establishes a containing block for absolute descendants, even without a position set. So an absolute child can suddenly reparent its offsets onto a transformed grandparent you did not think of as an anchor. When an absolute element positions against the "wrong" element, check for a stray transform up the tree, not only for a position.

JunoAbsolute positioningposition: absolute pulls an element out of the flow, so it takes up no space and neighbours close in behind it. It then positions against its nearest parent that has a position set. That is why the badge-on-a-card trick is a pair: relative on the card, absolute on the badge, so the badge lands in the card's corner and not somewhere random.
JunoAbsolute positioning An absolute element leaves the flow completely and positions against its containing block, the nearest ancestor that is itself positioned. The everyday pattern is position: relative on the parent, which changes nothing visibly but becomes the reference frame, then absolute on the child. When an absolute element lands in the top-left corner of the page, it means no ancestor was positioned and it fell back to the viewport.
JunoAbsolute positioning The containing block for an absolute element is the padding box of its nearest non-static ancestor, or the viewport if none exists. The trap is that transform, filter, and will-change also create a containing block with no position in sight, so a child can anchor to a transformed grandparent you forgot about. When offsets land against the wrong element, scan the tree for a stray transform, not only a missing position.

Fixed and sticky

position: fixed pins an element to the screen itself. It leaves the flow like absolute does, but instead of anchoring to a parent, it anchors to the viewport, the visible window. Once fixed, it stays in the same place on screen even as you scroll the page. This is how a navigation bar or a "back to top" button stays visible the whole time.

css
.site-header {
  position: fixed;
  top: 0;                    /* pinned to the top of the window */
  left: 0;
  right: 0;                  /* stretched across the full width */
}

position: sticky is the friendly middle ground. A sticky element scrolls along with the page normally, until it reaches a threshold you set, and then it sticks in place. A section heading that scrolls up with its content and then holds at the top of the screen is the classic example.

css
.section-title {
  position: sticky;
  top: 0;                    /* sticks once it reaches the top edge */
}

Fixed positioning works like absolute, except the containing block is always the viewport rather than an ancestor. A fixed element is removed from flow and then pinned to a fixed spot on screen, so it does not move when the page scrolls. That makes it the tool for persistent chrome: a top bar, a cookie banner, a floating action button.

css
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 64px;
  /* content underneath needs padding-top: 64px so it isn't hidden */
}

That comment points at the main gotcha. Because a fixed header is out of flow, it overlaps the content beneath it, so you usually add matching padding or margin to the page body to push content clear.

Sticky positioning is a hybrid: the element behaves as relative while it is within view, then switches to fixed-like behaviour once it hits the threshold you set with top, bottom, left, or right. A sticky element without one of those threshold offsets never sticks, because it has no line to stick at.

css
.section-title {
  position: sticky;
  top: 16px;                 /* holds 16px from the top once it reaches there */
}

fixed is absolute with one difference that matters: its containing block is the viewport, so it is pinned to the screen and does not scroll with the document. That makes it right for persistent UI, with two caveats. It overlaps in-flow content (reserve space with padding on the body), and the same transform-establishes-a-containing-block rule from absolute applies here too, so an ancestor transform will trap a fixed element inside that ancestor and it will scroll with it. A "fixed" element that mysteriously scrolls is almost always sitting under a transformed ancestor.

Sticky positioning is the most conditional of the five. A sticky element is laid out in normal flow, keeping its space, and stays there until its nearest scrolling ancestor scrolls it to the threshold you declared; from that point it is held at the threshold until its containing block scrolls past, at which point it releases. Two requirements are the ones people miss:

css
.toc {
  position: sticky;
  top: 24px;                 /* the threshold; without an offset it never sticks */
  /* sticks only while .toc's parent is still on screen */
}

First, sticky needs a threshold offset (top, bottom, left, or right); with none, there is no line to catch on and it behaves like a plain relative element. Second, it can only stick within the bounds of its parent, so if the parent is short, the element unsticks as soon as the parent scrolls out of view. And a frequent silent failure: an ancestor with overflow: hidden, scroll, or auto becomes the sticky element's scroll container, so a sticky item can quietly do nothing because it is confined to a container that is not the one you expected to scroll.

JunoFixed and stickyposition: fixed pins an element to the screen, so it stays put as you scroll, which is how a header or a back-to-top button hangs around. position: sticky is the middle ground: it scrolls with the page, then sticks once it reaches a threshold like top: 0. Fixed also overlaps whatever is below it, so leave some room for the content it covers.
JunoFixed and stickyfixed is absolute anchored to the viewport, so it stays on screen while the page scrolls, at the cost of overlapping in-flow content unless you pad around it. sticky behaves like relative until it hits its threshold offset, then holds in place. A sticky element with no top or bottom set never sticks, because there is no line for it to catch on.
JunoFixed and stickyfixed pins to the viewport, but an ancestor transform traps it inside that ancestor and it will scroll, so a scrolling "fixed" element usually has a transformed parent. sticky needs two things people forget: a threshold offset and a tall enough parent, since it releases the moment its parent scrolls off. And watch for an ancestor with overflow set, which becomes the scroll container and can silently stop sticky from working.

Stacking with z-index

When elements overlap, something has to decide which one sits on top. That is what z-index controls. A higher z-index draws in front of a lower one, so a dropdown with z-index: 10 covers a card with z-index: 1.

There is one rule that trips everyone up: z-index only works on positioned elements. If an element is still static, its z-index is ignored. So you almost always set a position and a z-index together.

css
.modal {
  position: fixed;
  z-index: 100;              /* sits above normal page content */
}

.modal-overlay {
  position: fixed;
  z-index: 99;               /* the dark backdrop, one step behind the modal */
}

You rarely need huge numbers here. A small, tidy scale like 1, 10, 100 is easier to reason about than a page full of 9999s fighting each other.

When two elements overlap, z-index sets their front-to-back order: higher numbers paint on top of lower ones. The catch is that z-index only affects positioned elements. Give a static element a z-index and nothing happens, so z-index and position almost always travel together.

css
.modal {
  position: fixed;
  inset: 0;
  z-index: 100;
}

.tooltip {
  position: absolute;
  z-index: 50;               /* above cards, below the modal */
}

The habit worth forming early is a small, deliberate scale rather than escalating numbers. When you find yourself writing z-index: 9999 to beat something, the real fix is usually a plan for your stacking values, not a bigger number. The next level explains why a bigger number sometimes loses anyway.

z-index orders overlapping positioned elements along the z-axis, higher in front, and it is ignored on static elements. The part that turns a five-minute bug into an hour is the stacking context: a self-contained group within which z-index values are compared, and which stacks as a single unit against its siblings.

A stacking context forms whenever an element is positioned with a z-index other than auto, but also from opacity below 1, any transform, filter, will-change, isolation: isolate, and a handful of others. Crucially, z-index is only compared within the same stacking context. Once an element sits inside a context, its z-index cannot lift it above anything outside that context, no matter how large the number.

css
.card {
  opacity: 0.98;             /* this alone creates a stacking context */
}

.card .badge {
  position: absolute;
  z-index: 9999;             /* trapped inside .card's context */
}

.sibling-panel {
  position: relative;
  z-index: 2;                /* still paints above .badge */
}

Here .badge at 9999 loses to .sibling-panel at 2, because .badge is confined to the context that .card's opacity created, and that whole context ranks below .sibling-panel. When a high z-index refuses to win, the answer is almost never a higher number; it is an ancestor that quietly opened a stacking context (often an opacity or a transform), and the fix is to raise or isolate that ancestor instead.

A closing point on judgement: positioning and z-index are for overlays, badges, and pinned chrome, the things that need to escape the flow or sit on top. They are the wrong tool for arranging a page's main structure. For rows, columns, and grids that should respond to their content and the viewport, reach for flexbox or the box-model-driven flow in the box model, and keep positioning for the layer that floats above them.

JunoStacking with z-index When elements overlap, z-index decides which one is in front: a higher number wins. It only works on positioned elements, so a z-index on a plain static element is ignored, and you set position and z-index together. Keep the numbers small and tidy, like 1, 10, 100, instead of a pile of 9999s.
JunoStacking with z-indexz-index sets front-to-back order for positioned elements, and it does nothing on static ones, so it rides along with position. Reach for a small deliberate scale rather than climbing numbers; if you are writing 9999 to win, you need a plan, not a bigger value. And sometimes a bigger value loses anyway, which the deep dive on stacking contexts explains.
JunoStacking with z-indexz-index is only compared inside a stacking context, and things like opacity below 1, transform, and filter quietly create one. That is why a child at z-index: 9999 can still lose to an outside element at 2: it is trapped in its ancestor's context. When a high number will not win, hunt for the ancestor that opened a context and raise that, and keep positioning for overlays rather than page layout.