Lesson 14 of 25

CSS Positioning

Five Values, and What "Positioned" Means

Normal flow — blocks stacking downwards, inline content running along a line — handles the overwhelming majority of a page, and flexbox and grid handle most of the rest. The position property is for the small number of cases where an element has to sit somewhere flow cannot put it: a badge on the corner of a card, a dropdown under a button, a header that stays visible while you scroll.

It takes five values. static is the default and means "stay in normal flow". The other four — relative, absolute, fixed and sticky — all make the element positioned, and that word matters because two other things in CSS depend on it. The offset properties top, right, bottom and left only do something on a positioned element, and so does z-index. Writing top: 20px on a static element has no effect at all, silently, which is a very common first hour of confusion.

A second distinction runs through the whole lesson: whether the element still occupies space. relative and sticky keep their place in the flow. absolute and fixed are taken out of it entirely, so everything else on the page lays out as though they were never there.

Example
/* Does nothing — the element is still static */
.oops { top: 20px; left: 20px; }

/* Now the offsets apply */
.moved { position: relative; top: 20px; left: 20px; }

/* The five values */
.a { position: static; }    /* default; not positioned */
.b { position: relative; }  /* offset from where it would have been */
.c { position: absolute; }  /* placed against a positioned ancestor */
.d { position: fixed; }     /* placed against the viewport */
.e { position: sticky; top: 0; }  /* flow, then fixed once it hits the edge */
  • static — normal flow; offsets and z-index are ignored
  • relative — shifted visually, but its original space is kept
  • absolute — removed from flow, placed against the nearest positioned ancestor
  • fixed — removed from flow, placed against the viewport, stays put while scrolling
  • sticky — behaves like relative until a scroll threshold, then sticks
  • Only positioned elements respond to top, right, bottom, left and z-index

relative Moves the Paint, Not the Space

position: relative with top: 20px draws the element twenty pixels lower than normal — but the gap it originally occupied stays exactly where it was, and its neighbours do not move up to fill it. The element is displaced visually while the layout carries on as if nothing happened. That is why relative positioning is a poor way to lay anything out: you get a hole in one place and an element overlapping something in another.

Its real job is different, and it is the one you will use constantly: relative positioning turns an element into an anchor for absolutely positioned children. Very often you will write position: relative with no offsets at all, purely so that something inside it can be placed against its edges. A card that needs a "New" badge in its top-right corner is the standard example.

The offsets themselves are also worth reading carefully. top: 20px pushes the element down by twenty pixels — it means "move the top edge away from where it was", not "put the top edge at 20px". Likewise left: 20px pushes it right. Setting both top and bottom on a relative element is a contradiction, and the browser resolves it by obeying top and ignoring bottom.

Example
/* The gap where it used to be stays empty */
.nudged {
  position: relative;
  top: 20px;      /* moves DOWN */
  left: 20px;     /* moves RIGHT */
}

/* The usual real use: an anchor, with no offsets of its own */
.card {
  position: relative;
  padding: 24px;
  border: 1px solid #e3e3ea;
  border-radius: 12px;
}

.card__badge {
  position: absolute;
  top: 12px;
  right: 12px;
  background: #d1039e;
  color: #ffffff;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
}
Notes
  • To nudge something by a couple of pixels — optically aligning an icon with text, for instance — transform: translateY(-1px) is usually better than relative positioning. Transforms are cheaper for the browser to redraw and make it obvious to the next reader that the move is cosmetic.

absolute: Against the Nearest Positioned Ancestor

This is the sentence to memorise: an absolutely positioned element is placed against the padding box of its nearest ancestor that is itself positioned. Not its parent — its nearest positioned ancestor. The browser walks up the tree looking for an element whose position is anything other than static, and uses the first one it finds as the frame of reference.

If it finds none, it falls back to the initial containing block, which is effectively the page itself. That is the cause of the single most common positioning bug in existence: you write position: absolute; top: 12px; right: 12px on a badge expecting it to sit in the corner of its card, you forget position: relative on the card, and the badge flies off to the top-right corner of the whole document. The CSS is not wrong — the anchor is simply missing.

