Ch. 5 · HTML & CSS

The Cascade, Specificity & the Box Model

How the browser decides which CSS rule wins (origin, @layer, specificity, order), how inheritance and box-sizing work, and why z-index sometimes seems broken.

~7 min readbeginner

Every CSS bug that ends with “but my rule is right there!” comes down to a handful of ideas: the cascade decides which declaration wins, inheritance fills in what nobody declared, and the box model decides how big things end up. Interviewers ask about these because they separate people who can reason about CSS from people who add !important until it works. This note walks through each one with the exact rules.

How the cascade picks a winner

When several declarations set the same property on the same element, the browser compares them on these criteria, in order, and stops at the first one that differs:

  1. Origin and importance: browser defaults, user styles or your (author) styles, and whether the declaration is !important.
  2. Inline styles: a style attribute beats any selector.
  3. Cascade layers: which @layer the rule lives in.
  4. Specificity: how precise the selector is.
  5. Order of appearance: the last declaration wins.

The origin step looks like this, lowest priority first:

Rank Declarations
1 Browser (user-agent), normal
2 User, normal
3 Author, normal
4 CSS animations
5 Author, !important
6 User, !important
7 Browser, !important
8 CSS transitions

Notice that !important flips the origin order. That is deliberate: a user’s accessibility stylesheet can override any site. It also means an inline style loses to an !important rule in your stylesheet, while an inline !important beats everything else you author.

Cascade layers

Layers let you group styles and decide up front which group wins, independently of specificity.

@layer reset, base, components; /* sets the order: later layers win */

@layer components {
  .btn { color: white; }
}

@layer base {
  #app a.btn { color: blue; } /* more specific, earlier layer: loses */
}

.btn { color: black; } /* unlayered: beats every layer */
CSS

The button is black. Remove the unlayered rule and it is white, even though #app a.btn is far more specific. The rules:

  • Layer order is fixed by the first time each name appears. Later layers win.
  • For normal declarations, unlayered styles beat all layered styles.
  • For !important declarations the order reverses: earlier layers win, and important layered styles beat important unlayered ones.
  • Layers are compared before specificity. That is the point: a reset layer can never accidentally out-specify your components.

Interview tip

Recite the order without hesitating: “origin and importance, then inline styles, then layers, then specificity, then source order.” Most candidates jump straight to specificity.

Calculating specificity

Specificity is a three-part value, written (A, B, C):

  • A: ID selectors, such as #nav.
  • B: classes (.item), attribute selectors ([type="email"]) and pseudo-classes (:hover, :focus-visible).
  • C: type selectors (li) and pseudo-elements (::before).
  • Nothing: the universal selector * and combinators (>, +, ~, the space).

Compare column by column from the left; the first column that differs decides. There is no carrying over: eleven classes, (0, 11, 0), still lose to a single ID, (1, 0, 0).

