CSS: box model, layout and positioning
Everyone has written CSS and almost nobody can say what it computed. That gap is the entire interview: box model arithmetic, which ancestor an absolutely positioned element measures from, why a z-index of 9999 loses to a 1, and what flex: 1 actually expands to. Learn the values the browser resolves rather than the properties you type, and the trick questions stop being tricks.
How a declaration wins
Every question about 'why isn't my style applying' is answered by the same four-step sort, in this order. First origin and importance: user-agent styles lose to author styles lose to inline styles, except that !important flips the whole ordering within its origin, which is why a user's important rule beats everything you write. Second specificity. Third, and only when everything above ties, source order - last one wins.
Specificity is three numbers, not one: (ids, classes, everything else). Count id selectors, then class/attribute/pseudo-class selectors, then type/element and pseudo-element selectors. Compare left to right and stop at the first difference - 1-0-0 beats 0-99-0, because it is a comparison of tuples, not a sum. The universal selector * and combinators like > and + count zero. :not(), :is() and :has() count as their most specific argument; :where() always counts zero, which is what makes it the tool for shipping overridable defaults.
Then two things that sit outside that sort. An inline style attribute behaves as if it were more specific than any selector - usefully thought of as 1-0-0-0. And an author !important beats an inline style that is not itself important. So the ladder from weakest to strongest is: author rule, inline style, author !important, inline !important.
- Inheritance is a different mechanism from the cascade
- The cascade decides which declaration wins for an element that MATCHED a selector. Inheritance is what happens to an element that matched nothing: text properties (color, font, line-height, visibility) inherit from the parent, box properties (margin, padding, border, background, display) do not. inherit, initial, unset and revert let you force it either way - unset means 'inherit if this property is inheritable, otherwise initial'.
- Computed, used, and what getComputedStyle gives you
- A declared width: 50% computes to 50% and is only USED as a pixel value once layout runs and the containing block is known. getComputedStyle mostly hands you the used value for layout properties, which is why it returns pixels for a percentage width and why reading it forces the browser to finish layout first.
Questions
Answer each one yourself before you open it.
01Why does CSS exist at all - why not style in the markup?
So that presentation lives in one place and the document keeps its meaning. One stylesheet restyles a thousand pages, it caches separately from the HTML, and the same markup can be laid out differently for a phone, a printer or a screen reader.
The historical version is more convincing: HTML briefly had font, center, bgcolor and align, and every visual change meant editing every page. Pulling presentation out is also what made the accessibility and search stories work - the document is a document, and the look is an opinion applied to it.
02Describe the box model.
Every element is four nested rectangles: content, then padding, then border, then margin. Background paints under content and padding and out to the border; margin is always transparent and is the space between this box and its neighbours.
Follow-up Where does the width property land in that?
That depends entirely on box-sizing. In the default content-box, width sizes the CONTENT rectangle and padding and border are added on top. In border-box, width sizes out to the border edge and padding and border eat into it. Margin is never included either way.
03content-box or border-box - which do you use, and why does everyone set border-box?
border-box, globally. It makes width mean the number you actually care about: the visible width of the box on screen. With content-box, adding padding makes the element wider, which breaks every layout that had to add up to 100%.
The arithmetic is the argument. width: 300px with 20px of padding and a 5px border is 350px wide in content-box and 300px in border-box. In a grid of three columns at 33.33% each, one padding declaration overflows the row in content-box and changes nothing in border-box. The historical joke is that this is the box model Internet Explorer's quirks mode had all along.
Follow-up How do you turn it on globally?
*, *::before, *::after { box-sizing: border-box; } is the simple version. The more careful version sets html { box-sizing: border-box } and then * { box-sizing: inherit }, so a third-party component can opt a subtree back out with a single rule.
04What is margin collapsing?
Adjacent vertical margins merge into one, and the merged margin is the LARGER of them, not the sum. It happens between siblings, between a parent and its first or last child, and on an empty element's own top and bottom margins.
The parent-and-child case is the one that produces the bug report: a child with margin-top: 50px and a parent with no padding, border or content above it pushes the PARENT down 50px instead of moving itself inside. Nothing in the DOM looks wrong; the margin has escaped. Horizontal margins never collapse, and neither do margins inside flex, grid, or any element that establishes a new block formatting context.
Follow-up How do you stop it?
Put anything between the two margins - one pixel of padding or border on the parent - or make the parent a new block formatting context with overflow: hidden/auto, display: flow-root, or by making it a flex or grid container. display: flow-root is the modern, side-effect-free answer. Or stop using vertical margins for spacing and use gap, which never collapses.
05Run through the selector types.
Universal *, type (div), class (.a), id (#a), attribute ([type='text'], with ^= $= *= ~= variants), and the combinators: descendant (space), child (>), adjacent sibling (+), general sibling (~). Then pseudo-classes (:hover, :nth-child, :focus-visible, :not, :is, :where, :has) and pseudo-elements (::before, ::after, ::placeholder, ::first-line, ::selection). Commas group.
Follow-up What is :has for?
The parent selector CSS did not have for twenty years. .card:has(img) styles a card because of what is inside it, and li:has(+ li:hover) reaches sideways. Its specificity is that of its most specific argument, and it is not a performance disaster the way the old folklore assumed.
06How is specificity calculated?
As three numbers: ids, then classes plus attributes plus pseudo-classes, then elements plus pseudo-elements. You compare them left to right, so a single id beats any number of classes. Ties are broken by source order, last one wins.
The mistake is adding them up. #a is 1-0-0 and .a.b.c.d.e.f is 0-6-0, and the id wins outright because the comparison stops at the first column that differs. * and combinators contribute nothing. Inline styles sit above all of it, and !important sits above that.
Follow-up So how do you get out of a specificity war?
Lower the winning selector rather than raising the losing one. Flatten to single classes, stop styling by id, and wrap defaults in :where() so they carry zero specificity and any later rule beats them. If you have to bump something in place, duplicating a class in the selector (.btn.btn) is a cheaper hack than !important because it can still be overridden.
07When is !important acceptable?
In utility classes that are meant to be the final word, and when overriding a third-party stylesheet you cannot edit. Inside your own code it is almost always a sign you have lost a specificity argument and should have flattened the selector.
The reason to be strict is that !important escalates: the only thing that beats it is another !important later in the file, so the first one guarantees the second. The one place it is genuinely correct is accessibility - a user's own important rule is designed to beat everything an author writes, and that is by design in the cascade.
08What are the values of position?
static, relative, absolute, fixed and sticky. static is the default and means the element sits in normal flow and ignores top/right/bottom/left and z-index entirely.
09relative versus absolute - precisely.
relative keeps the element in normal flow - the space it occupied is still reserved - and then offsets it visually from where it would have been. absolute removes it from flow entirely, so the surrounding content closes up, and positions it against the nearest ancestor whose position is not static.
The second half is the part that gets tested. If no ancestor is positioned, an absolute element positions against the initial containing block - effectively the page, not the viewport, so it scrolls away. That is why 'position: relative on the parent, absolute on the child' is a formula: the relative parent exists only to become the reference frame, and it moves nothing itself.
Follow-up Does anything other than position create that reference frame?
Yes - transform, filter, perspective, will-change, contain: paint and backdrop-filter all make an element a containing block for absolutely positioned descendants, and for fixed ones too. That is why a fixed-position modal suddenly positions itself inside a card: some ancestor has a transform on it, often from an animation library.
10How does sticky work?
The element stays in normal flow and scrolls with the page until it hits the threshold you set - top: 0, say - and then it sticks there while its containing block is still on screen. It is relative and fixed in sequence, with no JavaScript.
It fails silently in three ways, and knowing them is the whole answer. One: you did not set a threshold, and sticky with no top/bottom/left/right never sticks. Two: an ancestor has overflow: hidden, auto or scroll, which makes THAT the scroll container and the element sticks inside a box you cannot see. Three: it only sticks within its parent, so a sticky header in a short section unsticks as soon as that section scrolls past.
11How do you put text on top of an image?
Two ways. Either a container with position: relative holding an img and an absolutely positioned overlay - which keeps the image in the document as a real, alt-able image. Or set the image as a background-image on the element and put the text inside it as normal content.
The choice is semantic, not aesthetic. If the image carries information - a photo of the product, a person, a chart - it must be an img with alt text, so you use the positioned overlay. If it is decoration behind text, background-image is right and it is invisible to assistive tech, which is exactly what you want. A third modern option: make the container a grid and place both children in the same grid area, which stacks them with no positioning at all.
12Pseudo-classes versus pseudo-elements?
A pseudo-class selects an element that is already there, in a particular state - :hover, :focus, :nth-child, :disabled. A pseudo-element creates or targets a piece of the element that is not in the DOM - ::before, ::after, ::first-line, ::placeholder. One colon for the class, two for the element.
The single-colon form of ::before still works because CSS2 used one colon for both and browsers keep it for compatibility. Write two. And ::before and ::after do not render at all without a content property - content: '' is the minimum, which is the single most common reason a pseudo-element 'does not work'. They are also children of the element, so they cannot exist on replaced elements like img or input.
13Why does my z-index: 9999 lose to a z-index: 1?
Because they are in different stacking contexts. z-index only sorts against siblings within the same context - if the 9999 element's ancestor forms a stacking context that paints below the other element's, no number inside it can climb out.
The things that create a stacking context are more numerous than people expect: any positioned element with a z-index other than auto, plus opacity less than 1, transform, filter, will-change, mix-blend-mode, isolation: isolate, contain: paint, and a flex or grid item with a z-index. So a parent with opacity: 0.99 - a fade animation mid-flight - traps every descendant. The fix is to raise the ANCESTOR, or to stop creating the context.
Follow-up Does z-index work on a static element?
No. It applies to positioned elements, and to flex and grid items. On a plain static block it is inert, which is the other half of the 'my z-index does nothing' bug.
14What display values do you know?
block, inline, inline-block, flex, inline-flex, grid, inline-grid, none, contents, flow-root, list-item, and the table family (table, table-row, table-cell). Modern CSS treats it as two values - outer display (how the box behaves in its parent) and inner display (how it lays out its children) - so display: block flow-root is the long form of flow-root.
Follow-up What is display: contents for?
It removes the element's own box while keeping its children, so a wrapper div can stop interrupting a flex or grid layout and its children become items of the grandparent. Use it carefully - for a long time it also removed the element's semantics from the accessibility tree, and that bug still exists in older browsers.
15Why does an image leave a gap underneath it in a div?
Because img is inline by default, so it sits on the text baseline, and the space below the baseline is reserved for descenders. The fix is display: block on the image, or vertical-align: middle/bottom, or making the container a flex container.
16Centre a div, three ways.
One: flex - display: flex; justify-content: center; align-items: center on the parent. Two: grid - display: grid; place-items: center, which is one declaration. Three: absolute - position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) on a relatively positioned parent.
The one to name for horizontal-only centring of a block is margin: 0 auto, and the reason it is often reported as 'not working' is that it does nothing unless the element has a width narrower than its container: with width: auto the block already fills the line and the auto margins compute to zero. The translate trick is the one for when you cannot touch the parent's layout, and it works without knowing the element's size because a percentage in translate resolves against the element itself.
17Explain flexbox.
One-dimensional layout. You set display: flex on a container, which lays its children along a main axis - horizontal by default - with a cross axis at right angles. flex-direction picks the main axis, justify-content distributes along the main axis, align-items aligns on the cross axis, and gap puts space between items without touching margins.
The pairing is the thing to say correctly: justify-content is always the MAIN axis and align-items is always the cross axis, so the moment you write flex-direction: column they swap meaning - justify-content now controls vertical distribution. Everything else follows: flex-wrap decides whether items overflow or wrap, align-content only does anything on a wrapped multi-line container, and align-self overrides align-items for one item.
18What does flex: 1 actually mean?
It is shorthand for flex-grow: 1, flex-shrink: 1, flex-basis: 0%. Basis zero is the important part: it tells the item to ignore its own content width when the free space is divided, so equal flex: 1 items come out exactly equal.
Contrast it with flex: auto, which is 1 1 auto - items grow from their content size, so the one with more text ends up wider. And flex: none is 0 0 auto, meaning fixed at content size. If you want columns of identical width, flex: 1 is right and flex: auto is the bug.
Follow-up Why does my flex item refuse to shrink below its content?
Because a flex item's min-width defaults to auto, which resolves to its content size. A long unbroken string or a nested element with an intrinsic width sets a floor that shrinking cannot cross. min-width: 0 (or overflow: hidden) on the item is the fix, and it is the standard reason a flex row overflows its container.
19flex-basis or width?
flex-basis is the starting size along the main axis before growing and shrinking, and it wins over width when the item is a flex item in a row. In a column direction, flex-basis governs height instead - that is the point of 'along the main axis'.
20Grid or flexbox?
Flexbox for one dimension: a row of buttons, a nav bar, a card's footer - content-driven, where the items size themselves and you distribute what is left. Grid for two dimensions at once: a page layout, a gallery, anything where rows AND columns have to line up across independent children.
The sharper distinction is who decides the size. Flexbox is content-out: the items say how big they are and the container reacts. Grid is container-in: you declare the tracks and the items land in them, which is why a grid keeps columns aligned across rows and a wrapped flex row does not. They compose - grid for the page, flex inside each region - and 'when in doubt, flex a row, grid a layout' is a fine thing to say out loud.
Follow-up Give me a grid thing flexbox cannot do.
repeat(auto-fit, minmax(240px, 1fr)) - a responsive card grid that fits as many columns as will hold 240px and stretches them to fill, with no media queries at all. Also grid-template-areas, which lets you draw the layout as ASCII art and reposition regions per breakpoint by rewriting one string.
21What is responsive design?
One document that lays out well at any viewport, using fluid sizing, flexible images and media queries for the points where the layout has to genuinely change - rather than a separate mobile site or fixed pixel widths.
Follow-up Mobile-first or desktop-first?
Mobile-first: write the small-screen layout as the base and add complexity with min-width queries. It matches how the constraint works - it is easier to add space than to unwind a layout designed for space you do not have - and it means a phone never downloads and then overrides a pile of desktop rules.
22px, rem, em, %, vh - when do you use which?
rem for typography and most spacing, because it is relative to the root font size and so respects a user who has changed their browser's default. em for things that should scale with the local text - padding inside a button. % for widths relative to the parent. vh/vw for viewport-sized things like a hero. px for hairlines, borders and anything that genuinely is one device-independent pixel.
The accessibility argument for rem is the one to say out loud: a user who sets their browser font size to 20px because they cannot read 16 gets nothing from your px-based type scale, because px does not respond to it. rem does. Note that zoom scales everything including px - it is the font-size setting specifically that px ignores, and that setting is the one people with low vision actually use.
23Why is em risky?
Because it is relative to the element's own font-size, so it compounds through nesting. A list where li { font-size: 0.9em } gets 0.9, then 0.81, then 0.729 as it nests, and the third level is unreadable.
That compounding is also what makes em the right unit inside a single component: padding: 0.5em on a button is half the button's own text size, so one font-size change rescales the whole control. Use em when you want it to track the local text, rem when you want it stable.
24What is wrong with 100vh on mobile?
It historically resolved against the LARGEST viewport - the one with the browser chrome hidden - so a 100vh section was taller than the visible area and the bottom was cut off behind the address bar. The modern answer is the dynamic units: dvh follows the shrinking and growing viewport, svh is the smallest, lvh the largest.
Prefer 100dvh for full-height layouts, with 100vh as the fallback declaration before it. Be aware dvh changes as the address bar collapses, which causes reflow on scroll - for a static hero, svh is often the calmer choice.
25What are CSS custom properties, and how do they differ from Sass variables?
--name: value declared on a selector and read with var(--name, fallback). Unlike a Sass variable they exist at runtime: they inherit down the tree, they can be changed per selector or per media query, and JS can read and write them with getComputedStyle().getPropertyValue and style.setProperty.
That runtime existence is the whole point. A Sass variable is gone by the time the CSS ships - it was a find-and-replace. A custom property can be redefined inside a media query, inside [data-theme='dark'], or on one component, and everything that uses it updates without a rebuild. That is why theming moved to them entirely. They are also just values, so var() cannot be used for a property NAME or a selector, and a custom property holding an invalid value falls back to inherited-or-unset rather than being ignored.
Follow-up Where do you declare them?
The palette on :root so it inherits everywhere, then override the same names on a narrower selector for a theme or a variant. Redefining the token rather than the rule is what keeps a theme to one block instead of a second stylesheet.
26display: none, visibility: hidden, and opacity: 0 - compare them.
display: none removes the box entirely: no space, no events, gone from the accessibility tree. visibility: hidden keeps the space but the element is invisible, not clickable, and removed from the accessibility tree. opacity: 0 keeps the space AND still receives clicks AND is still announced by a screen reader - it is invisible paint over a live element.
opacity: 0 is the dangerous one, because an invisible element that still takes clicks is a bug nobody can see. It is also the only one of the three that is animatable and composited, which is why fades use it - pair it with visibility or pointer-events: none at the end of the transition. And 'hidden from sight but read to screen readers' is a fourth, separate thing: the sr-only pattern, a 1px clipped box, which none of these three give you.
27How do you hide something visually but keep it for screen readers?
The sr-only utility: position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap. Not display: none and not visibility: hidden - both of those remove it from the accessibility tree too.
It is used for skip links, for the text of an icon-only button, and for live-region announcements. The white-space: nowrap matters: a 1px-wide box would otherwise wrap the text one character per line and some screen readers read it that way.
28Which properties are cheap to animate, and why?
transform and opacity. They can be handled by the compositor on their own layer, so the browser skips layout and paint entirely and just re-composites. Animating width, height, top, left, margin or padding forces layout on every frame.
This is the reflow/repaint distinction applied to animation. Moving something with transform: translateX() and moving it with left: look identical and cost completely different amounts - left changes geometry, so the browser re-runs layout for the subtree, repaints and re-composites, 60 times a second. will-change: transform can promote a layer ahead of time, but it costs memory and is worth applying just before the animation, not permanently.
29What is a block formatting context?
An independent layout region: floats inside it do not escape, margins inside it do not collapse with anything outside, and it does not overlap floats outside it. You create one with overflow other than visible, display: flow-root, flex, grid, inline-block, absolute positioning, or a float.
It is the machinery under three separate tricks people learn as folklore: overflow: hidden 'fixing' a collapsed float parent, overflow: auto stopping a child's margin escaping, and a sidebar next to a float not being overlapped. display: flow-root does all of it with no side effects, which is what it was added for.
30How do media queries work, and what else can you query?
@media with a condition wraps rules that only apply when it matches - min-width and max-width most of the time. You can also query prefers-color-scheme, prefers-reduced-motion, hover and pointer (to tell a mouse from a touchscreen), orientation and print.
prefers-reduced-motion is the one people forget and the one that matters: some users get motion sickness from parallax and large transitions, and honouring it is three lines. prefers-color-scheme is how dark mode works with no JavaScript. Container queries - @container - are the newer thing worth naming: they let a component respond to its own width rather than the viewport's, which is what you actually wanted every time you wrote a viewport query for a card.
31How would you debug 'my style is not applying'?
Open the element in devtools and look at the Styles panel: if the declaration is struck through, something more specific won and devtools tells you which rule. If the rule is not listed at all, the selector does not match. If it is listed, applied, and the element still looks wrong, the property does not apply to that display type - width on an inline element, z-index on a static one.
Those are genuinely the only three cases, and naming them as three is a better answer than 'I'd inspect it'. The fourth thing to check is the Computed tab, which shows the final resolved value and the rule that provided it - that is the fastest way to find an inherited value or a custom property resolving to something unexpected.
32float - what was it for and do you use it now?
It was designed to let text wrap around an image, and it was abused for a decade to build column layouts because nothing else could. For layout, no - flex and grid replaced it entirely. For wrapping text around a pull quote or an image, it is still the right tool.
Follow-up What was a clearfix?
A parent containing only floated children collapses to zero height, because floats are out of flow. The clearfix was a ::after pseudo-element with content: '' and clear: both to force the parent to contain them. display: flow-root is the one-line modern replacement.
33How do transitions differ from animations?
A transition interpolates between two states and needs a trigger - a class change, a hover. An animation runs from a @keyframes definition, can have many steps, can loop, and starts on its own. If you need more than from-and-to, or you need it to run without an event, use an animation.
34What is the difference between rem and root font size in practice?
1rem is the computed font-size of the html element, which is 16px unless the user or you changed it. Setting html { font-size: 62.5% } to make 1rem equal 10px is a common trick and it is a bad one - it breaks any user who has raised their default, which is the entire reason to use rem.
35What does the * selector cost?
Nothing you will ever measure. Selector performance was a real concern in 2010 and is not one now - browsers match right-to-left and the work is dominated by layout and paint, not matching. Write the selector that expresses the intent.
36How do you build a dark theme?
Define the palette as custom properties on :root, then redefine only those properties inside @media (prefers-color-scheme: dark) and again under an explicit [data-theme='dark'] selector so a manual toggle can win in both directions. Nothing else in the stylesheet mentions a colour literal.
The failure mode is defining a colour ONLY inside the dark block - then the light case inherits whatever the browser or a parent supplies, and one component comes out transparent or unreadable. Define the full light palette on bare :root and only override in the dark blocks.
37What is the difference between :focus and :focus-visible?
:focus matches whenever the element has focus, including after a mouse click. :focus-visible matches only when the browser thinks a focus ring should be shown - keyboard navigation, mostly. Style :focus-visible so keyboard users get a ring and mouse users do not get one on every click.
This replaced the old and harmful habit of outline: none, which removed the ring for everybody and made the page unusable by keyboard. If you remove the default outline, you owe a replacement on :focus-visible, and it needs contrast against both the element and the page.
38What does overflow: hidden do that people forget?
It creates a block formatting context and it makes the element a scroll container - which is why a sticky descendant stops sticking, and why a dropdown that escapes its parent gets clipped. It also cannot clip in one axis only: setting one axis to hidden makes the other axis auto rather than visible.
39How do CSS and layout interact with the render pipeline?
Style, layout, paint, composite. Changing a geometric property invalidates layout and everything after it - that is a reflow. Changing a colour or a shadow skips layout and repaints. Changing transform or opacity on a composited layer skips both.
The practical rule that falls out: batch your DOM reads and your DOM writes. Reading offsetHeight after a write forces a synchronous layout, and doing that inside a loop is layout thrashing - the browser recomputes the whole thing on every iteration.
40What is specificity of a selector inside :is() versus :where()?
:is() takes the specificity of its most specific argument, so :is(#a, p) is 1-0-0 even when it matched the p. :where() is always zero. That is the entire difference, and it is why :where() is the right wrapper for library defaults and reset rules.
41How do you make an aspect-ratio box?
aspect-ratio: 16 / 9 on the element. The old technique was a padding-bottom percentage hack, because vertical padding percentages resolve against the container's WIDTH - worth knowing because you will still meet it, but nothing new should use it.
42Name a CSS reset and say whether you use one.
Yes - at minimum box-sizing: border-box everywhere, margin zeroed on the body and the text elements, images set to display: block with max-width: 100%, and form controls inheriting the page font. Whether it is normalize.css, a modern reset, or ten lines of your own matters much less than having one.
The reason is consistency of the starting point: every browser ships a user-agent stylesheet with its own margins on headings and lists and its own font on inputs, and without a reset you are debugging differences you did not write. The image rule alone prevents the single most common responsive overflow.
Predict the output
The output is hidden. Predict it before you open it.
Box model arithmetic
// <div id='a'></div> with:
// #a { width: 300px; padding: 20px; border: 5px solid; }
const a = document.getElementById('a');
console.log(a.offsetWidth);
a.style.boxSizing = 'border-box';
console.log(a.offsetWidth);Show the output
350
300offsetWidth is the border-box width. In content-box, 300 is the content and the 40 of padding and 10 of border are added, giving 350. Switching to border-box makes the declared 300 the outer width, so the content shrinks to 250 and nothing else moves. Margin is in neither number.
Margin collapsing between siblings
// <div id='x'></div><div id='y'></div> with:
// #x { height: 50px; margin-bottom: 30px; }
// #y { height: 50px; margin-top: 20px; }
const x = document.getElementById('x').getBoundingClientRect();
const y = document.getElementById('y').getBoundingClientRect();
console.log(y.top - x.bottom);Show the output
30Adjacent vertical margins collapse to the larger of the two, not the sum - so the gap is 30, not 50. This is why a design system that sets margin-bottom on every block still gets consistent spacing, and why adding a margin-top sometimes changes nothing at all.
Margin escaping a parent
// <div id='p'><div id='c'></div></div> with:
// #p { background: grey; }
// #c { height: 40px; margin-top: 50px; }
const p = document.getElementById('p').getBoundingClientRect();
const c = document.getElementById('c').getBoundingClientRect();
console.log(p.top === c.top, p.height);Show the output
true 40The child's top margin collapses THROUGH the parent, because the parent has no border, padding or content above it. So the parent is pushed down instead of the child moving inside, the two tops are identical, and the parent's height is only the child's. One pixel of padding on the parent, or display: flow-root, stops it.
Specificity is a tuple, not a sum
// <div id='box' class='a b c d e f'></div> with, in this order:
// .a.b.c.d.e.f { color: rgb(0, 128, 0); }
// #box { color: rgb(255, 0, 0); }
console.log(getComputedStyle(document.getElementById('box')).color);Show the output
rgb(255, 0, 0)One id is 1-0-0 and six classes are 0-6-0. The comparison runs left to right and stops at the first column that differs, so the id wins outright - six classes never add up to an id. Source order never comes into it, because there is no tie.
!important against an inline style
// <div id='t' style='color: rgb(255, 0, 0)'>hi</div> with:
// #t { color: rgb(0, 0, 255) !important; }
console.log(getComputedStyle(document.getElementById('t')).color);Show the output
rgb(0, 0, 255)An inline style beats any normal author rule, but an author !important beats an inline style. The full author ladder is: normal rule, inline style, !important rule, inline !important. This is the one case where a stylesheet can override a style attribute it cannot edit.
The stacking context trap
// <div id='p'><div id='deep'></div></div><div id='flat'></div> with:
// #p { position: relative; opacity: 0.99; height: 50px; }
// #deep { position: absolute; inset: 0; z-index: 9999; }
// #flat { position: relative; z-index: 1; margin-top: -50px; height: 50px; }
console.log(document.elementFromPoint(10, 10).id);Show the output
flatopacity below 1 makes #p a stacking context, so #deep's 9999 only sorts it against its siblings INSIDE #p. #p itself has no z-index, so it sits at level auto and #flat's z-index: 1 paints the whole subtree over it. Raising the ancestor is the fix; raising the descendant can never be.
em compounds, rem does not
// html font-size is the default 16px.
// <div class='em'><div class='em'><div class='em' id='e'></div></div></div>
// <div class='em'><div class='em'><div class='rem' id='r'></div></div></div>
// .em { font-size: 2em; } .rem { font-size: 2rem; }
console.log(getComputedStyle(document.getElementById('e')).fontSize);
console.log(getComputedStyle(document.getElementById('r')).fontSize);Show the output
128px
32pxem resolves against the element's inherited font-size, so three levels of 2em is 16 to 32 to 64 to 128. rem always resolves against the root, so it is 32px wherever it sits. That compounding is exactly why nested lists with an em font-size shrink into nothing.
Percentage height with no parent height
// <div id='p'><div id='c'>text</div></div> with:
// #p { } /* height not set */
// #c { height: 50%; }
console.log(getComputedStyle(document.getElementById('c')).height === '0px');Show the output
falseA percentage height needs a definite height on the containing block. The parent's height is auto - it depends on its children - so the percentage cannot resolve and is treated as auto, meaning the child is sized by its own content. So it is not zero; it is the height of the text. The old fix was height: 100% all the way up from html; the modern one is to make the parent a flex or grid container, where percentages and stretch behave.
margin: 0 auto with no width
// <div id='m'>hi</div> with:
// #m { margin: 0 auto; }
console.log(getComputedStyle(document.getElementById('m')).marginLeft);Show the output
0pxA block box with width: auto already fills its containing block, so there is no free space to distribute and the auto margins resolve to zero. Auto margins centre only when the element is narrower than its container - set a width or a max-width and the same rule starts working.
What flex: 1 expands to
// <div id='f'><div class='i'>a</div><div class='i'>a much longer label</div><div class='i'>b</div></div>
// #f { display: flex; width: 300px; } .i { flex: 1; }
const i = document.querySelectorAll('.i');
console.log(getComputedStyle(i[0]).flexBasis);
console.log(i[0].offsetWidth, i[1].offsetWidth, i[2].offsetWidth);Show the output
0%
100 100 100flex: 1 is 1 1 0%, and a basis of zero means content length is ignored when free space is shared - so all three come out identical despite very different text. Write flex: auto (1 1 auto) instead and the middle item takes the width its text needs first, then grows.
width on an inline element
// <span id='s'>hi</span> with:
// #s { width: 300px; height: 100px; }
const s = document.getElementById('s');
console.log(s.offsetWidth === 300);
s.style.display = 'inline-block';
console.log(s.offsetWidth);Show the output
false
300width and height do not apply to non-replaced inline boxes, so the first read is the width of the word. One display change and both apply. Horizontal padding and margin WOULD have applied all along, which is what makes the bug look partial.
The three ways to hide, measured
// <div id='a'>x</div><div id='b'>x</div><div id='c'>x</div>, each 20px tall, with:
// #a { display: none; } #b { visibility: hidden; } #c { opacity: 0; }
console.log(document.getElementById('a').offsetHeight);
console.log(document.getElementById('b').offsetHeight);
console.log(document.getElementById('c').offsetHeight);
console.log(document.elementFromPoint(10, 45).id);Show the output
0
20
20
cdisplay: none takes no space at all; the other two hold their box. And the hit test lands on #c - an opacity: 0 element is still a click target and is still in the accessibility tree. That is the whole reason a faded-out overlay eats clicks: it is invisible, not gone.
nth-child versus nth-of-type
// <div><h2>H</h2><p>one</p><p>two</p></div>
console.log(document.querySelector('p:nth-child(2)').textContent);
console.log(document.querySelector('p:nth-of-type(2)').textContent);
console.log(document.querySelectorAll('p:nth-child(1)').length);Show the output
one
two
0nth-child counts ALL siblings and then checks the element type, so p:nth-child(2) means 'the second child, if it is a p' - that is the h2's position plus one. nth-of-type counts only the p elements. And p:nth-child(1) matches nothing, because the first child is the heading.
Which ancestor absolute positions against
// <div id='outer'><div id='mid'><div id='abs'></div></div></div>
// #abs { position: absolute; top: 0; }
const abs = document.getElementById('abs');
console.log(abs.offsetParent === document.body);
document.getElementById('mid').style.position = 'relative';
console.log(abs.offsetParent.id);Show the output
true
midWith no positioned ancestor the element resolves against the initial containing block, and offsetParent reports the body. Making the nearest wrapper position: relative moves the reference frame with one declaration and nothing about the parent's own layout changes. This is why 'relative parent, absolute child' is a formula rather than a preference.
A transform captures position: fixed
// <div id='p'><div id='f'></div></div> with:
// #p { transform: translateZ(0); margin-top: 200px; height: 300px; }
// #f { position: fixed; top: 0; left: 0; }
const f = document.getElementById('f').getBoundingClientRect();
console.log(f.top);Show the output
200A transformed ancestor becomes the containing block for fixed descendants, so top: 0 is the top of #p rather than the top of the viewport. filter, perspective, will-change, backdrop-filter and contain: paint do the same. It is the usual cause of a modal that will not cover the page, and the fix is to portal the modal out of the transformed subtree.
Shorthand versus longhand order
// <div id='a' class='m'></div><div id='b' class='n'></div> with:
// .m { margin: 10px; margin-left: 30px; }
// .n { margin-left: 30px; margin: 10px; }
console.log(getComputedStyle(document.getElementById('a')).marginLeft);
console.log(getComputedStyle(document.getElementById('b')).marginLeft);Show the output
30px
10pxA shorthand sets every one of its longhands, including the ones you did not mention - so margin: 10px resets margin-left to 10px. Same specificity means source order decides, and the shorthand written second wipes the longhand written first. This is the bug behind 'my override worked in one component and not the other'.