Display & Positioning
display: block/inline/inline-block in real rendered behavior, position: static/relative/absolute/fixed/sticky and how each containing block is determined, and what genuinely creates a stacking context.
display: block — Full-Width Boxes That Stack Vertically
Every HTML element has a default display value baked into the browser's built-in stylesheet — not something you have to set yourself unless you want to change it. div, p, h1-h6, section, article, ul, li, and form are all display: block by default. A block-level box has three defining behaviors that every other display value is defined in contrast to.
.box {
display: block; /* the default for div, p, section, etc. */
}
/*
1. Takes up the full available width of its parent, regardless of content size
2. Always starts on a new line — forces a line break before AND after itself
3. Respects width, height, margin, and padding on all four sides in full
*/<div style="background: #fecaca;">First box</div>
<div style="background: #bbf7d0;">Second box</div>
<!--
Even though these are written on adjacent lines with no line break between
them, they render as two full-width bars, one directly under the other.
Block elements ignore how much horizontal room their content actually needs
and claim the entire row for themselves.
-->This is the single most important thing to internalize about block: width is not determined by content, it is determined by the parent. A div containing the single word "Hi" still stretches edge-to-edge across its container unless you explicitly constrain its width. This surprises almost everyone the first time they try to put a border around some inline text and watch it stretch across the whole page.
width on a block element does not change its block-level behavior — it still starts on its own line and forces the next element onto a new line. Width only constrains how far the box extends; it does not make the element share a line with its siblings. That requires a different display value entirely, covered next.display: inline — Flows With Text, Ignores Box Dimensions
span, a, strong, em, and img (with a caveat covered below) default to display: inline. An inline element is, in almost every way, the opposite of block: it takes up only as much horizontal space as its content needs, sits in the normal flow of text rather than forcing a line break, and — this is the part that trips people up — it largely ignores width, height, and vertical margin/padding.
.tag {
display: inline;
width: 200px; /* has NO effect — inline boxes size to their content */
height: 100px; /* has NO effect */
margin-top: 40px; /* has NO effect on layout — does not push siblings away */
margin-bottom: 40px; /* has NO effect */
padding: 20px; /* renders visually (background/border extend),
but does NOT push surrounding line boxes apart —
it can visually overlap the line above/below */
}The reason is baked into how inline layout works: inline boxes are placed along a line, and the height of that line is determined by the tallest inline content actually needed to display the text (roughly, the line-height). A height: 100px declaration on an inline span genuinely has no effect on how much vertical room the element occupies in the page — the browser is not calculating a box in the way it does for block elements, it is placing a fragment of content into a line of text.
.tag {
display: inline;
margin-left: 8px; /* works — pushes the next inline content sideways */
padding: 4px 10px; /* works horizontally; vertically it may visually
overlap without changing the line's height */
}display: inline (a span or an a tag left at its default). The fix is not a CSS bug workaround — it is switching the element to inline-block or block, covered next.display: inline-block — The Best of Both, With One Gotcha
inline-block exists precisely to solve the problem from Part 02: it sits inline with surrounding content (no forced line break before or after), but it fully respects width, height, and margin/padding on every side, exactly like a block element would.
.nav-button {
display: inline-block;
width: 120px;
height: 44px;
padding: 10px 16px;
margin: 0 4px;
text-align: center;
background: #1e293b;
color: white;
border-radius: 6px;
}
<!-- HTML -->
<a class="nav-button" href="/home">Home</a>
<a class="nav-button" href="/pricing">Pricing</a>
<a class="nav-button" href="/about">About</a>
<!--
All three sit on the same line (inline behavior), each is exactly
120x44px with real spacing between them (block-level sizing). This
exact pattern was, before Flexbox existed, THE standard way to build
a horizontal row of equal-sized clickable elements.
-->The whitespace gap — inline-block's one famous gotcha
Because inline-block elements participate in inline/text layout, the whitespace (literal newlines and spaces) between them in your HTML source is rendered as a real gap — typically around 4px, matching the default font's space-character width. Three inline-block boxes written on separate lines in your markup will have visible gaps between them that margin: 0 alone will not remove.
<!-- Produces a ~4px gap between each button, from the newlines/indentation -->
<div>
<a class="nav-button">Home</a>
<a class="nav-button">Pricing</a>
<a class="nav-button">About</a>
</div>
<!-- Fix 1: remove the whitespace by writing tags with no gap between them -->
<div><a class="nav-button">Home</a><a class="nav-button">Pricing</a><a class="nav-button">About</a></div>
<!-- Fix 2: set font-size: 0 on the parent, reset it on the children -->
.nav-wrapper { font-size: 0; }
.nav-button { font-size: 16px; }
<!-- Fix 3 (the real modern answer): don't use inline-block for this at all —
use display: flex on the parent, which has no whitespace-gap issue
and is covered in full starting in Module 23 -->inline-block for building rows of equal-sized elements — it does not have the whitespace-gap problem, and gives far more control over spacing and alignment. Knowing inline-block and its gotcha is still valuable: you will encounter it constantly in existing/legacy codebases, and it remains the right tool for a few specific cases, like wrapping a background/border around a short run of inline text without breaking the surrounding paragraph flow.position: static and relative — The Foundation Before Absolute Makes Sense
position: static is the default value for every element — "static" means the element sits exactly where normal document flow places it, and the top, right, bottom, left, and z-index properties have zero effect on a statically positioned element. This surprises beginners constantly: setting top: 20px on an element does nothing at all until you also set a non-static position.
.box {
position: static; /* the default — you rarely write this explicitly */
top: 50px; /* completely ignored */
left: 50px; /* completely ignored */
}position: relative is where offsets start actually working — but in a way that surprises people the first time: a relatively positioned element is still laid out in normal flow first (it still takes up its original space, and siblings are positioned as if it were static), and then shifted visually by the given offset from where it would otherwise have been. It does not affect the layout of any other element.
.box-a { background: pink; }
.box-b {
position: relative;
top: 20px;
left: 30px;
background: lightblue;
}
.box-c { background: lightgreen; }
<!--
box-b visually shifts 20px down and 30px right from where it would
normally sit. box-c does NOT move up to fill the gap — box-b's
original position in the flow is still reserved, exactly as if it
had never moved. This is the key difference from position: absolute.
-->relative is rarely used for the visual shift on its own in modern layouts — its real, dominant purpose in real-world CSS is something else entirely, covered in the next part: establishing a positioning anchor for an absolutely positioned child.
position: absolute — Precisely How Its Containing Block Is Determined
position: absolute removes an element from normal document flow entirely — surrounding elements behave exactly as if it were not there at all, closing the gap it would otherwise have occupied. It is then positioned using top/right/bottom/left relative to its containing block — and getting this containing block right is the single most important skill for using absolute correctly.
position is anything other than static — that is, the nearest ancestor with relative, absolute, fixed, or sticky. If no ancestor has a non-static position, the containing block falls all the way back to the <html> element — the initial containing block — which is why an absolutely positioned element with no positioned ancestor appears to be positioned relative to the entire page/viewport.<div class="card">
<span class="badge">New</span>
</div>
.card { padding: 20px; background: #f1f5f9; }
.badge {
position: absolute;
top: 10px;
right: 10px;
background: red;
color: white;
}
/*
.card is position: static (the default) — it does NOT establish a
containing block. So .badge's "top: 10px; right: 10px" is measured
against the <html> element, not against .card. The badge ends up
pinned to the top-right corner of the ENTIRE PAGE, not the card —
almost never the intended result.
*/.card {
position: relative; /* the ONLY change needed */
padding: 20px;
background: #f1f5f9;
}
.badge {
position: absolute;
top: 10px;
right: 10px;
background: red;
color: white;
}
/*
Now .card has a non-static position, so it becomes .badge's containing
block. "top: 10px; right: 10px" is now measured from .card's own
padding-relative corner, so the badge sits correctly inside the card.
This exact pattern — position: relative on a parent purely to give an
absolutely positioned child a local containing block, with no visual
shift applied to the parent itself — is by far the most common reason
you will ever write position: relative in real CSS.
*/The containing block search skips past static ancestors entirely
The search for a containing block walks straight up the ancestor chain and stops at the first non-static ancestor found — it does not stop at the nearest parent regardless of position, and it does not stop at the first block-level container. A deeply nested span five levels down, inside a chain where only the outermost wrapper is position: relative, will use that outermost wrapper as its containing block, skipping every static element in between.
<div class="outer"> <!-- position: relative -->
<div class="middle"> <!-- position: static (default) -->
<div class="inner"> <!-- position: static (default) -->
<span class="tooltip">Hi</span> <!-- position: absolute -->
</div>
</div>
</div>
/*
.tooltip's containing block is .outer — not .middle, not .inner —
because those two are static and get skipped entirely in the search.
*/position: fixed and sticky — Viewport-Relative and Hybrid Positioning
position: fixed works almost identically to absolute — removed from flow, positioned via offsets — with one crucial difference: its containing block is normally the viewport itself, not any ancestor element, which means it stays glued to the same spot on screen even as the page scrolls.
.site-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 64px;
background: white;
z-index: 100;
}
/* IMPORTANT: since .site-header is removed from flow, the content below
it will scroll up UNDER it unless you add matching top padding/margin
to the next element (or the body) equal to the header's height. */
body { padding-top: 64px; }position: fixed element has a CSS transform, filter, perspective, or will-change: transform property set, that ancestor becomes the fixed element's containing block instead of the viewport — the "fixed" element then scrolls along with that ancestor, breaking the effect. This is a genuinely common source of "why is my fixed header scrolling away" bugs, usually caused by an animation library or a CSS transform applied somewhere up the tree for an unrelated reason.position: sticky — flows normally, then locks in place
position: sticky is a hybrid: the element behaves like position: relative (stays in normal flow, taking up its original space) until the page scrolls to the point where it would cross the threshold you specify — at which point it "sticks" and behaves like position: fixed relative to its nearest scrolling ancestor, until its parent container scrolls out of view entirely, at which point it unsticks again.
.section-heading {
position: sticky;
top: 0; /* required — sticky needs at least one offset to know its threshold */
background: white;
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
}
<!--
As the user scrolls, .section-heading scrolls normally until it
reaches the top of the viewport (top: 0), then sticks there. Once
its parent .section scrolls fully past, it scrolls away with it —
it does NOT stay pinned across sibling sections.
-->position: sticky requires an explicit top (or bottom/left/right) value to work at all — without one, the browser has no threshold to stick at, and the element behaves exactly like relative. A second, very common gotcha: sticky silently stops working if any ancestor has overflow: hidden, overflow: auto, or overflow: scroll — the sticky element can only stick within the boundaries of the nearest ancestor that establishes a scrolling context, and a clipped overflow container breaks that.z-index — What Genuinely Creates a Stacking Context
z-index controls which element renders on top when two positioned elements overlap — but only among elements that share the same stacking context. This is the part almost every tutorial oversimplifies: z-index does not simply compare numbers globally across the whole page. A misunderstanding here is the root cause of the extremely common bug "I set z-index: 9999 and it still renders behind this other element."
z-index value does not, by itself, create a new stacking context — it only has an effect at all on an element with a non-static position (relative, absolute, fixed, or sticky). A z-index on a position: static element is silently ignored entirely.The real, complete list of what creates a new stacking context
A new stacking context is created by any of the following — this list is considerably longer than most people expect, and several entries create one without any explicit z-index at all:
/* Positioned + z-index other than auto */
.a { position: relative; z-index: 1; }
.b { position: absolute; z-index: 0; }
.c { position: fixed; z-index: 1; }
.d { position: sticky; z-index: 1; }
/* opacity less than 1 — even with position: static */
.e { opacity: 0.99; }
/* transform, filter, perspective — any value other than none */
.f { transform: translateZ(0); }
.g { filter: blur(0px); }
/* will-change, if it names a property that would itself create one */
.h { will-change: transform; }
/* isolation — exists SPECIFICALLY to create a stacking context deliberately */
.i { isolation: isolate; }
/* mix-blend-mode other than normal */
.j { mix-blend-mode: multiply; }
/* the root element <html> always is one, implicitly */The consequence that matters in practice: once an element creates a new stacking context, every descendant's z-index is compared only against its siblings inside that same context — it can never climb "above" an element outside the context, regardless of how large its z-index number is. A child with z-index: 999999 is still trapped entirely underneath a sibling of its stacking context's root, if that root itself has a lower z-index than some other element on the page.
<div class="modal-wrapper"> <!-- position: relative; z-index: 1 -->
<div class="modal"> <!-- position: absolute; z-index: 999999 -->
Modal content
</div>
</div>
<div class="dropdown"> <!-- position: relative; z-index: 2 -->
Dropdown menu
</div>
/*
.modal has an enormous z-index, but it is trapped INSIDE the stacking
context created by .modal-wrapper (z-index: 1). .dropdown, at the top
level with z-index: 2, beats the entire .modal-wrapper context outright
— so .dropdown renders on top of .modal, no matter how high .modal's
own z-index climbs. The fix is not a bigger number; it is raising
.modal-wrapper's z-index above .dropdown's, or restructuring so .modal
is not nested inside a lower-stacked context at all (e.g. rendering it
via a portal at the document root, a common React/Next.js pattern).
*/A Modal That Renders Behind the Navbar at a Seattle Fintech Startup
A support ticket comes in: on the payments dashboard, clicking "Confirm Transfer" opens a confirmation modal — but the modal renders underneath the sticky top navbar, cutting off its top third and making the confirm button unreachable on smaller screens. The engineer assigned the bug pulls up the CSS and immediately bumps the modal's z-index from 50 to 9999. It does not fix anything.
.dashboard-shell {
position: relative;
}
.top-navbar {
position: sticky;
top: 0;
z-index: 50;
}
.transaction-panel {
position: relative;
z-index: 10;
transform: translateZ(0); /* added months earlier for a scroll-performance fix */
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 9999; /* bumped by the engineer — still renders behind the navbar */
}What the engineer finds after actually reading the DOM tree
The modal component is not rendered at the document root — it is rendered directly inside .transaction-panel, in the exact spot in the React tree where the "Confirm Transfer" button lives, rather than through a portal. And .transaction-panel has transform: translateZ(0), added months earlier by a different engineer chasing an unrelated scroll-jank fix — exactly the kind of property from Part 07's list that creates a stacking context without anyone intending it to. The modal's z-index: 9999 is real, but it is trapped entirely inside .transaction-panel's stacking context, which itself only has z-index: 10 — comfortably below the navbar's 50.
// Instead of rendering <Modal /> inline inside TransactionPanel's JSX,
// render it through a portal attached to document.body:
import { createPortal } from 'react-dom'
function Modal({ children }) {
return createPortal(
<div className="modal-overlay">{children}</div>,
document.body
)
}
/*
Now .modal-overlay is a direct child of <body> in the actual DOM,
completely outside .transaction-panel's stacking context. Its
z-index: 9999 is now compared against .top-navbar's z-index: 50 at
the SAME level, and 9999 correctly wins.
*/The team also adds a short comment above every transform, filter, and will-change declaration in the codebase noting that it creates a stacking context — a small process change, but it turns "invisible side effect" into "documented and searchable" the next time someone chases a similar bug.
Five Misconceptions About Display and Positioning
6 Interview Questions — With Complete Answers
Display & Positioning Mistakes Beginners Make Constantly
Rendering Bugs You Will Hit — And Exactly Why
🎯 Key Takeaways
- ✓display: block takes full available width and forces line breaks; display: inline sizes to content, flows with text, and ignores width/height/vertical margin & padding entirely.
- ✓display: inline-block combines inline flow placement with full block-level sizing — its one famous gotcha is the whitespace gap caused by literal newlines between elements in the HTML source.
- ✓position: static (the default) ignores top/right/bottom/left/z-index completely. Any of relative/absolute/fixed/sticky is required before offsets do anything.
- ✓An absolutely positioned element's containing block is the NEAREST ancestor with a non-static position, skipping every static ancestor in between — falling back to the <html> element if none exists.
- ✓position: relative is most often used not for its own visual shift, but purely to give an absolutely positioned descendant a local containing block.
- ✓position: fixed anchors to the viewport by default — but a transform, filter, perspective, or will-change on any ancestor silently changes its containing block instead.
- ✓position: sticky requires an explicit offset (e.g. top: 0) to work, and silently breaks if any ancestor has overflow: hidden/auto/scroll.
- ✓z-index only compares elements within the SAME stacking context, and many properties besides z-index create a new one — opacity below 1, transform, filter, will-change, isolation, and mix-blend-mode among them. A trapped child can never out-stack an element outside its ancestor's context, regardless of its own z-index value.
What comes next
Module 22 moves from structure to visual polish — background properties and the shorthand, linear and radial gradients, border-radius (including elliptical corners), and stacking multiple box-shadows for real depth.
Module 22 → Backgrounds & BordersDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.