HTML & CSS NotesRohit’s interview study guide
Chapter 05

Positioning & Stacking

Where the browser puts a box when you leave it alone (normal flow), how each position value nudges a box or lifts it out of that flow, which box ends up on top and why, plus floats, every way to centre something, and the new anchor positioning.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Normal flow

Added

Normal flow is the layout every element gets until you say otherwise. Block-level boxes (div, p, section, headings) are laid out one after another in the block direction — top to bottom in English — and each one stretches to fill the width of its container. Inline-level boxes (span, a, strong, text, inline-block elements) are laid out side by side in the inline direction, left to right, inside line boxes, and wrap onto a new line when they run out of room.

A good mental model is a word processor. Paragraphs stack down the page, each as wide as the page; inside a paragraph, words flow along a line and wrap. Everything takes up room, and everything pushes the next thing along. Taking an element out of flow — with position: absolute or fixed, or with float — is like peeling a sticky note off the page: it's still visible, but the text closes up behind it as if it were never there.

Normal flow is also what the other layout modes are measured against. Flexbox and grid don't remove anything from flow; they replace the flow of their children with a different algorithm (see The flex model). The position values in this chapter all start from where a box would sit in normal flow and then decide how far to move it, or whether it still takes up space.

Blocks stack, inline boxes flow
CSS
.page { width: 300px; padding: 10px; background: #f1f5f9; }
.block { height: 40px; margin-bottom: 10px; background: #dbeafe; color: #1e3a8a; }
.line { line-height: 30px; }
.chip {
  display: inline-block; vertical-align: top;
  width: 80px; height: 30px;
  background: #fde68a; text-align: center;
}
HTML
<div class="page">
  <div class="block" id="b1">Block 1</div>
  <div class="block" id="b2">Block 2</div>
  <div class="line"><span class="chip" id="c1">A</span><span class="chip" id="c2">B</span><span class="chip" id="c3">C</span><span class="chip" id="c4">D</span></div>
</div>
Resultlive, in your browser
What’s happening
  1. .page is 300px wide with 10px padding. Its children are laid out inside the 300px content box, starting 10px in from its edges.
  2. #b1 and #b2 are blocks with width: auto, so each one fills the available width: both measure exactly 300px. You never told them how wide to be; filling the container is what auto means for a block in normal flow.
  3. The blocks stack in the block direction: #b2 starts 10px below #b1, because of #b1's margin-bottom: 10px. Each block pushes the next one down by its full height plus margins.
  4. Inside .line, the four chips are inline-block: they sit on a line side by side. A, B and C touch (B.left − A.right = 0, since there is no whitespace between them in the markup), using 240 of the 300px.
  5. D would need 80px more (320px in total), so it wraps: it starts a new line box 30px lower (the line height), back at the left edge of the content box.
  6. Everything in flow adds to the parent's height: .page is 10 + 40 + 10 + 40 + 10 + 30 + 30 + 10 = 180px. That's the property that makes normal flow so robust: change any child's size and everything after it moves to make room.
AdvancedBlock direction isn't always "down"

"Block direction" and "inline direction" are used on purpose. They follow the writing mode, and in a vertical writing mode blocks stack sideways. This is why modern CSS has logical properties such as margin-block-end and inline-size (see Logical properties).

Normal flow in a vertical writing mode
CSS
.vertical { writing-mode: vertical-rl; height: 160px; background: #f1f5f9; }
.vblock { block-size: 40px; margin-block-end: 10px; background: #dbeafe; color: #1e3a8a; }
HTML
<div class="vertical">
  <div class="vblock" id="v1">Block 1</div>
  <div class="vblock" id="v2">Block 2</div>
</div>
Resultlive, in your browser
What’s happening
  1. writing-mode: vertical-rl turns the inline direction to top-to-bottom and the block direction to right-to-left.
  2. So the first block sits against the right edge, and the second one stacks to its left, 10px away (margin-block-end is now a left margin).
  3. Each block fills the inline size of the container, which is now its height: both are 160px tall. block-size: 40px became their width.
  4. Same algorithm, rotated. Normal flow is defined in terms of block and inline directions, not "down" and "across".
How tall is the wrapper?
CSS
.wrap { width: 300px; background: #f1f5f9; }
.out { position: absolute; width: 100px; height: 100px; }
.side { float: left; width: 80px; height: 120px; }
.in { height: 60px; }
HTML
<div class="wrap">
  <div class="out"></div>
  <div class="side"></div>
  <div class="in"></div>
</div>
Show answer

60px. Only .in is in normal flow. The absolutely positioned box and the float are both out of flow, so they don't add to the wrapper's height, even though they stick out below it.

CSS
.wrap { width: 300px; background: #f1f5f9; margin-bottom: 70px; }
.out { position: absolute; width: 100px; height: 100px; background: #fca5a5; }
.side { float: left; width: 80px; height: 120px; background: #bbf7d0; }
.in { height: 60px; background: #dbeafe; }
HTML
<div class="wrap">
  <div class="out">out</div>
  <div class="side">side</div>
  <div class="in">in</div>
</div>
Resultlive, in your browser
What’s happening
  1. .out is position: absolute with no offsets, so it stays at its static position (where it would have been: the top-left of .wrap) but takes up no space.
  2. .side is a float. Floats are out of flow for their parent's height too, so it hangs 60px below the wrapper.
  3. .in is the only in-flow child. Its block box starts at the very top of .wrap (block boxes run under floats; only line boxes move aside, see Floats and clearing), and its 60px is the wrapper's whole height.
  4. Painting order decides who's visible where they overlap: the positioned .out paints above the float, and the float paints above the in-flow block's background. elementFromPoint finds .out at the corner, .side just below it, and .in to the right.
  5. To make the wrapper contain the float, give it display: flow-root. Nothing makes it grow for an absolutely positioned child: that's the point of absolute positioning.

#position: static and relative

Added

position: static is the default. A static box is in normal flow and is not positioned: top, right, bottom, left and z-index simply don't apply to it. You rarely write static on purpose, except to undo a position set elsewhere.

position: relative keeps the box in normal flow — it takes up exactly the space it would have anyway — and then shifts the painted box by the offsets, measured from where it would have been. The space it left behind stays reserved; nothing around it moves. Think of a picture on a wall that you slide a little to one side: the nail (its slot in the layout) doesn't move, only the picture does.

Most of the time relative is used with no offsets at all, for its side effects: it makes the element positioned, so it becomes the containing block for absolutely positioned children (see position: absolute and the containing block), and so that z-index applies to it (see z-index and stacking contexts).

Relative moves the painting, not the slot
CSS
.stack { width: 260px; background: #f1f5f9; }
.box { width: 200px; height: 40px; }
#one { position: static; top: 50px; background: #dbeafe; }
#two { position: relative; top: 20px; left: 30px; background: #fca5a5; }
#three { background: #bbf7d0; }
HTML
<div class="stack">
  <div class="box" id="one">one (static)</div>
  <div class="box" id="two">two (relative)</div>
  <div class="box" id="three">three</div>
</div>
Resultlive, in your browser
What’s happening
  1. #one has top: 50px, but it's static, so the offset is ignored: it sits at the top of .stack (offset 0).
  2. #two's slot in the flow is 40px down (right after #one). position: relative; top: 20px; left: 30px paints it 20px lower and 30px further right: measured at 60px down and 30px in.
  3. #three comes straight after #two's slot, at 80px, exactly where it would be with no offsets. Relative positioning never moves other boxes, and .stack is still 3 × 40 = 120px tall.
  4. So #two (60–100px) now overlaps #three (80–120px). At a point in the overlap elementFromPoint returns #two: positioned boxes paint after (above) non-positioned in-flow boxes, even ones later in the source.
  5. Its right edge moved too: 30px + 200px = 230px, which still fits in the 260px stack. Push it further and it would stick out without the stack growing — relative offsets never affect layout.
AdvancedConflicting offsets: top and the start side win

A relatively positioned box can't be stretched, so if you set both top and bottom, one has to lose. top wins over bottom; for the horizontal pair, the one at the start of the line wins: left in a left-to-right context, right in right-to-left (dir="rtl").

Over-constrained offsets in LTR and RTL
CSS
.lane { width: 300px; height: 60px; background: #f1f5f9; margin-bottom: 8px; }
.mover {
  position: relative; top: 10px; bottom: 40px; left: 20px; right: 60px;
  width: 100px; height: 30px; background: #fde68a;
}
HTML
<div class="lane" id="ltr"><div class="mover">ltr</div></div>
<div class="lane" id="rtl" dir="rtl"><div class="mover">rtl</div></div>
Resultlive, in your browser
What’s happening
  1. Both lanes get the same four offsets. A relative box keeps its own size, so the browser can honour only one offset per axis.
  2. Vertically, top: 10px wins in both: each mover is 10px below its slot and bottom: 40px is ignored.
  3. In the LTR lane the slot is at the left edge and left: 20px wins: the mover is 20px in from the left.
  4. In the RTL lane the block's slot is at the right edge, and the start side is the right, so right: 60px wins: the mover ends 60px in from the right edge.
AdvancedNudging: relative or transform?
Where does the box end up?
CSS
.box { position: relative; top: -10px; bottom: 50px; left: 20px; right: 20px; }
Show answer

10px up and 20px to the right of its normal position (in a left-to-right page), and it's still exactly its normal size.

CSS
.lane { width: 300px; height: 70px; padding-top: 20px; background: #f1f5f9; }
.box { position: relative; top: -10px; bottom: 50px; left: 20px; right: 20px; width: 120px; height: 30px; background: #c4b5fd; }
HTML
<div class="lane"><div class="box">box</div></div>
Resultlive, in your browser
What’s happening
  1. Negative offsets are allowed: top: -10px moves the box up. Its slot is 20px down (the lane's padding), so it paints 10px down.
  2. top and bottom are both set; top wins and bottom: 50px is ignored.
  3. left and right are both set; in LTR left wins, so the box moves 20px right.
  4. Its size doesn't change: unlike an absolutely positioned box, a relative box is never stretched by opposing offsets. It's still 120 × 30px.

#position: absolute and the containing block

Added

position: absolute takes a box out of flow entirely: it takes up no space, the elements around it close up, and its parent doesn't grow for it. Then it is placed by its offsets (top, right, bottom, left, or the inset shorthand) relative to its containing block.

For an absolutely positioned box, the containing block is the padding box of the nearest positioned ancestor — the nearest ancestor whose position isn't static. If there isn't one, it's the initial containing block: a viewport-sized rectangle at the top of the document. (A few other properties also make an ancestor the containing block: transform, filter, perspective, contain: layout or paint, and more — see position: fixed.) Percentages for width, left and right are a fraction of that padding box's width; for height, top and bottom, of its height.

The mental model: position: relative on a parent says "measure from me". That's why "parent relative, child absolute" is the most common pairing in CSS: badges on cards, close buttons in corners, overlays over images.

A badge in the corner of a card
CSS
.card {
  position: relative;
  width: 260px; height: 100px; padding: 20px;
  border: 6px solid #93c5fd; background: #eff6ff;
}
.badge {
  position: absolute; top: 0; right: 0;
  width: 60px; height: 24px; background: #fca5a5; text-align: center;
}
.body { height: 40px; background: #dbeafe; }
.half {
  position: absolute; bottom: 0; left: 0;
  width: 50%; height: 20px; background: #bbf7d0;
}
HTML
<div class="card">
  <span class="badge">New</span>
  <div class="body">Card content</div>
  <div class="half">50% wide</div>
</div>
Resultlive, in your browser
What’s happening
  1. .card is position: relative, so it's the nearest positioned ancestor of .badge and .half: it is their containing block. Its border box is 6 + 20 + 260 + 20 + 6 = 312px wide.
  2. .badge has top: 0; right: 0. The containing block is the card's padding box, which starts inside the 6px border: the badge lands 6px from the card's outer top and right edges, not 0px. (Padding doesn't push it in; only the border is outside the padding box.)
  3. .body starts 26px down (border + padding), exactly where it would start if the badge weren't there: the badge is out of flow and takes no space.
  4. .half has width: 50%. For an absolute box that's 50% of the padding box (300px), so it measures 150px, not the 130px you'd get from the 260px content box.
  5. bottom: 0; left: 0 pins .half to the padding box's bottom-left corner, 6px inside the border on both sides.
  6. The badge is a span, but absolute positioning blockifies it: its computed display is block, so width and height apply.
AdvancedNo positioned ancestor: the initial containing block

Forget the position: relative and the box measures from the initial containing block instead, which usually means "the top-left of the page", far away from where you expected.

The box with no positioned ancestor
CSS
.outer { margin: 30px; padding: 20px; height: 60px; background: #f1f5f9; }
.lost { position: absolute; top: 0; left: 0; width: 80px; height: 30px; background: #fca5a5; }
.anchored { position: relative; }
HTML
<div class="outer" id="o1"><div class="lost" id="l1">lost</div></div>
<div class="outer anchored" id="o2"><div class="lost" id="l2">found</div></div>
Resultlive, in your browser
What’s happening
  1. #l1's ancestors are .outer, body and html, all static. There's no positioned ancestor, so top: 0; left: 0 is measured from the initial containing block: the box lands at (0, 0), ignoring the body's 12px margin and .outer's 30px margin and 20px padding.
  2. #o2 adds position: relative. Now #l2 measures from #o2's padding box and sits exactly in its top-left corner (#o2 itself starts at 12 + 30 = 42px).
  3. The initial containing block has the size of the viewport but scrolls with the document, so an absolute box placed against it scrolls away with the page. That's the difference from position: fixed.
AdvancedLeaving the offsets at auto: the static position

If an absolutely positioned box has auto offsets (the default), it stays at its static position: where it would have been in normal flow. It still leaves the flow, so the next sibling moves up underneath it.

Absolute with no offsets
CSS
.list { width: 260px; background: #f1f5f9; }
.item { height: 30px; margin-bottom: 4px; background: #dbeafe; }
#b { position: absolute; width: 120px; background: #fca5a5; }
HTML
<div class="list">
  <div class="item" id="a">a</div>
  <div class="item" id="b">b (absolute, no offsets)</div>
  <div class="item" id="c">c</div>
</div>
Resultlive, in your browser
What’s happening
  1. #b has no positioned ancestor, but it doesn't matter: with every offset auto, it is drawn at its static position, 4px below #a (after #a's margin), at the list's left edge.
  2. Because #b is out of flow, #c is laid out as if #b didn't exist and moves up into the same spot: c.top === b.top.
  3. The list's height counts only #a and #c: 30 + 4 + 30 = 64px. (#c's own 4px bottom margin collapses through the list, which has no padding or border; see Margin collapsing.)
  4. #b paints on top of #c (positioned above in-flow). This trick — position: absolute with no offsets — is handy for a dropdown that should appear right where it is in the markup without pushing content down.
AdvancedHow an absolute box is sized

An absolutely positioned box doesn't fill its container the way a block does. With width: auto and only one horizontal offset it shrinks to fit its content. Set both left and right (or inset) and width: auto stretches it between them. Set both offsets and a size, with margin: auto, and it's centred (see Centering things).

Shrink-to-fit, stretch and fill
CSS
.cb { position: relative; width: 300px; height: 160px; background: #f1f5f9; }
.shrink { position: absolute; top: 10px; left: 10px; background: #fde68a; }
.shrink i { display: block; width: 90px; height: 20px; }
.stretch { position: absolute; top: 40px; left: 10px; right: 10px; height: 20px; background: #bbf7d0; }
.fill { position: absolute; inset: 70px 10px 10px; background: #dbeafe; }
HTML
<div class="cb">
  <div class="shrink"><i></i></div>
  <div class="stretch"></div>
  <div class="fill">inset: 70px 10px 10px</div>
</div>
Resultlive, in your browser
What’s happening
  1. .shrink has only top and left, and width: auto. An absolute box with auto width uses shrink-to-fit: as wide as its content (the 90px i), no wider, up to the space available.
  2. .stretch sets both left: 10px and right: 10px. Now width: auto is solved from the equation left + width + right = 300, so it's 280px.
  3. .fill uses the inset shorthand (top right bottom left, like margin; three values mean top, left/right, bottom). Both pairs are set, so it's 280 × 80px: 300 − 10 − 10 by 160 − 70 − 10.
  4. inset: 0 is the classic "cover the whole parent" overlay: a modal backdrop inside a card, a clickable layer over an image.
AdvancedGotcha: overflow: hidden doesn't always clip
Clipped or not, depending on the containing block
CSS
.outer { position: relative; height: 90px; width: 320px; background: #f8fafc; }
.clip { overflow: hidden; width: 140px; height: 50px; background: #dbeafe; }
.clip.positioned { position: relative; }
.pop { position: absolute; top: 10px; left: 100px; width: 120px; height: 30px; background: #fca5a5; }
HTML
<div class="outer" id="first"><div class="clip"><div class="pop">menu</div></div></div>
<div class="outer" id="second"><div class="clip positioned"><div class="pop">menu</div></div></div>
Resultlive, in your browser
What’s happening
  1. In #first, .clip is static, so .pop's containing block is .outer. The menu spans 100–220px, past .clip's 140px width, and at 200px elementFromPoint still finds .pop: it isn't clipped.
  2. In #second, .clip is position: relative, so it becomes .pop's containing block, and its overflow: hidden now clips it. At 200px you hit .outer; at 120px (inside the clip) you still hit .pop.
  3. To let a menu escape a clipping card, keep the card static and position the menu against an ancestor outside it, use position: fixed, or render it in the top layer with the popover API (see Anchor positioning).
Where does the child end up?
CSS
.grand { position: relative; width: 300px; height: 140px; border: 4px solid; padding: 10px; }
.parent { margin: 20px; padding: 30px; }          /* static */
.child { position: absolute; bottom: 0; left: 0; width: 50px; height: 50px; }
HTML
<div class="grand"><div class="parent"><div class="child"></div></div></div>
Show answer

In the bottom-left corner of .grand's padding box: 4px in from .grand's left border edge and 4px up from its bottom border edge. .parent's margin and padding play no part, because .parent is static.

CSS
.grand { position: relative; width: 300px; height: 140px; border: 4px solid #93c5fd; padding: 10px; background: #eff6ff; }
.parent { margin: 20px; padding: 30px; background: #dbeafe; }
.child { position: absolute; bottom: 0; left: 0; width: 50px; height: 50px; background: #fca5a5; }
HTML
<div class="grand"><div class="parent">parent<div class="child"></div></div></div>
Resultlive, in your browser
What’s happening
  1. Walk up from .child looking for a positioned ancestor: .parent is static, so skip it; .grand is relative. .grand is the containing block.
  2. The containing block is .grand's padding box: inside its 4px border, but including its 10px padding. So left: 0 is 4px in from the outer edge, not 14px.
  3. bottom: 0 measures from the bottom of the same padding box: 4px above the outer bottom edge.
  4. .parent's 20px margin and 30px padding would only matter if .parent were positioned, or if the child had auto offsets (then its static position would be inside .parent's content box).

#position: fixed

Added

position: fixed is like absolute, but the containing block is the viewport: the box is placed relative to the visible window and stays there when the page scrolls. It's out of flow, takes no space, and — as MDN puts it — always creates a new stacking context. Fixed headers, cookie banners, floating "chat" buttons and full-screen overlays all use it.

The mental model: a fixed element is stuck to the glass of the window, not to the page behind it. Scrolling moves the page; the glass stays put. That also means it ignores every scroll container between it and the window: scrolling a scrollable div doesn't move it either.

In the live results on this page each example is its own little document, so "the viewport" is the result frame: a fixed box sits against the frame's edges, not the page's.

Scroll the box: absolute moves, fixed doesn't
CSS
.scroller { position: relative; width: 300px; height: 160px; overflow: auto; background: #f8fafc; }
.tall { height: 500px; background: linear-gradient(#eff6ff, #bfdbfe); }
.abs { position: absolute; top: 10px; left: 10px; width: 120px; height: 30px; background: #bbf7d0; }
.fix { position: fixed; top: 10px; right: 10px; width: 120px; height: 30px; background: #fca5a5; }
HTML
<div class="scroller">
  <div class="tall">scroll me</div>
  <div class="abs">absolute</div>
  <div class="fix">fixed</div>
</div>
Resultlive, in your browser
What’s happening
  1. .abs's containing block is .scroller (it's position: relative), so it's placed 10px inside the scroller's top-left corner and it is part of the scroller's scrolling content.
  2. .fix is a child of the same scroller, but its containing block is the viewport. top: 10px; right: 10px puts it 10px from the top and right of the viewport — outside the 300px scroller altogether, even though it's in the scroller's markup. The scroller's overflow: auto doesn't clip it either, because the scroller isn't in its containing-block chain.
  3. The test scrolls the scroller by 100px. .abs moves up exactly 100px with the content; .fix stays at top: 10px. You can scroll the live result to see the same.
  4. Neither box adds to the scrollable area: scrollHeight is the 500px of .tall.
  5. On a real page the same thing happens when the document itself scrolls: the fixed box stays glued to the window.
AdvancedThe transform trap: when fixed isn't fixed

The viewport is the containing block unless an ancestor has a transform, perspective or filter (or will-change naming one of those, among others): that ancestor then becomes the containing block, and the "fixed" element behaves like an absolute one inside it, scrolling with it. This is behind a classic bug: a modal with position: fixed that suddenly appears inside a card, because some ancestor got a transform for an animation.

The test below puts the same fixed child (top: 0; left: 0) into panels with different properties and checks where it lands in Chrome 155: at the viewport's corner, or at the panel's.

Which ancestors capture a fixed child
CSS
.panel { height: 34px; width: 300px; margin-bottom: 6px; background: #f1f5f9; font-size: 13px; padding-left: 90px; }
.panel .fix { position: fixed; top: 0; left: 0; width: 80px; height: 20px; background: #fca5a5; }
.t { transform: translateX(0); }
.f { filter: saturate(1); }
.p { perspective: 500px; }
.w { will-change: transform; }
.c { contain: layout; }
.b { backdrop-filter: blur(1px); }
.q { container-type: inline-size; }
.r { rotate: 0deg; }
HTML
<div class="panel" id="plain">nothing<div class="fix"></div></div>
<div class="panel t">transform<div class="fix"></div></div>
<div class="panel f">filter<div class="fix"></div></div>
<div class="panel p">perspective<div class="fix"></div></div>
<div class="panel w">will-change: transform<div class="fix"></div></div>
<div class="panel c">contain: layout<div class="fix"></div></div>
<div class="panel b">backdrop-filter<div class="fix"></div></div>
<div class="panel q">container-type<div class="fix"></div></div>
<div class="panel r">rotate<div class="fix"></div></div>
Resultlive, in your browser
What’s happening
  1. In #plain nothing special is set, so the fixed child goes to the viewport's top-left corner, (0, 0), ignoring the body margin and the panel.
  2. Every other panel sets one property. In each, the fixed child lands at that panel's top-left corner instead: the panel has become its containing block.
  3. Measured in Chrome 155: transform, filter, perspective, will-change: transform, contain: layout, backdrop-filter and the individual transform property rotate all capture the fixed child. MDN's list (the containing block guide) also includes scale, translate, contain: paint/strict/content and content-visibility: auto.
  4. container-type: inline-size does not capture it any more: the fixed child is at the viewport corner. Container queries used to imply layout containment, which did capture fixed and absolute children; in 2024 the CSS Working Group resolved that container-type no longer forces layout containment, and Chrome changed in version 129. Older articles still list it, so if you meet it in an interview, that's the history.
  5. Panels with transform, filter and friends are the real-world trap: a modal with position: fixed inside an animated card stops being fixed.
  6. The fix is structural: render overlays as direct children of body (React portals exist for this), or use <dialog> with showModal() or the popover API, which put the element in the top layer, above everything and outside any ancestor's containing block (see Built-in interactive elements).
AdvancedFixed elements on phones
A "fixed" header that scrolls away

A header has position: fixed; top: 0, and it scrolls away with the content instead of staying put. Its only ancestors are body, html, and a wrapper div.app with transform: translateZ(0) that someone added "for performance". What's going on?

Show answer

.app's transform makes it the header's containing block, so the header is fixed to .app, not the viewport. It's positioned at .app's top and moves when .app moves, which, when the document scrolls, is exactly like an absolute element.

What’s happening
  1. The containing block of a fixed box is the viewport unless an ancestor has a transform (or filter, perspective, will-change: transform, contain: layout…) other than none.
  2. translateZ(0) is a transform, even though it moves nothing. It was a common hack to force a GPU layer, and it silently changes layout rules.
  3. So top: 0 now means "the top of .app's padding box", and when the page scrolls, .app scrolls and takes the header with it (the demo above shows the same capture with transform: translateX(0)).
  4. Fix: remove the transform from the ancestor (or move it to an element that doesn't contain the header), or move the header out of .app.

#position: sticky

Added

position: sticky is a hybrid. The box stays in normal flow (it takes up its space like a relative box), but when scrolling would carry it past a threshold you set with top, bottom, left or right, it sticks at that distance from the edge of its nearest scrollport. It keeps sticking only while its containing block (normally its parent) is on screen: when the parent's end scrolls past, the sticky box is pushed out with it.

The mental model: the sticky box is a magnet on a rail. The rail is its parent element; the magnet slides along the rail to stay at top: 0 of the scrolling area, but it can never leave the rail. Section headers in a long list are the textbook case: each header sticks while its own section is visible, and the next section's header pushes it away.

Two things are needed for it to work at all: a threshold (top: 0, not just position: sticky), and room to move (the parent must be taller than the sticky box, and nothing between the box and the scroller may be a non-scrolling overflow: hidden box). The second demo shows each failure.

Sticky section headers
CSS
.scroller { width: 300px; height: 150px; overflow: auto; background: #f8fafc; }
.section { height: 200px; background: #eff6ff; }
.section:nth-child(2) { background: #f0fdf4; }
.head { position: sticky; top: 0; height: 30px; background: #93c5fd; }
.section:nth-child(2) .head { background: #86efac; }
HTML
<div class="scroller">
  <div class="section"><div class="head" id="h1">Section 1</div>…</div>
  <div class="section"><div class="head" id="h2">Section 2</div>…</div>
  <div class="section"><div class="head" id="h3">Section 3</div>…</div>
</div>
Resultlive, in your browser
What’s happening
  1. At scrollTop = 0 both headers sit where normal flow puts them: header 1 at the top of the scroller, header 2 200px down (the top of section 2). Sticky boxes take up space like relative ones; the scroll height is the plain 3 × 200 = 600px.
  2. At scrollTop = 60 header 1 would be at −60px in normal flow, which is above the top: 0 threshold, so it sticks at 0. Header 2 is still in its normal place, 200 − 60 = 140px down.
  3. At scrollTop = 180, section 1's bottom edge is only 20px below the top of the scroller. Header 1 can't leave its parent, so it's pushed up: its bottom stays at 20px, its top at 20 − 30 = −10px. Header 2 is at 20px, about to take over.
  4. At scrollTop = 250 header 2 is the stuck one (at 0), and header 1 has scrolled away with section 1 (its top is at 200 − 250 − 30 = −80px).
  5. Scroll the live result and you'll see the hand-over: each header is a magnet on its own section's rail. Like fixed, a sticky box always creates a stacking context (MDN), so give it a z-index and a background if content should scroll under it.
AdvancedWhy sticky doesn't stick

Three causes cover almost every "sticky isn't working" bug: no threshold, an overflow ancestor between the box and the scroller, and no room in the parent. The first two are below; the third, which mostly bites in flex and grid layouts, gets its own demo.

No threshold, overflow: hidden, overflow: clip
CSS
.row { display: flex; gap: 10px; }
.scroller { width: 180px; height: 120px; overflow: auto; background: #f8fafc; }
.content { height: 400px; }
.head { position: sticky; height: 30px; background: #93c5fd; font-size: 13px; }
.with-top .head { top: 0; }
.hidden { overflow: hidden; }
.clip { overflow: clip; }
HTML
<div class="row">
  <div class="scroller" id="a"><div class="content"><div class="head">no top</div></div></div>
  <div class="scroller with-top" id="b"><div class="content hidden"><div class="head">overflow: hidden</div></div></div>
  <div class="scroller with-top" id="c"><div class="content clip"><div class="head">overflow: clip</div></div></div>
</div>
Resultlive, in your browser
What’s happening
  1. Each scroller is scrolled by 60px, and the test measures where the header ends up relative to the scroller's top.
  2. #a has position: sticky but no top. With every inset auto there's no threshold, so it behaves exactly like position: relative: it scrolls away to −60px.
  3. #b has top: 0, but its parent .content has overflow: hidden. Any overflow other than visible or clip makes an element a scroll container, and a sticky box sticks to its nearest scroll container — .content, which never scrolls. So it scrolls away with .content to −60px. This is the most common real-world cause: an overflow: hidden added to a page wrapper to stop horizontal scrolling.
  4. #c uses overflow: clip instead. It clips the same way but, as MDN says, the element "is not a scroll container", so the header sticks to the real scroller and stays at 0. overflow: clip works in every current engine (Safari since 16, in 2022), which makes it the usual fix.
The sticky sidebar in a flex row
CSS
.scroller { width: 320px; height: 160px; overflow: auto; background: #f8fafc; margin-bottom: 8px; }
.layout { display: flex; gap: 10px; }
.side { position: sticky; top: 0; width: 90px; background: #fde68a; }
.side .menu { height: 60px; }
.start .side { align-self: flex-start; }
.main { flex: 1; height: 500px; background: #eff6ff; }
HTML
<div class="scroller" id="stretch">
  <div class="layout"><aside class="side"><div class="menu">menu</div></aside><main class="main">main</main></div>
</div>
<div class="scroller start" id="fixed">
  <div class="layout"><aside class="side"><div class="menu">menu</div></aside><main class="main">main</main></div>
</div>
Resultlive, in your browser
What’s happening
  1. In both scrollers the sidebar is a flex item next to a 500px main column, and the test scrolls each by 100px.
  2. In #stretch the sidebar gets the default align-items: stretch, so it's 500px tall, the full height of the row. A sticky box can only move within its parent, and a 500px box in a 500px parent has nowhere to go: it scrolls away (−100px).
  3. In #fixed, align-self: flex-start makes the sidebar only as tall as its 60px menu, leaving 440px of rail. It sticks at the top (0px).
  4. The same "no room" problem happens whenever the sticky element's parent is barely taller than it, for example a sticky element wrapped in a div of its own. Put position: sticky on the element whose parent is the long thing you're scrolling past.
Where is the header?

A scroller is 150px tall. Its first section is 200px tall and starts with a 30px header that has position: sticky; top: 10px. The scroller is scrolled down by 175px. Where is the header's top, relative to the scroller's top?

Show answer

At −5px. It wants to stick at 10px, but its section's bottom edge is only 25px from the top, and the header can't leave its section.

CSS
.scroller { width: 300px; height: 150px; overflow: auto; background: #f8fafc; }
.section { height: 200px; background: #eff6ff; }
.section + .section { background: #f0fdf4; }
.head { position: sticky; top: 10px; height: 30px; background: #93c5fd; }
HTML
<div class="scroller">
  <div class="section"><div class="head">Section 1</div></div>
  <div class="section"><div class="head">Section 2</div></div>
</div>
Resultlive, in your browser
What’s happening
  1. In normal flow the header would be at 0 − 175 = −175px, way above the scrollport.
  2. Sticky clamps it to the threshold: it wants to be at top: 10px.
  3. But it must stay inside its containing block, the section, whose bottom edge is at 200 − 175 = 25px. The header's bottom can't go past 25px, so its top is at most 25 − 30 = −5px.
  4. The smaller of "where it wants to stick" (10) and "how far its rail allows" (−5) wins: −5px. Scroll 20px less and it would be at the full 10px.

#z-index and stacking contexts

Added

Boxes overlap all the time: positioned elements, negative margins, transforms. The browser paints a page back to front in a fixed order, and z-index lets you change that order — but only among boxes in the same stacking context.

Within one stacking context the painting order is, from back to front: (1) the context's own background and borders; (2) children with negative z-index; (3) in-flow, non-positioned blocks; (4) non-positioned floats; (5) inline content; (6) positioned descendants with z-index: auto or 0, in source order; (7) descendants with positive z-index, lowest first. Ties go to whichever comes later in the source. z-index applies to positioned boxes and to flex and grid items; on anything else it does nothing.

A stacking context is a group that is painted as one unit, flattened into a single layer and slotted into its parent context at its own z-index. Whatever z-index its children have, they can't come out of the group. The mental model: folders in a pile. A z-index: 999 sheet inside a folder that sits at position 1 in the pile is still under a folder at position 2. z-index numbers are only ever compared between siblings inside the same folder. The root element is a stacking context; so is any positioned element with a z-index other than auto, and a long list of others (opacity below 1, transforms, isolation: isolate… listed below).

z-index: 999 loses to z-index: 2
CSS
.stage { position: relative; width: 340px; height: 160px; background: #f8fafc; }
.a { position: relative; z-index: 1; width: 200px; height: 120px; background: #dbeafe; }
.child {
  position: absolute; z-index: 999; top: 60px; left: 100px;
  width: 150px; height: 80px; background: #fca5a5;
}
.b {
  position: absolute; z-index: 2; top: 40px; left: 160px;
  width: 160px; height: 100px; background: #bbf7d0;
}
HTML
<div class="stage">
  <div class="a">A (z 1)<div class="child">child (z 999)</div></div>
  <div class="b">B (z 2)</div>
</div>
Resultlive, in your browser
What’s happening
  1. .stage is positioned but has no z-index, so it isn't a stacking context. .a (z-index: 1) and .b (z-index: 2) are both compared in the root stacking context.
  2. .a is positioned and has a z-index that isn't auto, so it creates its own stacking context. .child's z-index: 999 is compared only with other things inside .a. Seen from outside, the whole .a folder — child included — sits at level 1.
  3. At (200, 100) the child and B overlap. B's 2 beats A's 1, so B is on top: elementFromPoint returns .b. The 999 never enters the comparison.
  4. Where only the child is (120, 80) you hit the child, and where only A is, A.
  5. If you changed .a to z-index: auto, it stops being a stacking context; now the child's 999 is compared in the root context with B's 2 and wins. Setting .a to z-index: 3 also makes the child win, along with all of A: the whole folder now sits above B.
  6. This is the answer to "I set z-index: 9999 and it's still underneath": find the child's nearest ancestor stacking context, and compare that ancestor with the other element's.
AdvancedPainting order without any z-index

Even with no z-index anywhere, positioned boxes paint above non-positioned ones, whatever the source order; among positioned boxes the later one wins.

Positioned beats in-flow, later beats earlier
CSS
.p1, .n2, .p3 { width: 150px; height: 60px; }
.p1 { position: relative; background: #fca5a5; }
.n2 { margin: -30px 0 0 60px; background: #bbf7d0; }
.p3 { position: relative; margin: -60px 0 0 120px; background: #c4b5fd; }
HTML
<div class="wrap">
  <div class="p1">p1 (relative)</div>
  <div class="n2">n2 (static)</div>
  <div class="p3">p3 (relative)</div>
</div>
Resultlive, in your browser
What’s happening
  1. .p1 is at 0–60px, .n2 is pulled up 30px and in 60px by negative margins (30–90px), and .p3 is pulled up 60px (30–90px, from 120px in).
  2. At (100, 45) .p1 and .n2 overlap. .n2 comes later in the source, but it's a non-positioned block (layer 3), while .p1 is positioned (layer 6): .p1 wins.
  3. At (135, 45) all three overlap. .p1 and .p3 are both in layer 6, so source order decides and the later .p3 is on top.
  4. At (80, 80) only .n2 is there. Without position, there's no way to lift .n2 with z-index — it would be ignored.
AdvancedStacking contexts you didn't ask for: opacity

Many properties create a stacking context as a side effect. opacity below 1 is the classic surprise: a tooltip with z-index: 100 inside a faded card can no longer get above a sibling with z-index: 1, because the card is now a folder sitting at level 0.

A tooltip trapped by opacity: 0.9
CSS
.row { margin-bottom: 30px; }
.card { width: 220px; height: 70px; background: #dbeafe; }
.fade { opacity: 0.9; }
.tip {
  position: absolute; z-index: 100; margin: 40px 0 0 60px;
  width: 140px; height: 50px; background: #fde68a;
}
.next { position: relative; z-index: 1; width: 220px; height: 70px; background: #bbf7d0; }
HTML
<div class="row" id="plain">
  <div class="card"><div class="tip">tip (z 100)</div>card</div>
  <div class="next">next (z 1)</div>
</div>
<div class="row" id="faded">
  <div class="card fade"><div class="tip">tip (z 100)</div>card, opacity 0.9</div>
  <div class="next">next (z 1)</div>
</div>
Resultlive, in your browser
What’s happening
  1. Each .tip is absolute with no offsets, pushed 40px down by its margin, so it hangs 20px over the .next box below its card.
  2. In #plain, .card creates no stacking context. The tip's z-index: 100 is compared in the root context with .next's z-index: 1: the tip wins.
  3. In #faded, opacity: 0.9 makes .card a stacking context. It's painted as one unit at the level of a positioned z-index: 0 element, and the tip's 100 only counts inside it. .next at 1 beats the card's 0, so .next covers the tip.
  4. The same happens with transform, filter, will-change: opacity, isolation: isolate and more. When a z-index "doesn't work", look up the ancestors for one of these.
AdvancedNegative z-index and isolation: isolate

A child with a negative z-index paints below the in-flow content of its stacking context. If its parent isn't a stacking context, that means behind the parent's own background, where it disappears. isolation: isolate turns the parent into a stacking context and does nothing else, which is exactly what you want for decorative layers (a blob behind a heading, a pseudo-element shadow).

The vanishing negative z-index
CSS
.parent { position: relative; width: 220px; height: 90px; margin-bottom: 10px; background: #dbeafe; }
.under {
  position: absolute; z-index: -1; top: 20px; left: 20px;
  width: 100px; height: 50px; background: #fca5a5;
}
.iso { isolation: isolate; }
HTML
<div class="parent" id="lost"><div class="under">z −1</div></div>
<div class="parent iso" id="kept"><div class="under">z −1</div></div>
Resultlive, in your browser
What’s happening
  1. #lost is positioned but has z-index: auto, so it isn't a stacking context. Its child's z-index: -1 is resolved in the root context, where negative layers paint before (under) in-flow and positioned content, including #lost's own background. elementFromPoint hits #lost: the child is invisible.
  2. #kept adds isolation: isolate, so it's now a stacking context. Its own background is layer 1 of that context, and the child at −1 paints just above it (layer 2), below any normal content in the parent.
  3. z-index: 0 on the parent would have the same effect here (a positioned element with a non-auto z-index), but isolation: isolate also works on non-positioned elements and states the intent.
Advancedz-index on flex and grid items

z-index needs position on ordinary blocks, but flex and grid items take z-index without being positioned.

z-index without position
CSS
.flex { display: flex; margin-bottom: 10px; }
.f1, .f2, .s1, .s2 { width: 120px; height: 60px; }
.f1 { z-index: 2; background: #dbeafe; }
.f2 { margin-left: -60px; background: #bbf7d0; }
.s1 { z-index: 2; background: #dbeafe; }
.s2 { margin: -30px 0 0 60px; background: #bbf7d0; }
HTML
<div class="flex"><div class="f1">flex item, z 2</div><div class="f2">later</div></div>
<div class="s1">block, z 2</div>
<div class="s2">later</div>
Resultlive, in your browser
What’s happening
  1. .f2 is pulled 60px left over .f1. Both are flex items; .f1 has z-index: 2 and no position, and still paints above the later .f2.
  2. .s1 and .s2 are plain blocks overlapping by negative margins. .s1's z-index: 2 is ignored because it isn't positioned, so the later .s2 paints over it.
  3. Same rule for grid items. It's why z-index "works" on some unpositioned elements: check whether the parent is a flex or grid container.
AdvancedWhich properties create a stacking context

MDN lists them: the root; position: absolute/relative with a non-auto z-index; position: fixed and sticky; flex and grid items with a non-auto z-index; opacity below 1; mix-blend-mode other than normal; transform, scale, rotate, translate, filter, backdrop-filter, perspective, clip-path or mask other than none; isolation: isolate; will-change naming any of those; contain: layout or paint (or strict/content); container-type: size or inline-size; and elements in the top layer. The container-type entry is out of date: the CSS Working Group dropped layout containment from container-type in 2024 and Chrome followed in version 129, and the test below shows it no longer creates a stacking context in Chrome 155. The test checks a dozen triggers in Chrome with the same setup as the opacity demo: if the parent forms a stacking context, its z-index: 5 child loses to a later z-index: 1 sibling.

Testing stacking-context triggers
CSS
.row { position: relative; height: 30px; margin-bottom: 4px; transform: translate(0); font-size: 12px; }
.parent { width: 160px; height: 30px; background: #dbeafe; }
.kid { position: absolute; z-index: 5; top: 0; left: 100px; width: 100px; height: 30px; background: #fca5a5; }
.sib { position: absolute; z-index: 1; top: 0; left: 120px; width: 140px; height: 30px; background: #bbf7d0; }
HTML
<div class="row"><div class="parent">nothing<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="position: relative">relative<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="position: relative; z-index: 0">relative + z 0<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="opacity: 0.99">opacity<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="transform: translateX(0)">transform<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="filter: saturate(1)">filter<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="isolation: isolate">isolation<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="will-change: opacity">will-change<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="mix-blend-mode: multiply">mix-blend-mode<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="contain: paint">contain: paint<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="container-type: inline-size">container-type<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="clip-path: inset(0)">clip-path<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="position: sticky">sticky<div class="kid"></div></div><div class="sib"></div></div>
<div class="row"><div class="parent" style="position: fixed">fixed<div class="kid"></div></div><div class="sib"></div></div>
Resultlive, in your browser
What’s happening
  1. Each row contains a .parent with a z-index: 5 kid, followed by a z-index: 1 sibling. They overlap at 140px. (Each row has a transform so that it is its own little stacking context and containing block, including for the fixed row, so rows can't affect each other.)
  2. "nothing" and "relative" (positioned, but z-index: auto): the parent isn't a stacking context, the kid's 5 is compared with the sibling's 1, and the kid is on top.
  3. Every other row: the parent becomes a stacking context at level 0 (or auto), the kid is trapped inside it, and the sibling wins. Measured in Chrome 155 for z-index: 0, opacity: 0.99, transform, filter, isolation: isolate, will-change: opacity, mix-blend-mode, contain: paint, clip-path, sticky and fixed. The exception is container-type: inline-size: the kid wins there, so it did not form a stacking context.
  4. Note opacity: 0.99: any value below 1 counts, and even animating opacity briefly below 1 can make z-index "flicker" during a fade.
Which one is on top?

A site header has position: sticky; top: 0; z-index: 10. Inside it is a dropdown menu with position: absolute; z-index: 9999. The page content below is a <main> with position: relative; z-index: 20. The dropdown opens over <main>. Which is on top?

Show answer

<main> is on top. The header is a stacking context at level 10, so the dropdown's 9999 only counts inside the header; <main> at 20 beats the header's 10.

CSS
.header { position: sticky; top: 0; z-index: 10; height: 40px; background: #93c5fd; }
.menu { position: absolute; z-index: 9999; top: 30px; left: 20px; width: 160px; height: 80px; background: #fde68a; }
main { position: relative; z-index: 20; display: block; height: 100px; background: #f0fdf4; }
HTML
<header class="header">header<div class="menu">menu (z 9999)</div></header>
<main>main (z 20)</main>
Resultlive, in your browser
What’s happening
  1. position: sticky always forms a stacking context, and z-index: 10 places that context at level 10 in the root.
  2. <main> is positioned with z-index: 20, a sibling of the header in the root context. 20 > 10, so <main> paints above the header and everything inside it.
  3. The menu wins inside its own folder (it covers the header's background at 35px) but loses where it hangs over <main> (at 60px).
  4. Fixes: raise the header above <main> (z-index: 30), drop <main>'s z-index if it doesn't need one, or put the menu in the top layer with the popover API so no z-index is involved.

#Floats and clearing

Added

float: left or right takes a box out of normal flow and pushes it to the side of its container, and the inline content after it (text, inline images) wraps around it. Floats were designed for exactly one job: letting text flow around an image, like in a magazine. For about fifteen years they were also abused for whole-page column layouts, which is where clearfixes and collapsing parents came from. Today flexbox and grid do layout, and floats are back to their original job.

The subtle part is what gets out of the way. A float doesn't push the block boxes after it: their boxes still start at the container's edge and run underneath the float. Only their line boxes get shorter so the text doesn't overlap. The mental model: a float is a rock in a stream. The water (lines of text) flows around it; the riverbed (the block boxes) goes straight underneath.

Floats are also out of flow for their parent's height, so a container with only floated children collapses to zero height. clear makes a box start below earlier floats, and display: flow-root makes a container grow to contain its floats.

Lines flow around a float
CSS
.article { width: 320px; line-height: 30px; background: #f8fafc; }
.pic { float: left; width: 100px; height: 80px; margin-right: 10px; background: #c7d2fe; }
.text { margin: 0; }
.w {
  display: inline-block; vertical-align: top;
  width: 40px; height: 30px;
  background: #fde68a; box-shadow: inset -3px 0 #f8fafc;
}
HTML
<div class="article">
  <div class="pic">image</div>
  <p class="text"><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i><i class="w"></i></p>
</div>
Resultlive, in your browser
What’s happening
  1. .pic floats left: it moves to the left edge of .article and leaves the flow. Its margin-right: 10px keeps text 10px away, so the space taken next to it is 110px.
  2. The paragraph's block box ignores the float: it starts at the article's top-left and is the full 320px wide. That's the "riverbed".
  3. Its line boxes avoid the float. Each word is a 40px inline-block and lines are exactly 30px tall (line-height: 30px, vertical-align: top). Next to the float there are 320 − 110 = 210px, so 5 words fit (200px) and the sixth wraps.
  4. Lines 1, 2 and 3 start at 0, 30 and 60px. All three begin at the float's edge (110px), because the float reaches down to 80px and line 3 starts above that.
  5. Line 4 starts at 90px, below the float, so it starts back at the left edge (0px) and could hold 8 words.
  6. The article is 120px tall: four lines of text. The float happens to fit inside that; if it were taller than the text it would stick out below (next demo).
AdvancedCollapsing parents and the fixes
Containing floats: nothing, flow-root, clearfix, overflow
CSS
.box { width: 300px; margin-bottom: 70px; background: #f1f5f9; outline: 2px dashed #94a3b8; }
.fl { float: left; width: 80px; height: 50px; margin-right: 8px; background: #fca5a5; }
.root { display: flow-root; }
.clearfix::after { content: ""; display: block; clear: both; }
.ov { overflow: hidden; }
HTML
<div class="box" id="none"><div class="fl"></div><div class="fl"></div></div>
<div class="box root" id="root"><div class="fl"></div><div class="fl"></div></div>
<div class="box clearfix" id="cf"><div class="fl"></div><div class="fl"></div></div>
<div class="box ov" id="ov"><div class="fl"></div><div class="fl"></div></div>
Resultlive, in your browser
What’s happening
  1. #none contains only floats, and floats don't count towards their parent's height: it's 0px tall, and the floats hang out of it (the dashed outline is a flat line).
  2. display: flow-root creates a new block formatting context (BFC). A BFC contains its floats, so the box grows to 50px. It exists for exactly this and has no side effects; it's the modern answer.
  3. The clearfix adds an empty ::after block at the end with clear: both. That block has to start below the floats, and since it is in flow, the parent grows to contain it: 50px. This was the standard fix before flow-root.
  4. overflow: hidden (any value except visible or clip) also creates a BFC, so it works too, but it clips anything that sticks out, like shadows or dropdowns. That's why it fell out of favour.
  5. A BFC also stops margins of the children collapsing through the parent (see Margin collapsing) and stops the box itself from running underneath a neighbouring float.
Advancedclear: start below the floats
clear: none, left and both
CSS
.wrap { display: flow-root; width: 320px; margin-bottom: 8px; background: #f8fafc; }
.l { float: left; width: 100px; height: 60px; background: #c7d2fe; }
.r { float: right; width: 100px; height: 90px; background: #fbcfe8; }
.foot { height: 24px; background: #bbf7d0; }
HTML
<div class="wrap" id="w1"><div class="l"></div><div class="r"></div><div class="foot">no clear</div></div>
<div class="wrap" id="w2"><div class="l"></div><div class="r"></div><div class="foot" style="clear: left">clear: left</div></div>
<div class="wrap" id="w3"><div class="l"></div><div class="r"></div><div class="foot" style="clear: both">clear: both</div></div>
Resultlive, in your browser
What’s happening
  1. Each wrapper has a 60px left float and a 90px right float, then a footer block.
  2. With no clear, the footer's box starts at the top (0px), running under both floats; only its text is squeezed into the 120px between them.
  3. clear: left moves the footer down until it's below every earlier left float: 60px. The right float still overlaps it.
  4. clear: both clears both sides: 90px, below the taller float. This is what you want for a footer after floated columns.
AdvancedFloats today
Floats blockify; flex items ignore float
CSS
.tag { float: left; width: 70px; height: 24px; margin-right: 6px; background: #fde68a; }
.row { display: flex; gap: 6px; margin-top: 34px; }
.row span { float: right; width: 70px; height: 24px; background: #bbf7d0; }
HTML
<span class="tag">float</span>
<div class="row"><span id="a">a</span><span id="b">b</span></div>
Resultlive, in your browser
What’s happening
  1. Floating blockifies an element: the span computes to display: block, which is why width and height work on it without changing display yourself.
  2. Inside a flex container, the spans still compute float: right, but flex layout ignores float: #a is first, at the left edge, and #b follows 6px later (the gap).
  3. The same holds for grid items. So when you convert an old float layout to flex, leftover float rules are harmless, but also do nothing.
How tall is the container?
CSS
.c { width: 300px; }
.a { float: left; width: 100px; height: 50px; }
.b { float: right; width: 100px; height: 70px; }
.p { height: 20px; }
HTML
<div class="c"><div class="a"></div><div class="b"></div><div class="p"></div></div>

What if you add display: flow-root to .c?

Show answer

20px, the height of .p, the only in-flow child. With display: flow-root it's 70px, the bottom of the taller float.

CSS
.c { width: 300px; background: #f1f5f9; margin-bottom: 80px; }
.a { float: left; width: 100px; height: 50px; background: #c7d2fe; }
.b { float: right; width: 100px; height: 70px; background: #fbcfe8; }
.p { height: 20px; background: #bbf7d0; }
HTML
<div class="c" id="plain"><div class="a"></div><div class="b"></div><div class="p"></div></div>
<div class="c" id="root" style="display: flow-root"><div class="a"></div><div class="b"></div><div class="p"></div></div>
Resultlive, in your browser
What’s happening
  1. Both floats are out of flow, so without a BFC they don't add to .c's height. .p is in flow and starts at the top, under the floats: .c is 20px.
  2. display: flow-root makes .c a block formatting context, and a BFC's height includes its floats' bottom margin edges. The taller float ends at 70px, so .c grows to 70px.
  3. .p still starts at 0 in both cases: making .c a BFC doesn't push .p below the floats; only clear would.

#Centering things: every way

Added

"How do you centre a div?" is a joke because for years the answer depended on the situation. It still does, a little: which way (horizontally, vertically or both), and what kind of box (a block with a known width, inline content, an absolutely positioned element, something of unknown size). Today the short answer is flexbox or grid, but interviews like to hear that you know the others and when each one breaks.

The mental model: centring always means sharing out the free space equally on both sides. Each technique is a different way of telling the browser where the free space is and who gets it: auto margins absorb it, justify-content/align-items distribute it in flex, place-items aligns within a grid area, and translate(-50%, -50%) corrects an offset after the fact.

Six ways to centre a box in a box
CSS
.grid { display: grid; grid-template-columns: repeat(3, 170px); gap: 10px; font-size: 12px; }
.frame { position: relative; width: 170px; height: 100px; background: #f1f5f9; }
.child { width: 60px; height: 40px; background: #93c5fd; }

.f1 { display: flex; justify-content: center; align-items: center; }
.f2 { display: grid; place-items: center; }
.f3 .child { position: absolute; inset: 0; margin: auto; }
.f4 .child { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.f5 { align-content: center; }
.f5 .child { margin-inline: auto; }
.f6 { display: flex; }
.f6 .child { margin: auto; }
HTML
<div class="grid">
  <div><code>flex</code><div class="frame f1"><div class="child"></div></div></div>
  <div><code>grid</code><div class="frame f2"><div class="child"></div></div></div>
  <div><code>inset: 0 + margin: auto</code><div class="frame f3"><div class="child"></div></div></div>
  <div><code>50% + translate</code><div class="frame f4"><div class="child"></div></div></div>
  <div><code>align-content (block)</code><div class="frame f5"><div class="child"></div></div></div>
  <div><code>flex + margin: auto</code><div class="frame f6"><div class="child"></div></div></div>
</div>
Resultlive, in your browser
What’s happening
  1. Every frame is 170 × 100px and every child 60 × 40px, so the free space is 110px across and 60px down; centred means 55px on each side and 30px above and below. The test checks all six children are centred to within half a pixel, and still 60 × 40.
  2. Flex: justify-content: center centres along the main axis (horizontal in a row) and align-items: center along the cross axis. The everyday answer.
  3. Grid: place-items: center is shorthand for align-items + justify-items, centring each item in its grid area. The shortest answer.
  4. Absolute + inset: 0 + margin: auto: offsets on all four sides plus a fixed size over-constrain the box, and auto margins then split the leftover equally on both axes. It needs a known size (or fit-content).
  5. Absolute + top/left: 50% + translate(-50%, -50%): the offsets put the child's top-left corner at the centre, and the transform pulls it back by half its own size. Works for unknown sizes, but the box is out of flow and the transform is no longer free for animation.
  6. align-content: center on a plain block: since 2024 it works in block layout too (Baseline 2024: Chrome 123, Firefox 125, Safari 17.4), centring the content vertically without making the parent flex or grid; margin-inline: auto centres the block horizontally. .f5 is still display: block.
  7. Flex + margin: auto on the child: in a flex container auto margins absorb free space on both axes, so one declaration on the child centres it. Unlike justify-content: center, this also degrades safely when the child is bigger than the container (below).
Advancedmargin: 0 auto and its conditions

The oldest way, margin: 0 auto, centres a block horizontally only if the block is narrower than its container. A block with width: auto fills the container, so the auto margins become 0 and nothing visibly changes. And text-align: center centres inline content (text, inline-blocks), not block children.

What margin: auto and text-align actually centre
CSS
.box { width: 300px; background: #f1f5f9; margin-bottom: 8px; }
.inner { height: 24px; background: #93c5fd; }
.auto-only { margin: 0 auto; }
.fit { width: fit-content; margin: 0 auto; }
.fit i, .ib { display: inline-block; width: 80px; height: 24px; vertical-align: top; }
.centered-text { text-align: center; }
.blockchild { width: 80px; height: 24px; background: #fca5a5; }
.ib { background: #fde68a; }
HTML
<div class="box"><div class="inner auto-only">margin auto, width auto</div></div>
<div class="box"><div class="inner fit"><i></i></div></div>
<div class="box centered-text"><span class="ib"></span><div class="blockchild"></div></div>
Resultlive, in your browser
What’s happening
  1. .auto-only has margin: 0 auto but width: auto. A block's auto width is solved first: it fills the 300px, there's no space left over, and the auto margins resolve to 0.
  2. .fit adds width: fit-content (as wide as its 80px content). Now there are 220px left over and the auto margins split it: 110px each side.
  3. In the third box, text-align: center centres the inline-block span (110px in), because text-align aligns inline content within line boxes.
  4. The div in the same box is a block, so text-align doesn't move it: it sits at 0. (Its own inline content would be centred, since text-align is inherited.)
AdvancedVertical auto margins: block vs flex

In block layout, margin-top: auto and margin-bottom: auto simply compute to 0, so margin: auto never centres vertically there. In a flex (or grid) container, auto margins on an item take up the free space in both directions. That's why margin: auto works in frame 6 above.

margin: auto in a block and in a flex container
CSS
.frame { width: 200px; height: 100px; margin-bottom: 8px; background: #f1f5f9; }
.child { width: 60px; height: 40px; margin: auto; background: #93c5fd; }
.flex { display: flex; }
HTML
<div class="frame" id="block"><div class="child"></div></div>
<div class="frame flex" id="flex"><div class="child"></div></div>
Resultlive, in your browser
What’s happening
  1. Both children have width: 60px; height: 40px; margin: auto in a 200 × 100px frame.
  2. In #block, the horizontal auto margins share the 140px spare (70px each side), but vertical auto margins in block layout compute to 0px: the child sits at the top.
  3. In #flex, the child is a flex item, and auto margins absorb free space on both axes: 70px left and right, 30px top and bottom. Centred.
AdvancedThe overflow trap: safe centring

When the child is bigger than the container, justify-content: center still centres it, so it overflows equally on both sides. The part that sticks out on the start side is at a negative position, and scrolling can't reach it: content is lost. justify-content: safe center (or the auto-margin technique) falls back to the start edge when there isn't room.

center vs safe center vs margin: auto, with a too-wide child
CSS
.track { display: flex; width: 200px; height: 40px; overflow: auto; margin-bottom: 8px; background: #f1f5f9; }
.wide { flex: none; width: 300px; height: 30px; background: linear-gradient(90deg, #fca5a5, #93c5fd); }
.c1 { justify-content: center; }
.c2 { justify-content: safe center; }
.c3 .wide { margin: 0 auto; }
HTML
<div class="track c1"><div class="wide">START … end</div></div>
<div class="track c2"><div class="wide">START … end</div></div>
<div class="track c3"><div class="wide">START … end</div></div>
Resultlive, in your browser
What’s happening
  1. Each track is 200px wide and holds a 300px child (flex: none, so it can't shrink), giving −100px of free space.
  2. justify-content: center splits the negative space evenly: the child starts at −50px. A scroll container only scrolls towards the end, so scrollWidth is 250px and the first 50px ("START") can never be scrolled into view.
  3. safe center centres only when the item fits; otherwise it aligns to the start. The child starts at 0 and all 300px are scrollable.
  4. Auto margins never go negative: with no free space they're 0, so the child also starts at 0. This is the technique to use when the content size is unknown, such as a centred modal that might be taller than a phone screen.
  5. This was measured in Chrome 155; the auto-margin version relies only on long-standing flexbox behaviour.
Why isn't this centred vertically?
CSS
.parent { height: 300px; }
.child { width: 200px; height: 100px; margin: auto; }
Show answer

Because .parent is a plain block. In block layout vertical auto margins compute to 0, so the child is centred horizontally only. Make the parent display: flex (or grid), or add align-content: center to the parent.

What’s happening
  1. margin: auto sets all four margins to auto.
  2. Horizontally, a block with a fixed width splits the spare space between auto left and right margins: centred.
  3. Vertically, block layout doesn't distribute spare height to margins; auto top and bottom margins become 0, so the child sits at the top (the demo above measures margin-top: 0px).
  4. In a flex or grid container, auto margins absorb free space on both axes, so the same margin: auto centres both ways. Or keep block layout and use align-content: center on the parent (Baseline 2024).

#Anchor positioning

Added

Tooltips, dropdowns and popovers need to sit next to another element, wherever that element ends up. Classic CSS can't express "below that button" unless the popup is inside a positioned wrapper around the button, which then often clips it, so for years libraries like Popper and Floating UI measured the button with JavaScript on every scroll and resize. CSS anchor positioning does it in CSS: you name an element as an anchor with anchor-name, point an absolutely or fixed positioned element at it with position-anchor, and place it with the anchor() function in its insets, or with position-area.

Mental model: anchor-name drops a pin on an element; the positioned element can then measure from that pin instead of from its containing block. top: anchor(bottom) reads as "my top goes at the anchor's bottom". position-area: top center puts the element in one cell of an imaginary 3 × 3 grid drawn around the anchor. And position-try-fallbacks lists alternatives (such as "flip to the other side") for when the first choice would overflow.

Support: anchor positioning is Baseline 2026, newly available: MDN says it has worked in the latest browsers since January 2026, when Firefox 147 shipped it. Chrome and Edge have had it since version 125 (2024) and Safari since 26 (2025). MDN also notes some parts have varying support, and older browsers don't have it at all, so production code still guards it with @supports (anchor-name: --a) and falls back to a simpler position or a JS library.

A tooltip under a button, with no wrapper
CSS
.toolbar { height: 110px; background: #f8fafc; padding: 10px; }
.save { anchor-name: --save; width: 120px; height: 36px; margin-left: 40px; }
.tip {
  position: absolute;
  position-anchor: --save;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 6px;
  width: 160px; height: 30px; padding: 0 8px; box-sizing: border-box;
  background: #1f2937; color: #fff; font-size: 13px; line-height: 30px;
}
HTML
<div class="toolbar">
  <button class="save">Save</button>
</div>
<div class="tip">Saves a copy (⌘S)</div>
Resultlive, in your browser
What’s happening
  1. anchor-name: --save names the button as an anchor. Anchor names are dashed identifiers, like custom properties.
  2. The tooltip is a sibling of the toolbar, not inside it, and nothing is position: relative. Its containing block is the initial containing block — but position-anchor: --save makes the button its default anchor, so anchor() can measure the button from there.
  3. top: anchor(bottom) puts the tooltip's top at the button's bottom edge, and margin-top: 6px adds a gap: measured 6px below. left: anchor(left) lines up the left edges.
  4. The test moves the button 160px to the right by changing its margin. The tooltip follows without any script: its insets are recomputed from the anchor on every layout.
  5. Because the tooltip doesn't have to live inside the button's wrapper, an overflow: hidden card around the button can't clip it, which was the main headache of the "relative wrapper" approach.
Advancedposition-area and anchor-size()

position-area is the shortcut: name a cell of the 3 × 3 grid around the anchor (top center, right center, bottom span-right…), and the element is placed in that cell and aligned towards the anchor. anchor-size() lets the element take its size from the anchor, for example a dropdown as wide as its input.

position-area with an anchor-sized box
CSS
.stage { position: relative; height: 140px; background: #f8fafc; }
.chip {
  anchor-name: --chip; position: absolute; top: 60px; left: 120px;
  width: 100px; height: 30px; background: #93c5fd;
}
.above {
  position: absolute; position-anchor: --chip;
  position-area: top center;
  width: anchor-size(width); height: 30px; margin-bottom: 4px;
  background: #fde68a;
}
.beside {
  position: absolute; position-anchor: --chip;
  position-area: center right;
  width: 60px; height: 20px; margin-left: 4px;
  background: #bbf7d0;
}
HTML
<div class="stage">
  <div class="chip">anchor</div>
  <div class="above">above</div>
  <div class="beside">right</div>
</div>
Resultlive, in your browser
What’s happening
  1. .chip is the anchor (here it's absolutely positioned itself; an anchor can be any visible element).
  2. .above uses position-area: top center: the cell above the anchor, in the middle column. The browser aligns it towards the anchor (bottom of the cell) and centres it in the column. Measured: its bottom is 4px above the anchor (its margin-bottom), and its centre lines up with the anchor's.
  3. width: anchor-size(width) makes it exactly as wide as the anchor: 100px.
  4. .beside uses position-area: center right: the middle row, right column. It starts 4px right of the anchor (its margin-left) and is centred vertically on it.
  5. With insets and anchor() you control every edge; with position-area you pick a cell and let alignment do the rest. Both use the same default anchor.
AdvancedFlipping when there isn't room: position-try-fallbacks
flip-block when the tooltip would overflow
CSS
.box { position: relative; width: 300px; height: 150px; margin-bottom: 40px; background: #f8fafc; }
.a1, .a2 { position: absolute; bottom: 10px; width: 100px; height: 30px; background: #93c5fd; }
.a1 { anchor-name: --a1; left: 10px; }
.a2 { anchor-name: --a2; left: 170px; }
.t1, .t2 {
  position: absolute; top: anchor(bottom); left: anchor(left);
  width: 100px; height: 40px; background: #fde68a; font-size: 12px;
}
.t1 { position-anchor: --a1; }
.t2 { position-anchor: --a2; position-try-fallbacks: flip-block; }
HTML
<div class="box">
  <div class="a1">no fallback</div><div class="t1">overflows</div>
  <div class="a2">flip-block</div><div class="t2">flipped up</div>
</div>
Resultlive, in your browser
What’s happening
  1. Both anchors sit 10px from the bottom of a 150px box, and both tooltips ask for top: anchor(bottom): a 40px box starting at the anchor's bottom would end 30px below the containing block.
  2. .t1 has no fallback, so it's placed as asked and overflows the box by 30px.
  3. .t2 has position-try-fallbacks: flip-block. Its first position overflows the containing block, so the browser tries the fallback: the same declarations mirrored in the block axis (top: anchor(bottom) becomes bottom: anchor(top)). That fits, so it's used: the tooltip's bottom is at the anchor's top.
  4. Other built-in fallbacks are flip-inline and flip-start, and you can define your own with @position-try --name { … }. The browser picks the fallback during layout, with no JavaScript.
AdvancedPopovers get an implicit anchor

A popover opened by a button with popovertarget is implicitly anchored to that button, so you don't need an anchor-name; position-anchor: auto tells the popover to use that implicit anchor. Combined with the popover API (top layer, light dismiss, no z-index fights), a dropdown menu is now HTML plus a few lines of CSS. In the live result you can click the button; the frame runs no scripts, and doesn't need to.

A popover menu anchored to its button
CSS
.stage { height: 150px; }
.menu-btn { width: 110px; height: 34px; margin-left: 60px; }
#menu {
  position-anchor: auto;
  inset: auto; margin: 0;
  top: anchor(bottom); left: anchor(left);
  width: 160px; padding: 8px; border: 1px solid #cbd5e1; background: #fff;
}
HTML
<div class="stage">
  <button class="menu-btn" popovertarget="menu">Options ▾</button>
  <div id="menu" popover>Rename<br>Duplicate<br>Delete</div>
</div>
Resultlive, in your browser
What’s happening
  1. popovertarget="menu" makes the button toggle the popover declaratively; the test just calls click() as a user would.
  2. Opening a popover through its invoker creates an implicit anchor relationship (MDN: "an implicit anchor reference is made between the two"). position-anchor: auto makes that implicit anchor the default anchor, so anchor() refers to the button without any anchor-name. Leave that line out and, in Chrome 155 (where the initial value is normal), the menu ignored the button and opened at the viewport's top-left corner; the test failed that way before the line was added.
  3. The browser's own stylesheet gives popovers position: fixed; inset: 0; margin: auto, which centres them in the viewport. inset: auto; margin: 0 clears that. Then top: anchor(bottom); left: anchor(left) puts the menu right under the button: measured exactly at its bottom-left corner.
  4. The popover is in the top layer, so no ancestor's overflow, transform or z-index can clip or cover it, and clicking outside or pressing Escape closes it (light dismiss). See Built-in interactive elements.
AdvancedWhen anchors don't work

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.