Appearance
6.2.5 — Stacking, Positioning & Responsive Design
A modal dialog with z-index: 9999 renders behind a site header with z-index: 10. Raising it to 999999 changes nothing. This is not a browser bug and it is not a specificity problem; it is the most misunderstood rule in CSS, and it takes about two minutes to explain properly.
1. position, and the containing block that goes with it
Before stacking, positioning — because the two are tangled together.
static is the default: the element sits where normal flow puts it, and top/right/bottom/left and z-index are ignored entirely.
relative keeps the element in the flow — the space it occupied is still reserved — and then shifts the painted box by the offsets you give. Nothing else moves. Its main job in practice is not the shifting at all; it is to become a positioning ancestor for an absolutely positioned child.
absolute removes the element from the flow completely. Other elements lay out as if it does not exist, and it is positioned against its containing block.
fixed also leaves the flow and is positioned against the viewport, so it stays put while the page scrolls.
sticky is a hybrid, covered in its own section below.
What "containing block" means, precisely
This is the term that decides where an absolutely positioned element actually lands, and getting it wrong produces the "why is my dropdown in the corner of the page" bug.
For position: absolute, the containing block is the padding box of the nearest ancestor whose position is anything other than static. If there is no such ancestor, it is the initial containing block — effectively the document.
So the standard pattern is: the parent gets position: relative purely as an anchor, and the child positions against it.
css
.dropdown-anchor { position: relative; } /* (1) the anchor */
.dropdown-menu { position: absolute; top: 100%; inset-inline-start: 0; } /* (2) */Line (1) does nothing visible — no offsets are given — but it establishes the containing block. Line (2) puts the menu immediately below the anchor's bottom edge (top: 100% means 100% of the containing block's height) and aligned to its start edge. Remove line (1) and the menu positions against the document instead, which is how a dropdown ends up at the top of the page.
For position: fixed, the containing block is normally the viewport — but not always, and the exception is one of the nastiest bugs in CSS.
An ancestor with any of transform, filter, backdrop-filter, perspective, contain: paint, or will-change naming one of those becomes the containing block for its fixed descendants. The fixed element then scrolls with that ancestor instead of staying put.
css
/* Somebody adds a hover lift to the card. */
.card:hover { transform: translateY(-2px); }
/* And this, inside the card, quietly stops being fixed. */
.card .fullscreen-overlay { position: fixed; inset: 0; } The overlay is fixed relative to the card now, not the window, so it covers the card rather than the screen — and only while hovering, which makes it maddening to reproduce. The reason is not arbitrary: a transform changes the coordinate system of a whole subtree, and a descendant cannot be positioned in two coordinate systems at once, so the specification resolves it in favour of the transform.
The reliable fix is to not nest the overlay there. Render modals and full-screen overlays as children of <body> — which is exactly what React portals exist for (Chapter 6.4.4) — so no ancestor can capture them.
position: sticky, and the four reasons it silently does nothing
Sticky behaves as relative until the element reaches a scroll offset you specify, then behaves as fixed within its parent, and stops when the parent's bottom edge passes.
css
.section-heading { position: sticky; top: 0; }It fails silently more often than any other CSS feature, always for one of these four reasons:
No threshold given. position: sticky with no top, bottom, left or right never sticks, because you have not said when. This is the most common one and there is no warning.
An ancestor has overflow set to hidden, scroll or auto. Sticky positions against the nearest scrolling ancestor, so an overflow: hidden wrapper — often added for an unrelated reason three months earlier — becomes the scroll container, and inside it there is no scrolling, so nothing ever sticks.
The parent is exactly as tall as the element. Sticky can only move within its parent. A heading whose parent wraps only the heading has zero room, so it appears not to work when in fact it stuck and immediately ran out of space.
The parent is a flex or grid container with the default align-items: stretch and the sticky element got stretched to the full height, producing the previous problem. align-self: start restores the room.
2. Stacking contexts, and the modal that lost
Within a page, boxes are painted in a defined order. Ignoring the fine detail, it runs: backgrounds and borders of the current context, then non-positioned block boxes, then floats, then inline content, then positioned elements sorted by z-index. That is why a positioned element with no z-index still paints over a static sibling — position alone lifts it.
Now the crucial part. A stacking context is a self-contained layer group. Every element inside it is painted together, and its z-index values only compete with each other — never with anything outside.
The consequence is the whole of the opening puzzle: an element's z-index is only ever compared with its siblings inside the same stacking context. A child of a context with z-index: 10 can never escape above a sibling of that context with z-index: 20, no matter what number it carries. The parent's position in the order is fixed, and everything inside it travels with the parent.
What creates a stacking context
Some of these are obvious and some are not, and the surprising ones are where the bugs come from:
- The root
<html>element. position: relative/absolutewith anyz-indexother thanauto— includingz-index: 0, which is not the same asauto.position: fixedorsticky— always, with or without az-index.opacityless than 1. Yes,opacity: 0.99creates one.transform,filter,backdrop-filter,perspective,mix-blend-mode,mask,clip-path.will-changenaming any property that would create one.isolation: isolate— which exists for exactly this purpose and nothing else.contain: paintorcontain: strict, andcontent-visibility.- A flex or grid item with a
z-indexother thanauto.
The ones that catch people are opacity, transform and filter, because nobody writing a fade-in animation is thinking about paint order. A wrapper with opacity: 0.99 — sometimes added as a hack to force GPU rendering — silently traps every z-index inside it.
Fixing it properly
Diagnose first. In the browser's element inspector, walk up from the element that will not come forward and look for the first ancestor that creates a context. Some browsers label these directly in the layers panel. The ancestor you find is what is actually competing.
Then pick one of three fixes:
Render the overlay outside the trapped subtree. A modal, a tooltip and a dropdown belong as children of <body>, because they are visually on top of everything and structurally part of nothing. React's createPortal does this while keeping the component's place in the React tree (Chapter 6.4.4). The <dialog> element goes further and is covered in Chapter 6.8.2 — it renders in the browser's top layer, which sits above every stacking context by definition.
Remove the accidental context. If the wrapper's opacity: 0.99 served no purpose, delete it.
Use isolation: isolate deliberately. Put it on a component root and every z-index inside becomes local — a component can then use z-index: 1 and z-index: 2 internally with a guarantee that nothing leaks out and nothing outside interferes. This is the tidy answer for design-system components.
And the discipline that prevents the whole class of problem: define a small set of named layers as custom properties and never write a raw number.
css
:root {
--z-dropdown: 100;
--z-sticky-header: 200;
--z-modal-backdrop: 300;
--z-modal: 310;
--z-toast: 400;
}Five numbers with names beats forty numbers where the largest is 2147483647, which is a real value found in real codebases and is the maximum 32-bit signed integer — the point at which someone gave up.
3. Media queries, and the mobile-first ordering that is not just a slogan
css
/* Base: the narrow layout, no query needed. */
.grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
/* Then progressively add complexity as room appears. */
@media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(4, 1fr); } }Why min-width and not max-width? Two concrete reasons, not a preference.
The single-column layout is genuinely simpler, so it makes the better base — the queries then only add, and each rule you read is an addition rather than an undo. With max-width you write the complex desktop layout first and then unpick it, which means every mobile rule is a correction and the CSS reads backwards.
The second reason is what happens when the CSS is incomplete or a query fails to match: the fallback is the base, and the base being the simple layout is the safe failure. A device that matches nothing gets a usable single column rather than a broken four-column grid.
Mixing both directions is where it goes wrong, because overlapping ranges make it genuinely hard to say what applies at 768 pixels. Pick a direction and keep it.
Choose breakpoints from your content, not from device names. "iPad is 768" was already wrong when it was written and is more wrong now. Widen the browser slowly and add a breakpoint where the layout starts to look bad. That is a real signal; a device list is a snapshot of 2014.
The media features worth knowing beyond width
css
/* The user prefers a dark interface at the OS level. */
@media (prefers-color-scheme: dark) { :root { --surface: #16181d; } }
/* The user has asked the OS to reduce motion. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* A device whose primary pointer cannot hover — a touchscreen. */
@media (hover: none) { .tooltip-on-hover { display: none; } }
/* A coarse pointer: make targets bigger. */
@media (pointer: coarse) { .icon-button { min-block-size: 44px; min-inline-size: 44px; } }prefers-reduced-motion is not optional decoration. Vestibular disorders are real, and large parallax or zoom animations cause genuine nausea and dizziness for a meaningful number of people. The block above is the standard blunt implementation and it is better than nothing; the considered version replaces movement with a cross-fade rather than removing the transition, so the interface still communicates that something changed.
hover: none and pointer: coarse beat width for the thing they actually test. A hover-only tooltip is unreachable on a touchscreen, and a touchscreen can be 1400 pixels wide. Testing the input device directly is correct where guessing from the screen size is not.
The range syntax is now widely supported and reads much better than the min-/max- prefixes:
css
@media (width >= 640px) { … }
@media (400px <= width <= 900px) { … }4. Container queries, which are the fix media queries could not be
A media query asks about the window. A component does not care about the window; it cares about the space it has been given. A card in a 300-pixel sidebar and the same card in a 900-pixel main area need different layouts on the same screen, and no media query can express that.
css
/* (1) Declare the parent as a query container. */
.card-slot {
container-type: inline-size;
container-name: card;
}
/* (2) Now query the CONTAINER's width, not the viewport's. */
@container card (width >= 480px) {
.card { display: grid; grid-template-columns: 160px 1fr; gap: 16px; }
}Line (1) marks the element as a container and gives it a name. inline-size means "query the width only", and that restriction is not arbitrary — it is what keeps the query safe.
Line (2) applies when that container is at least 480 pixels wide. The same .card component now lays out correctly in a sidebar, in a modal, in a two-column grid and on a phone, with one set of rules and no knowledge of where it was placed. For component-driven codebases this is the single most important CSS feature of the decade, because it finally makes a component's styling as self-contained as its markup.
Two things to know before using it.
container-type: inline-size applies size containment in the block direction, which means the container's height can no longer be determined by its children in the way you might expect. In practice this bites when the container was relying on content to set its height in an unusual layout; usually nothing changes.
The reason there is no container-type: size by default is the infinite loop. If a container's width could depend on its children, and the children's layout depended on the container's width, the browser would have no fixed point to resolve. Containment breaks the cycle, and it is the price of admission.
Container query units measure against the container instead of the viewport: cqw, cqh, cqi (inline size), cqb (block size), cqmin, cqmax.
css
.card-title { font-size: clamp(1rem, 5cqi, 1.5rem); }The title scales with the card's own width rather than the window's, which is exactly what the vw-based version could never do correctly.
5. Responsive images
Two different problems get confused with each other, and each has its own tool.
Resolution switching — same image, different sizes. Use srcset and sizes and let the browser choose:
html
<img src="/hero-800.jpg"
srcset="/hero-400.jpg 400w,
/hero-800.jpg 800w,
/hero-1600.jpg 1600w"
sizes="(width >= 1024px) 50vw, 100vw"
width="1600" height="900"
alt="Two people assembling a bicycle in a workshop">srcset lists the candidates with their real pixel widths — the w value is the file's actual width, not a breakpoint. sizes tells the browser how wide the image will be displayed at a given viewport, which it needs because it picks the file before any CSS has been applied. The browser combines the two with the device pixel ratio and picks one.
sizes is the part people get wrong, and getting it wrong is silent: the browser downloads a needlessly large file and the page is just slower. If the image is half the viewport on desktop, say 50vw; do not leave the default, which assumes full width.
Art direction — a different crop for a different shape of screen. That is <picture>, because the decision is yours rather than the browser's:
html
<picture>
<source media="(width < 640px)" srcset="/hero-square.jpg">
<source type="image/avif" srcset="/hero.avif">
<img src="/hero.jpg" alt="…" width="1600" height="900">
</picture>The browser takes the first matching <source>, so order matters and the most specific goes first. type lets you offer a modern format with an automatic fallback — a browser that cannot decode AVIF skips that source silently. The <img> at the end is mandatory: it is the actual element, it carries the alt, and it is the fallback.
What the interviewer will push on
"A modal with z-index: 9999 is behind the header. Explain." z-index only competes within a stacking context. An ancestor of the modal creates one and that ancestor is losing to the header. Name the surprising creators — opacity below 1, transform, filter — and give the fix: portal the modal to <body>, or remove the accidental context, or use isolation: isolate to make component layering local.
"Why did my position: fixed element stop being fixed?" An ancestor has a transform, filter or will-change, which makes it the containing block for fixed descendants. Volunteer that a :hover transform makes this appear only intermittently, which is what makes it hard to reproduce.
"My sticky header does nothing. What do you check?" In order: is there a top value at all; does an ancestor have overflow set to anything but visible; is the parent tall enough for it to travel in; is it a stretched flex item. The first is the most common and there is no error to tell you.
"Mobile-first or desktop-first, and why?" Mobile-first with min-width, because the simple layout is the better base, every query then only adds, and the failure mode when nothing matches is a usable single column. Not a style preference — a reasoning about defaults.
"What do container queries solve that media queries cannot?" A component's layout depends on its available space, not the window. Same component, sidebar and main area, one screen. Then mention the containment requirement and why it exists — the circular dependency between container size and content size.
"Difference between srcset and <picture>?" srcset is resolution switching where the browser decides; <picture> is art direction where you decide. Then note that sizes is the part that silently costs bandwidth when it is wrong.
One thing to volunteer: bring up prefers-reduced-motion unprompted, and specifically the point that the good implementation replaces motion with a cross-fade rather than deleting the transition — the user still needs to perceive that something changed. It signals that you treat accessibility as design work rather than a checklist item.
Recall
- The containing block decides where an absolutely positioned element lands: the nearest ancestor with
positionother thanstatic. That is why the anchor pattern isposition: relativeon the parent with no offsets. position: fixedis captured by an ancestor withtransform,filter,perspective,contain: paintorwill-change— a hover transform makes the bug intermittent. Render overlays as children of<body>instead.position: stickyfails silently for four reasons: no threshold, an ancestor withoverflownotvisible, a parent with no spare height, or being a stretched flex item.- A stacking context is a sealed layer group.
z-indexonly competes with siblings inside the same context, so a trapped 9999 loses to an outside 10. Created by positioning with az-index,fixed/sticky,opacitybelow 1,transform,filter,will-change,isolation: isolate,contain: paint. - Fixes: portal the element out, delete the accidental context, or scope layering deliberately with
isolation: isolate. Keep a named set of z-index custom properties rather than raw numbers. - Mobile-first
min-widthbecause the simple layout is the safe base and every query then only adds. Choose breakpoints from where the layout breaks, not from device names. - Beyond width:
prefers-reduced-motion(a real accessibility need, not decoration),prefers-color-scheme, andhover: none/pointer: coarse, which test the input device rather than guessing from screen size. - Container queries ask about the component's available space, so one component works in a sidebar and a main column at once.
container-type: inline-sizeapplies containment to break the circular dependency, andcqiunits scale with the container. srcset+sizesis resolution switching (the browser chooses; a wrongsizessilently wastes bandwidth).<picture>is art direction (you choose, first match wins,<img>is mandatory).
Self-test: Why does z-index: 9999 sometimes lose to z-index: 10? · Which property added for a hover effect can break position: fixed? · Name three reasons sticky silently does nothing · Why is mobile-first about defaults rather than taste? · What circular dependency do container queries have to break? · When is <picture> right and srcset wrong?
Next: 6.3.1 leaves CSS behind and picks up the other half of the page — the object model your JavaScript manipulates, and the event system that decides which of six overlapping elements receives a click.