Because an absolute element is removed from normal flow, its parent no longer accounts for it. A card containing only absolutely positioned children collapses to zero height, since it has nothing left in flow to give it size. This is expected behaviour rather than a bug, and it is why absolute positioning is for overlays and decorations rather than for the main structure of a page.

Two more things follow from the same rules. Setting opposite offsets — left: 0 and right: 0 together — stretches the element across the full width of its anchor, which is how you build overlays. And an absolutely positioned element with no offsets at all stays roughly where it would have been in flow, which is occasionally handy but usually a sign you forgot to place it.

Example
/* The classic mistake and the fix */
.card         { /* position: static — no anchor! */ }
.card__badge  { position: absolute; top: 12px; right: 12px; }
/* The badge lands in the corner of the PAGE. */

.card         { position: relative; }   /* <- the one missing line */

/* Stretch across the anchor with opposite offsets */
.photo        { position: relative; }
.photo__caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  color: #ffffff;
  padding: 12px 16px;
}

/* Dead centre of the anchor, whatever the element's size */
.centred {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
Anchor an element with position: relative
HTML
<div class="grid">
  <div class="card anchored">
    <span class="badge">New</span>
    <h4>Anchored card</h4>
    <p>This card has <code>position: relative</code>, so the badge lands in <em>its</em> corner.</p>
  </div>

  <div class="card">
    <span class="badge">Lost</span>
    <h4>No anchor</h4>
    <p>This card is still <code>static</code>. Its badge is positioned against the page instead — scroll to the top right to find it.</p>
  </div>
</div>

<div class="photo">
  <div class="photo__fill">a "photo"</div>
  <div class="photo__caption">left: 0 and right: 0 stretch this caption across</div>
</div>
CSS
body {
  font-family: system-ui, Arial, sans-serif;
  padding: 24px;
  background: #f4f4f7;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 16px;
}

.card {
  background: #ffffff;
  border: 1px solid #e3e3ea;
  border-radius: 12px;
  padding: 20px;
}

/* Remove this line and watch the first badge run away too */
.anchored { position: relative; }

.badge {
  position: absolute;
  top: 12px;
  right: 12px;
  background: #d1039e;
  color: #ffffff;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 12px;
}

.card h4 { margin: 0 0 8px; }
.card p  { margin: 0; color: #555555; font-size: 14px; }

.photo {
  position: relative;
  margin-top: 24px;
  border-radius: 12px;
  overflow: hidden;
}

.photo__fill {
  height: 160px;
  background: linear-gradient(135deg, #d1039e, #5b026b);
  color: #ffffff;
  display: grid;
  place-items: center;
}

.photo__caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  color: #ffffff;
  padding: 12px 16px;
  font-size: 14px;
}
Notes
  • Percentage heights follow a related rule and cause a similar surprise. height: 100% means "all of the parent's height", so it needs a parent with a height the browser can already work out. A parent whose own height comes from its content has nothing definite to give, and the child's percentage is ignored. Either give the ancestors a height — the old html, body { height: 100% } chain — or, far more simply, use min-height: 100vh, or make the parent a flex or grid container where stretching happens by default.

fixed and sticky: Things That Follow the Scroll

position: fixed places an element against the viewport — the visible window — and it stays exactly there while the page scrolls underneath. Floating action buttons, cookie banners and full-screen modal backdrops all use it. It is removed from flow, so remember that a fixed header sits on top of your content rather than pushing it down; the usual companion is some padding-top on the body equal to the header's height.

Fixed positioning has one genuinely surprising failure. If any ancestor has a transform, filter, backdrop-filter or perspective other than none, that ancestor becomes the frame of reference instead of the viewport, and your fixed element scrolls with the page like an absolute one. Nothing in the fixed element's own CSS hints at the cause. If a fixed element inexplicably refuses to stay put, walk up its ancestors looking for a transform — a hover animation on a wrapper is a common culprit.

position: sticky is the hybrid, and it is how you make a table header or a section heading stay visible while its section scrolls past. The element sits in normal flow, keeping its space, until the scroll position reaches the threshold you set — then it behaves like a fixed element until its parent scrolls out of view, and finally it leaves with the parent.

Sticky has two requirements people miss. It does nothing without a threshold, so you must set at least one of top, bottom, left or rightposition: sticky alone is inert. And it can only stick within its own parent, so if the parent is short, the element unsticks almost immediately. The third trap is an ancestor with overflow: hidden, auto or scroll: that creates a different scrolling context and quietly kills the effect. When sticky does not work, that ancestor overflow is the first thing to check.

Example
/* A header that stays in the window */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 64px;
  background: #ffffff;
}
body { padding-top: 64px; }   /* it was removed from flow, so make room */

/* Breaks fixed positioning for everything inside it */
.wrapper { transform: translateY(0); }   /* even a no-op transform does it */

/* Sticky needs a threshold */
.section-heading {
  position: sticky;
  top: 0;               /* without this, nothing happens */
  background: #ffffff;
  padding: 12px 0;
}

/* And a parent that does not clip the scroll */
.scroll-parent { overflow: hidden; }   /* this breaks sticky children */

/* Sticky table headers */
thead th {
  position: sticky;
  top: 0;
  background: #ffffff;
}
  • fixed is relative to the viewport and does not scroll
  • A transform, filter or perspective on any ancestor breaks that and makes it scroll
  • Fixed and absolute elements are out of flow — reserve their space by hand
  • sticky needs at least one offset, or it does nothing at all
  • A sticky element only sticks inside its own parent
  • An ancestor with overflow other than visible disables sticky

z-index and Why 9999 Sometimes Loses

When elements overlap, z-index decides which one is on top — higher numbers in front. Two limits explain nearly every z-index problem people run into.

The first is that z-index only applies to positioned elements (plus flex and grid items, which are a special case). Set z-index: 100 on a static div and it is ignored completely. Adding position: relative with no offsets is usually all that is needed to make it take effect. Without any z-index at all, overlapping elements simply stack in source order — later elements paint over earlier ones.

The second is stacking contexts, and this is what makes z-index feel arbitrary. Certain properties cause an element to create its own self-contained stacking layer, and every descendant's z-index is then compared only inside that layer. If a card creates a stacking context and sits behind a header, then a dropdown inside that card with z-index: 9999 still cannot escape — the whole card is behind the header, and 9999 only ranks the dropdown among its siblings inside the card. Raising the number will never fix it; you have to raise the card, or move the dropdown out of it.

What creates a stacking context, apart from a positioned element with a z-index? An opacity below 1, any transform, filter, will-change, position: fixed or sticky, and isolation: isolate. That first one explains a lot of mysteries: a fade-in animation on a wrapper quietly changes the stacking rules for everything inside it. The practical advice is to keep z-index values small and meaningful — a handful of named steps like 10 for dropdowns, 100 for a sticky header, 1000 for modals — and when something refuses to come forward, look for the ancestor that is trapping it rather than adding another zero.

Example
/* Ignored — the element is static */
.panel { z-index: 100; }

/* Works */
.panel { position: relative; z-index: 100; }

/* A small, deliberate scale beats arbitrary numbers */
:root {
  --z-dropdown: 10;
  --z-sticky:   100;
  --z-modal:    1000;
}
.dropdown     { position: absolute; z-index: var(--z-dropdown); }
.site-header  { position: sticky;   z-index: var(--z-sticky); }
.modal        { position: fixed;    z-index: var(--z-modal); }

/* The trap: the card makes its own stacking context... */
.card { opacity: 0.99; }             /* below 1 — a new context */
.card .dropdown { z-index: 9999; }   /* still cannot escape the card */

/* Deliberately create one, so children cannot leak out */
.widget { isolation: isolate; }
Notes
  • If a modal, dropdown or tooltip has to sit above everything on the page, the most reliable structure is to keep it as a direct child of <body> rather than nested inside a card or a transformed wrapper. Fighting stacking contexts with larger numbers works until it suddenly does not.
Ask AI