The three questions behind every CSS bug

Which rule wins, how big the box really is, and what decides where it sits.

CSS Is Not Styling. It Is a Layout Engine You Are Negotiating With

Omni Academy · omniacad.com

Most people learn CSS as a list of properties. Colour, font, margin, padding — memorise enough of them and you can make a page look like the design. Then one day a box refuses to centre, a margin escapes its parent, a z-index: 9999 does nothing at all, and the whole subject starts to feel arbitrary and personal.

It is not arbitrary. The behaviour that feels random is the browser's layout engine doing exactly what it was specified to do, with rules you were never told. This article is about those rules — the ones that turn CSS from a pile of properties into a system you can reason about.

The cascade is not a bug, it is the name of the language

CSS stands for Cascading Style Sheets, and the cascade is the part people skip.

When two rules both apply to an element, the browser does not pick the last one it read, or the one you meant. It runs an algorithm, in this order:

  1. Origin and importance. Your stylesheet beats the browser's defaults. !important beats normal rules — and, in a detail that surprises people, !important in the user's stylesheet beats !important in yours. Accessibility settings are meant to win.
  2. Specificity. A score, not a vibe.
  3. Source order. Only if everything above ties.

Specificity is where most confusion lives, and it is simpler than its reputation. Count three numbers:

• inline style="" → the highest

• #id → first number

• .class, [attribute], :hover → second number

• tag, ::before → third number

So .card .title (0-2-0) beats h2.title (0-1-1), which beats h2 (0-0-1). And #header .title (1-1-0) beats both, no matter where it sits in the file.

The practical consequence: you cannot override a high-specificity rule with a low-specificity one by putting it later. Every developer discovers this by writing a rule that does nothing, adding !important, watching it work, and learning the wrong lesson. The right lesson is that !important is a confession — it means something upstream is more specific than it needed to be, and you have chosen to escalate instead of fix it.

Keep selectors flat. .card-title is better than .page .content .card h2 not because it is shorter but because it is overridable. Specificity you did not need is debt you pay later.

Inheritance: some properties flow downhill, and most do not

Set color on <body> and every paragraph inside inherits it. Set border on <body> and nothing inherits it at all.

This is not random either. Properties that describe text generally inherit — color, font-family, font-size, line-height, text-align, visibility. Properties that describe boxes generally do not — margin, padding, border, background, width, height.

The logic: inheriting text settings is almost always what you want, and inheriting a border would put a border on everything.

Two keywords let you override the default behaviour explicitly: inherit takes the parent's value even for a non-inheriting property, and initial resets to the specification's default rather than to whatever your reset stylesheet decided.

The box model, and the one line that fixes it

Every element is a box with four layers: content, padding, border, margin.

By default, width sets the content width only. So this element is 340px wide on screen, not 300:

.card {

width: 300px;

padding: 16px; /* +32 */

border: 4px; /* +8 */

}

That default is a historical accident that almost nobody wants, which is why nearly every real stylesheet starts with:

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

border-box makes width mean the whole visible box, padding and border included. Set a card to 300px and it is 300px. This one line removes an entire category of arithmetic from your life, and the fact that it is not the default is purely because changing it would break pages written twenty years ago.

Margin collapse: the behaviour that looks like a bug

Two vertical margins that meet do not add up. They collapse to the larger of the two.

h2 { margin-bottom: 20px; }

p { margin-top: 30px; }

The gap is 30px, not 50px. This is intentional: it is what stops a document made of headings and paragraphs from accumulating enormous gaps.

Where it bites is the second rule: a parent and its first or last child collapse together. Give a <div> no padding and no border, and its child's top margin escapes the parent and pushes the parent down. The classic symptom is "my margin is outside the box" — and the classic confused fix is to add padding, which works, because padding is precisely what stops the collapse.

Margins only collapse vertically, only in normal flow, and never inside flex or grid containers. Which is one of several reasons modern layouts feel better behaved: in a flex or grid container, margin collapse does not happen at all.

There is not one layout system. There are several, and you choose

This is the single most useful mental shift in CSS. A container's display chooses which layout algorithm lays out its children, and the properties available change completely.

Normal flow is the default: block elements stack, inline elements sit in a line. Good for documents, which is what HTML is.

Flexbox (display: flex) lays out in one dimension. A row or a column. Reach for it when you have a line of things that need distributing — a navigation bar, a toolbar, a card footer. The key insight people miss is that justify-content works along the main axis and align-items across it, and flex-direction swaps which is which. That is why justify-content: center centres horizontally in a row and vertically in a column. It was never about horizontal and vertical.

Grid (display: grid) lays out in two dimensions at once, with the structure defined on the parent:

.layout {

display: grid;

grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));

gap: 1rem;

}

That is a responsive card grid with no media queries at all. auto-fill makes as many columns as fit, minmax says never narrower than 260px but share the leftover space equally. Learning this one pattern replaces a great deal of breakpoint juggling.

The rule of thumb: one dimension, content-driven → flex. Two dimensions, structure-driven → grid. They are not rivals; a grid cell very often contains a flex container.

Centring, which deserves its reputation and no longer deserves it

The joke about centring a div is twenty years old and mostly obsolete:

.parent { display: grid; place-items: center; }

Two lines, both axes, any content size. The reason it used to be hard is that normal flow has no concept of vertical centring — blocks stack from the top, and vertical-align applies to inline content and table cells, not to block boxes. People reached for absolute positioning and negative margins because the layout mode they were in genuinely could not do it. Change the layout mode and the problem disappears.

Positioning, and why your z-index is ignored

position has five values and the confusion is concentrated in two.

