CSS looks like a pile of properties to memorize. It is really four systems running at once: a cascade that decides which declaration wins, a box model that decides how big a box is, a layout mode that decides where boxes go, and a containing block that every percentage and every positioned element resolves against. Nearly every "why is it doing that?" moment is one system being asked a question another one answers. This guide is the shape of the four, and the traps at their seams.
Each chapter opens with the short version. Tap one to read the detail.
How a stylesheet is read, and which rule wins
~3 min
The browser resolves each property separately: origin and !important, then cascade layer, then specificity, then source order. Selectors only reach downward and forward, and specificity is three columns compared left to right — so a hundred classes never beat one id, and :where() costs nothing at all.
A rule is a selector plus a block of property: value declarations, and the parser is asymmetric about mistakes: an unrecognized declaration is discarded alone and the block survives, while an unrecognized selector takes the whole rule with it — one typo in a comma-separated list kills every selector in it.
Selectors only ever walk downward and forward. article p matches a paragraph at any depth, article > p only a direct child, h2 + p the paragraph immediately after the heading, h2 ~ p every later sibling. None walks back up the tree, which is why styling a container by what it contains was impossible until :has() — figure:has(figcaption) finally does it, with support landing across the major browsers between 2022 and late 2023. Counting trips people too: :nth-child(2) counts every sibling and then checks the type, so p:nth-child(2) matches nothing when a heading sits first, while :nth-of-type(2) counts only same-tag siblings.
When several rules apply, the browser never compares them as wholes. It resolves each property independently, in a fixed order: origin and importance — where !important inverts the usual author-over-browser ranking — then cascade layer, then specificity, then source order.
Specificity is the step people misread. It is not one number you total up but three columns compared left to right: ids, then classes, attributes and pseudo-classes, then element types. A higher column wins outright:
That is what makes the grouping pseudo-classes a cascade decision, not just a matching one. :is() and :not() charge the specificity of their most specific argument, so :is(#main, p) costs an id even when it matched the p; :where() charges nothing. They also fail differently — one bad selector kills a plain list, but inside :is() the rest still match:
p.a, p:bogus { color: teal; } /* the whole rule dies */
:is(p.b, p:bogus) { color: teal; } /* p.b still matches */
Inheritance is a separate mechanism, and only some properties do it: color and the font properties pass down, border and margin do not.
Rule: when your rule loses, lower the winner's specificity rather than raising your own — wrap base styles in :where(), because an id arms race ends at !important and nothing beats that.
The box model, and what width actually sizes
~2 min
By default width sizes only the content box, so padding and border are added outside it; box-sizing: border-box folds them inside. Vertical margins between blocks collapse to the larger one rather than adding, and percentage padding resolves against the container's width even on the top and bottom.
Every box is four nested rectangles: content, padding, border, margin. Under the default box-sizing: content-box, width describes only the innermost one, so a box declared 200px wide with 20px of padding and a 5px border occupies 250px:
Width is then clamped in a fixed order: min-width overrides max-width, which overrides width.
Two behaviors surprise everyone. Vertical margins between adjacent blocks collapse into the larger one, never the sum, so 30px below one and 20px above the next gives a 30px gap. And a child's top margin escapes a parent with no padding, border or formatting context of its own — the parent gets no taller and the whole block slides down. One pixel of padding, or display: flow-root, contains it.
Percentages catch people once. Percentage padding and margin resolve against the container's width, so padding-top: 10% in a 400px-wide box is 40px whatever its height. A percentage height is the opposite: without a parent of definite height it quietly computes to zero.
Rule: set box-sizing: border-box globally on day one, and treat any unexplained vertical gap as a collapsed margin until proven otherwise.
Values, units and color
~2 min
em resolves against the element's own font size and therefore compounds through nesting; rem resolves against the root and never does. A unitless line-height inherits as a ratio, while 1.5em inherits an already-computed length — the most common typography bug in the language.
em is the element's own computed font size, so nesting multiplies: 2em inside 2em inside a 16px root is 64px, not 32px. rem refers to the root and stays put. Used on font-size itself, em necessarily means the parent's size, since the element's own size is what is being computed.
line-height is where this bites hardest, and the fix is to leave the unit off:
A unitless value inherits as a ratio and is recomputed per element; a length is computed once on the parent and inherited as a fixed 30px, which is how large headings end up with overlapping lines.
Percentages have no single base — they resolve per property, against the containing block's width for width and padding, against the element's own font size for line-height. calc() mixes units freely but demands whitespace around + and -, without which the declaration is invalid and silently dropped. clamp(min, ideal, max) is exactly max(min, min(ideal, max)), and currentColor resolves to the element's computed color, so borders and icons track their text automatically.
Rule: rem for the type scale, unitless for line-height, and never assume two percentages share a base.
Normal flow, and the formatting context you cannot see
~2 min
Before any layout mode is chosen, boxes are in normal flow: blocks stack down, inline boxes run along the line and ignore width, height and vertical margins entirely. A block formatting context is the invisible boundary that contains floats and stops margins escaping.
Normal flow is the default that every other layout mode replaces. Block boxes stack down the page, filling the available width and shrinking to their content in height. Inline boxes flow along the line and ignore width, height and vertical margins completely — a <span> told to be 400px square stays exactly as big as its text. inline-block keeps inline placement but accepts a box's sizing.
The invisible piece is the block formatting context (BFC): a region that lays itself out independently, containing its floats and refusing to collapse margins across its boundary. You create one with display: flow-root, any non-visibleoverflow, a float, absolute positioning, or implicitly whenever an element becomes a flex or grid item.
That is also why overflow behaves oddly. Every value except visible and clip makes both a scroll container and a formatting context. hidden can still be scrolled programmatically and reached by keyboard focus; clip cannot, which makes it honest for decoration and wrong for anything focusable. Nor are the axes independent: set one to a non-visible value and the other stops being visible too, behaving as auto.
Rule: when a float escapes its parent or a margin leaks out of one, you need a formatting context — reach for display: flow-root rather than an overflow value you did not otherwise want.
Flexbox: distributing space along one axis
~2 min
flex-direction picks the main axis and the cross axis follows, which is why justify-content starts moving items vertically in a column. flex: 1 means a basis of zero, so items come out equal whatever their content — and no item shrinks below its own content unless you set min-width: 0.
Flexbox lays out the direct children of a flex container along one axis. flex-direction chooses that main axis; the cross axis is perpendicular. justify-content works along the main axis and align-items along the cross, so switching to column swaps which one moves things down the page — the commonest source of "that property stopped working".
The flex shorthand hides the distinction that matters most:
In a 600px row holding one short item and one long one, flex: 1 gives 300px each while flex: auto gives roughly 177px and 423px. Both are correct; they answer different questions.
Then there is the bug everyone hits once. A flex item's automatic minimum size stops it shrinking below its content's intrinsic minimum, so one long unbreakable string bursts the container instead of being clipped — inside a 200px container such an item measures 313px and overflows. min-width: 0, or an overflow value on the item, opts out.
Rule: flex: 1 for equal columns, flex: auto for content-sized ones, and add min-width: 0 the moment an item refuses to shrink.
Grid: define the tracks, then place on the lines
~2 min
You define tracks, but you place items on the numbered lines between them, so grid-column: 1 / 3 spans two columns rather than three. fr shares out whatever is left after gaps and fixed tracks, and a 1fr track will not shrink below its content unless you write minmax(0, 1fr).
Grid is two-dimensional: you declare the tracks and the browser numbers the lines between them. A three-column grid has four column lines, and placement names lines, not columns — grid-column: 1 / 3 starts at line 1 and ends at line 3, covering two tracks. span 2 says the same without counting, and -1 is the last explicit line, so 1 / -1 reaches full width whatever the track count.
fr is a share of what remains: gaps and fixed tracks are subtracted first, then the remainder splits by fraction. Which leads to the behavior that catches everyone. 1fr is shorthand for minmax(auto, 1fr), so a track cannot shrink below its content's minimum — drop a long unbreakable string into a two-column grid and the tracks resolve to 313px and 8px inside a 300px container, overflowing it. The fix is one word:
For responsive track counts, repeat(auto-fill, minmax(100px, 1fr)) keeps empty tracks at full size while auto-fit collapses them to zero so the filled ones stretch — with two items in a 500px container, five 100px tracks versus two 250px ones.
Rule: count lines, not columns — and write minmax(0, 1fr) whenever a track holds text you do not control.
Positioning, stacking contexts, and why z-index stops working
~2 min
An absolutely positioned box resolves against its nearest positioned ancestor, and a transform or filter anywhere above it captures even position: fixed. z-index is only ever compared inside the nearest stacking context, and opacity below 1 quietly creates one.
static ignores insets. relative shifts what is painted while reserving the original slot. absolute leaves the flow and resolves against the nearest ancestor that is itself positioned, falling back to the page. fixed resolves against the viewport. sticky acts as relative until a threshold is crossed, and needs both an inset value and a scrollable ancestor.
The first surprise is that fixed is not always fixed to the viewport. An ancestor with a transform, filter or perspective becomes its containing block instead: a transformed wrapper 48px from the left pins a fixed child at 48px rather than 0, so an animation on a wrapper can break a modal three levels below it.
The second is z-index. It is not a global ordering — it is compared only among siblings inside the nearest stacking context, and ordinary properties create one:
.parent { position: relative; opacity: 0.99; }
.child { position: absolute; z-index: 9999; }
/* still painted beneath a later sibling with z-index: 1 */
The child's 9999 competes only within its parent, and the parent as a whole sits below the later sibling. opacity under 1, any transform, any filter and will-change all do this — which is also why fading a parent can never leave a child more opaque than it, and why filter, clip-path and mask change what is painted but never the layout.
Rule: when z-index will not work, look up the tree for an ancestor with opacity, transform or filter — the number is almost never the problem.
Shorthands, and the paint and text they quietly reset
~3 min
A shorthand resets every longhand it omits: background silently clears background-color, and font clears the line-height you set two lines earlier. Multiple backgrounds stack with the first entry on top, and text-transform changes only the rendering — the copied text keeps its original casing.
A shorthand does not merely set the parts you name; it resets every longhand it omits back to that property's initial value. Two of them cost real bugs. background: url(hero.png) written after background-color: green leaves the color transparent, and font — which requires a size and a family, family last — clears font-weight, font-style and line-height on its way past:
.card { background-color: green; background: url(hero.png); } /* colour gone */
p { line-height: 3; font: 16px Georgia, serif; } /* line-height now normal */
Name the longhand instead (background-image; font-size plus font-family) and both survive. A line height can go inside the font shorthand if you want one, immediately after the size and separated by a slash: font: 16px/1.5 Georgia, serif.
Backgrounds are layered. A comma-separated list stacks them with the first entry on top and the last at the bottom, and only that final layer may specify a color, because the color is always painted behind everything else. For sizing, cover scales the image until it fills the box and crops the overflow, while contain scales it until it fits entirely and leaves empty space.
outline is the one to remember from the border family: it paints outside the border and occupies no layout space, so adding one never shifts anything — a 20px border grows the box by 40px, a 20px outline leaves it identical. That is why deleting outlines from focus styles is an accessibility regression, not a cosmetic tidy-up.
Text carries the same lesson in a different key: these properties are styling, not data. text-transform: uppercase changes only the rendering, so what a user copies, what a search matches and what a screen reader announces all keep the original casing. text-overflow: ellipsis shows nothing unless the line also cannot wrap and the box has a constrained width. And list-style: none removes the marker but can also strip the list's semantics from the accessibility tree, so screen-reader users stop being told it is a list — role="list" restores them, and ::marker styles the bullet without removing it.
Rule: reach for a shorthand only when you want its reset, and read every one as "set these, reset the rest".
Transforms and motion
~2 min
A transform changes what is painted, never the layout — the element keeps occupying its untransformed space. Functions compose left to right, each in the coordinate space the previous one left behind, so translate then rotate lands somewhere quite different from rotate then translate.
A transform moves, rotates, scales or skews the rendered element without touching layout: siblings do not move and the original space stays reserved. That makes transforms cheap to animate, and also consequential — a transformed element becomes the containing block for fixed and absolute descendants, and creates a stacking context.
Order is not decorative. Each function establishes a new coordinate space for the next, so the result follows the written order:
.a { transform: translateX(100px) rotate(90deg); } /* move right, then spin in place */
.b { transform: rotate(90deg) translateX(100px); } /* spin, then move on the new axis */
From the same origin those two land 100px apart, on different axes. A percentage inside translate() is a percentage of the element's own box, not its container's, which is what makes translate(-50%, -50%) the classic centering trick.
For motion, transition interpolates between two states while @keyframes plus animation describes a sequence. The shorthand's parsing rule is worth memorizing: the first time value is the duration and the second the delay, so a lone time is always the duration. Omit it and the duration defaults to 0s — the animation genuinely runs and nothing is ever visible. Omitted longhands reset too, so animation-fill-mode returns to none and the element snaps back at the end unless you ask for forwards.
Rule: prefer animating transform — it never reflows the page — and always write the duration before the delay.
Media queries, and querying the container instead
~2 min
A media query tests the viewport, and min-width: 600px matches at exactly 600px, so a naive max-width: 600px / min-width: 600px pair overlaps by one pixel. Container queries ask the same questions of an ancestor's size, which is what component styling actually needs.
A media query is a media type plus feature tests joined with and, where a comma means or and not negates the whole query. The range syntax reads better than paired features: (400px <= width <= 700px) says what (min-width: 400px) and (max-width: 700px) says, and both boundaries are inclusive.
That inclusivity is the trap. max-width: 600px and min-width: 600px both match at exactly 600px, so both blocks apply and source order silently decides. Pick one direction and layer from it; min-width queries building up from a small-screen base is the usual convention, because then the base styles need no query at all.
Not every feature is about size: prefers-color-scheme reports a light or dark preference, prefers-reduced-motion that the user wants less animation, and hover and pointer describe the input device rather than the screen.
The newer tool is @container, which tests an ancestor's size instead of the viewport's:
That rule applies inside a 300px wrapper and not inside a 200px one, however wide the window happens to be — the difference between a component that is reusable and one that only works in the column it was designed for.
Rule: query the smallest thing that answers your question — the container when the decision is about the component, the viewport only when it is really about the device.
Custom properties, layers, and the cascade tools worth adopting
~2 min
Custom properties are live cascading values, not compile-time substitutions: they inherit, and redefining one on a subtree changes everything reading it below. Their sharp edge is that an unresolvable var() does not fall back to your previous declaration — it makes the property unset.
A custom property is declared like any other (--brand: teal) and read back with var(--brand). It inherits and cascades, so redefining it on a subtree changes every descendant reading it. That is the whole difference from a preprocessor variable, which is substituted once and cannot be answered differently per element.
The sharp edge is what happens when the lookup fails. An ordinary invalid value is thrown out at parse time and the previous declaration survives. An unresolvable var() is only discovered later, so the property becomes unset — inherited or initial — instead of falling back:
Everything after the first comma inside var() is the fallback, commas included.
@layer is the other addition worth adopting. Declaring @layer reset, base, theme; up front fixes the order, and a later layer then beats an earlier one whatever the specificity, so a plain class can finally win against a framework's id selector. Two rules to hold: unlayered styles beat every layered one for normal declarations, and !important inverts the layer order completely.
Rule: give a var() a fallback whenever the property has no safe inherited value, and reach for a cascade layer before you reach for !important.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.