Selector Specificity
* (0, 0, 0)
ul li::before (0, 0, 3)
.btn.primary (0, 2, 0)
input[type="email"] (0, 1, 1)
#nav .item a:hover (1, 2, 1)
a:not(.external) (0, 1, 1)
:is(#main, .card) p (1, 0, 1)
:where(#main, .card) p (0, 0, 1)
li:nth-child(2 of .done) (0, 2, 1)

The functional pseudo-classes follow their own rules:

  • :is(), :not() and :has() take the specificity of their most specific argument, whichever argument actually matched. They add nothing themselves.
  • :where() always counts as zero, which makes it ideal for library defaults that should be easy to override.
  • :nth-child(An+B of S) counts as one pseudo-class plus the most specific selector in S.

!important is not part of specificity; it acts at the origin step. Older tutorials also treat inline styles as a fourth, leftmost column. The modern spec handles them as a separate step, but the practical outcome is the same.

Gotcha

:is(#main, .card) p has ID-level specificity even when it matched through .card. If you want to group selectors without adding weight, use :where().

Inheritance and the CSS-wide keywords

When no declaration sets a property on an element, inheritance decides. Inherited properties take the parent’s computed value; the rest fall back to their initial value.

  • Inherited (mostly text): color, font-*, line-height, letter-spacing, text-align, visibility, cursor, list-style.
  • Not inherited (mostly boxes): margin, padding, border, background, width, height, display, position.

An inherited value has no specificity at all. Any rule that targets the element directly wins, even the universal selector:

#article { color: navy; } /* (1, 0, 0), but it only matches #article itself */
* { color: black; }       /* matches the <p> inside it directly: the text is black */
CSS

Five keywords work on every property:

Keyword Effect
inherit Use the parent’s computed value
initial Use the spec’s initial value (display: initial is inline!)
unset inherit for inherited properties, initial for the rest
revert Roll back to the previous origin, usually the browser default
revert-layer Roll back to the previous cascade layer

So div { display: initial; } makes a div inline; display: revert brings back block.

The box model and box-sizing

Every element generates a box made of four layers, from the inside out: content, padding, border and margin. What width and height measure depends on box-sizing:

  • content-box (the default): width is the content only; padding and border are added on top.
  • border-box: width includes padding and border, and the content shrinks to fit.
.card {
  width: 200px;
  padding: 20px;
  border: 5px solid;
}
/* content-box: 200 + 2 × 20 + 2 × 5 = 250px wide on screen */
/* border-box: exactly 200px wide; the content area is 150px */
CSS

Margins sit outside the box in both modes and are never part of width. Most codebases switch everything to border-box. box-sizing is not inherited, hence the universal selector:

*,
*::before,
*::after {
  box-sizing: border-box;
}
CSS

Two more details worth knowing: percentage padding and margin are resolved against the containing block’s width, even for top and bottom. And on inline boxes such as a span, width and height are ignored and vertical margins do not move surrounding lines.

Margin collapsing

In normal block layout, vertical margins that touch merge into one margin equal to the larger of the two, not their sum.

  • Adjacent siblings: margin-bottom: 30px above margin-top: 20px leaves a 30px gap.
  • Parent and first or last child: with no border, padding, inline content or new formatting context between them, the child’s margin escapes through the parent.
  • Empty blocks: an element with no content, padding, border or height collapses its own top and bottom margins into one.

With negative margins, the result is the largest positive margin plus the most negative one: 30px and -10px give 20px.

Collapsing never happens for horizontal margins, flex and grid items, floats, absolutely positioned elements or inline-blocks. A parent that establishes a block formatting context (for example with display: flow-root or overflow: hidden) keeps its children’s margins inside.

Note

In flex and grid layouts, prefer gap over margins for spacing between items. It never collapses, never leaks out of the container and needs no :last-child exceptions.

Stacking contexts and z-index

z-index only applies to positioned elements (position other than static) and to flex and grid items. On a plain static block it does nothing.

A stacking context is a group of elements painted together as one layer. z-index values only compete with other elements in the same context, and a child can never escape its parent’s context:

.header { position: relative; z-index: 2; }
.page   { position: relative; z-index: 1; }
.modal  { position: fixed; z-index: 9999; } /* inside .page: still below .header */
CSS

Common triggers that create a stacking context:

  • the root element, and position: fixed or sticky
  • position: relative or absolute with a z-index other than auto
  • flex or grid items with a z-index other than auto
  • opacity below 1, or mix-blend-mode other than normal
  • transform, filter, backdrop-filter, perspective, clip-path or mask other than none
  • isolation: isolate, contain: paint or layout, and will-change naming any of these properties

Inside one context the painting order, back to front, is: the context’s own background and borders, children with negative z-index, in-flow blocks, floats, inline content, positioned children with z-index: auto or 0, then positive z-index in increasing order.

To fix the modal above, render it outside .page (at the end of body, which is what portals do) or use a dialog opened with showModal(), which goes into the browser’s top layer, above every stacking context. For your own components, isolation: isolate contains their internal z-index values deliberately.

The interview answer

“When two declarations set the same property, the cascade compares origin and importance first, which is why !important and user styles can flip the normal order. Then inline styles, then cascade layers, where later layers win and unlayered styles beat all layers, then specificity, and finally source order. Specificity counts IDs, then classes, attributes and pseudo-classes, then types and pseudo-elements, compared left to right with no carrying. :where() is always zero, while :is(), :not() and :has() take their most specific argument.

For layout, I default to box-sizing: border-box so width includes padding and border, I remember that vertical margins collapse in block flow but not in flex or grid, and when a z-index seems ignored I look for the stacking context: an ancestor with a transform, an opacity below 1, or a positioned z-index traps its children.”

Keep reading

read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
esc