relative keeps the element in flow and offsets it visually. Crucially, it also makes the element a containing block for absolutely positioned descendants — which is the actual reason you see position: relative on parents with no offsets.

absolute removes the element from flow and positions it against the nearest positioned ancestor, not the parent. If no ancestor is positioned, it goes all the way to the page.

sticky is relative until it hits a scroll threshold, then fixed within its parent. It silently does nothing if you forget to give it an offset (top: 0) — the single most common reason "sticky isn't working".

Then there is the stacking context, which is the real answer to "why is my z-index ignored". z-index only compares elements within the same stacking context. A new context is created by a positioned element with a z-index, and also — surprisingly — by opacity less than 1, by transform, by filter, and by will-change. So an element with z-index: 9999 inside a parent with opacity: 0.99 can never escape above a sibling of that parent. The fix is never a bigger number; it is finding which ancestor created the context.

Units: when to use which

px is an absolute length and ignores the user's font settings. Fine for borders and small fixed details. A problem for text, because a reader who has set a larger default font gets nothing.

rem is relative to the root font size, so it scales with the user's preference. Use it for type and for spacing. em is relative to the current element's font size, which makes it compound when nested — useful inside a component, dangerous as a global.

% is relative to the parent, but which dimension of the parent depends on the property, and height: 100% famously does nothing unless the ancestors have heights too.

vh and vw are viewport-relative. 100vh on mobile has historically been taller than the visible area because of the browser chrome; dvh (dynamic viewport height) exists to fix exactly that.

fr only exists inside grid, and means a share of the leftover space. 1fr 2fr splits the remainder one-third, two-thirds.

Modern CSS adds functions worth knowing: min(), max() and especially clamp().

h1 { font-size: clamp(1.75rem, 1.2rem + 2vw, 3rem); }

That is fluid typography in one line, with a floor and a ceiling, no breakpoints.

Custom properties are variables that live in the cascade

:root { --accent: #11785f; }

.button { background: var(--accent); }

Unlike a preprocessor variable, these exist at run time. They inherit, they can be redefined on any element, and they can be changed from JavaScript. That is what makes theming trivial:

@media (prefers-color-scheme: dark) {

:root { --accent: #4fd1a5; }

}

Every rule using var(--accent) updates, with no second stylesheet and no class swapping.

Responsive design is not a phone stylesheet

The common mistake is building a desktop layout and then writing media queries to fix it on small screens. The better approach is to write a layout that was never fixed to begin with, and add breakpoints only where the content genuinely breaks.

Start from the small view. Use max-width rather than width so boxes shrink. Use grid's auto-fill/minmax so columns are decided by available space. Let text wrap. Then look at the page at various widths and add a breakpoint where something becomes awkward — which is a decision about this content, not about a device.

And set the viewport meta tag, or none of it applies:

<meta name="viewport" content="width=device-width, initial-scale=1">

Transitions and animation, and the two properties that are cheap

Animation in CSS is mostly one property:

.button { transition: background-color .15s ease; }

.button:hover { background-color: var(--accent-hover); }

The browser interpolates between the two values. What matters is which

property you animate, because they are not equally cheap.

The browser renders in stages: layout (where is everything), paint

(what colour is each pixel), composite (stack the layers). Animating

width, height, top, left, margin or padding changes geometry, so

the browser must redo layout for every frame — on a long list that drops

frames visibly. Animating color or background-color skips layout but still

repaints.

Animating transform and opacity skips both. They are handled at the

composite stage, often on the GPU, and they are the reason a well-built

animation feels smooth while a naive one stutters. So to move something, use

transform: translateX(20px) rather than left: 20px. To size it, use

transform: scale(1.05) rather than changing width. The visual result is the

same; the frame budget is not.

One accessibility rule that costs three lines and matters to real people —

some users get motion sickness from animation, and the operating system

already knows:

@media (prefers-reduced-motion: reduce) {

*, *::before, *::after {

animation-duration: .01ms !important;

transition-duration: .01ms !important;

}

}

How to debug CSS without guessing

When a rule does not do what you expect, there are only a handful of causes,

and they can be checked in order rather than stumbled through.

Is the rule applying at all? Open developer tools, select the element, and

look at the computed styles. A rule that is overridden shows struck through,

with the winning rule above it. This answers the specificity question in two

seconds and saves the !important reflex.

Is the element the size you think? Hover the element in the inspector and

the browser draws the box model on the page — content, padding, border, margin

in different colours. "My padding isn't working" is nearly always "my element

is not where I think it is".

Is it a different layout mode than you assumed? Flex and grid containers

are badged in Chrome and Firefox dev tools, and clicking the badge overlays

the grid lines or flex axes. Half of layout confusion is applying flexbox

reasoning to an element in normal flow.

Is something creating a stacking context? Walk up the ancestors looking

for opacity, transform, filter or a positioned element with z-index.

Is it inherited or set? The computed panel shows where each value came

from. A colour you cannot find in your stylesheet is usually inherited from

an ancestor or supplied by the browser's default sheet.

What to practise

Three exercises worth more than reading another property list:

  1. Rebuild a layout you like twice — once with flexbox, once with grid. You will learn where each is natural and where it fights you.
  2. Delete every !important in a stylesheet you own and make it work by lowering specificity instead. This teaches the cascade faster than any article.
  3. Build one component themed entirely with custom properties, then switch it light to dark by redefining four variables.

The thread through all of it: CSS is declarative, so you are not giving instructions, you are describing an outcome and letting an engine resolve it. When something surprises you, the question is never "why is CSS like this" — it is "which algorithm is laying this out, and what are its rules". Once you ask that question, the answers are reliably findable, and the subject stops feeling personal.

css specificity the cascade box model flexbox css grid custom properties responsive design container queries cascade layers