Chapter 3

CSS Fundamentals

Cascade, specificity, inheritance, the box model, units and custom properties.

3. CSS Fundamentals

CSS is a language for describing how documents are presented. Its power comes from a few interacting systems: the cascade chooses between competing declarations, the box model determines how elements occupy space, and units express dimensions relative to useful reference points. Understanding these rules makes CSS easier to debug than adding increasingly specific selectors until a page looks right.

This chapter builds a practical mental model for those systems. It also introduces custom properties and cascade layers, two tools that help keep a growing stylesheet predictable.

Overview & Objectives

By the end of this chapter, you will be able to:

  1. Explain how origin, importance, layers, specificity, and source order determine a declaration.
  2. Calculate selector specificity and recognize selectors that are difficult to override.
  3. Choose between the standard and border-box sizing models.
  4. Diagnose vertical margin collapsing.
  5. Select rem, em, ch, percentages, vh, or dvh for a layout's intent.
  6. Define and scope custom properties, including useful fallbacks.
  7. Organize styles with cascade layers and inspect computed styles in DevTools.

The Cascade: How CSS Chooses a Value

More than one CSS rule can match the same element and property. The cascade determines which declaration wins. It is not simply “the most specific selector wins.” The browser considers several stages, in order:

  1. Relevance: Does the rule apply to this element and environment? For example, a rule inside a non-matching media query is excluded.
  2. Origin and importance: Which kind of stylesheet supplied it, and is it marked !important?
  3. Cascade layer: If the declarations are layered, which layer has precedence for this importance level?
  4. Specificity: Which matching selector has greater specificity?
  5. Scoping proximity: For scoped CSS, which matching scope root is closer to the element?
  6. Order of appearance: If the declarations are still tied, the later one wins.

The browser may also consider transitions and animations in the cascade. For common author CSS, the earlier stages above explain most “why is this color not changing?” bugs.

Origins and !important

Declarations can come from browser defaults (the user-agent origin), user styles, or author styles (your stylesheets and style attributes). Browser defaults provide sensible initial behavior; author styles customize it. User styles and accessibility settings can be used to adapt a page to a person's needs.

For normal declarations, author styles generally take precedence over user-agent defaults. Importance changes the ordering: important user declarations outrank important author declarations, allowing user preferences such as high contrast to win. Author !important declarations outrank normal author declarations, but they are not a universal trump card: important declarations from different origins have their own order, and active transitions can temporarily outrank even important declarations.

Avoid !important as a routine fix. It removes declarations from the ordinary specificity contest and makes later customization harder. A focused use can be reasonable for a utility with a deliberately immutable contract or to override an external rule that cannot be changed, but first investigate why the intended selector is losing.

Specificity

When declarations have matching origin, importance, and layer, specificity helps choose the winner. A useful specificity tuple has three columns:

Selector component Example Contribution
ID selectors #billing (1, 0, 0)
Class, attribute, and pseudo-class selectors .card, [aria-expanded], :hover (0, 1, 0)
Type and pseudo-element selectors button, ::before (0, 0, 1)

Add the contributions in each column and compare left to right. A larger ID column wins before any number of classes is considered; a larger class column wins before any number of type selectors. The tuple is not a decimal number: 0-2-0 is less than 1-0-0, even though the class column contains 2.

For example:

css
button { color: navy; }                         /* 0-0-1 */
.toolbar button { color: teal; }                /* 0-1-1 */
#account .toolbar button { color: purple; }     /* 1-1-1 */

If all three selectors match the same button and the declarations otherwise tie in the cascade, purple wins because the ID column is greater. Adding another type selector to the first rule cannot beat the ID.

Some pseudo-classes have special specificity rules:

  • :is(...), :not(...), and :has(...) take the specificity of the most specific selector in their argument list; the pseudo-class itself adds no separate class contribution.
  • :where(...) always has zero specificity, including its arguments. It is useful for providing defaults that application styles can easily override.
  • :nth-child(...) contributes pseudo-class specificity, and a selector list passed to its of clause can also contribute specificity.
css
:where(.article) h2 { margin-block-start: 2rem; } /* 0-0-1 */
.article h2 { margin-block-start: 1.5rem; }       /* 0-1-1 */

The second rule can override the first without an ID or !important. This is one reason to keep reusable defaults low-specificity.

Source order and inheritance

If two declarations have the same cascade priority and specificity, the later declaration in source order wins:

css
.notice { color: darkred; }
.notice { color: darkgreen; } /* wins when all earlier cascade stages tie */

Source order means the order the browser processes the rules, including stylesheet imports and layer declarations—not just the order you remember editing files.

Inheritance is separate from specificity. Some properties, such as color and font-family, are inherited by default; others, such as margin and border, are not. A child with its own winning declaration does not inherit its parent's value for that property. Use inherit, initial, unset, or revert only when their reset semantics are intentional; they do different things.

The Box Model

Every element laid out as a box has content, padding, border, and margin. With the default content-box model, a declared width applies only to the content. Padding and border are added outside it:

css
.card {
  width: 20rem;
  padding: 1.5rem;
  border: 2px solid;
}

The rendered border-box width is 20rem + 3rem + 4px, before margins. That surprises developers who expect width to describe the visible outside edge.

With box-sizing: border-box, the declared width includes content, padding, and border. The content area shrinks to fit the inside of that declared width:

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

This is a common site-wide baseline. It makes components easier to size, but it does not make content fit automatically: long text, intrinsic image sizes, minimum sizes, and layout constraints can still cause overflow. A typical project places the universal rule early in its base layer so components start from the same model.

Margins are outside the border. They are transparent spacing and are not included in the element's background painting. Prefer padding when a surface needs clickable breathing room or a background extending to its edge; use margin to create separation between boxes.

Vertical margin collapsing

In normal block flow, adjacent vertical margins can collapse instead of adding. If a block above has margin-bottom: 24px and the next block has margin-top: 16px, the gap is usually 24px, not 40px. Positive margins collapse to the larger margin. A positive and negative margin combine by adding; two negative margins collapse to the more negative one.

A parent's top or bottom margin can also collapse with its first or last in-flow child when no border, padding, inline content, clearance, or new formatting context separates them. This can make the parent appear not to contain the child's margin.

Margins do not collapse between flex items, grid items, absolutely positioned boxes, or floats in the same way as adjacent normal-flow block margins. To stop parent/child margin collapse, consider padding, a border, or establishing a block formatting context with a deliberate layout choice such as display: flow-root. Do not add arbitrary overflow: hidden just to make a margin move; it may clip focus rings and other content.

Units: Pick the Right Reference

CSS units describe how a value relates to the surrounding context. Choose a unit because its resizing behavior matches the design, not because it is fashionable.

rem and em

  • rem is relative to the root element's font size. It is useful for a consistent type and spacing scale that can grow when a user changes the default font size.
  • em is relative to the computed font size of the element for font-related properties; for many other properties it is based on the element's computed font size. Nested em font sizes compound.
css
html { font-size: 100%; }
.card { padding: 1.5rem; }
.card__title { font-size: 1.25em; }

Keeping the root font size at the browser default respects user preferences. Avoid forcing html { font-size: 10px; } just to make mental arithmetic convenient: it changes the meaning of a user's default font setting.

ch and percentages

ch is based on the advance measure of the “0” glyph in the element's font. It is an approximate character-width unit, useful for readable line lengths:

css
.article { max-width: 68ch; }

It does not guarantee an exact number of characters for proportional fonts. % is relative to a containing or otherwise defined reference, which depends on the property. For example, a block's width percentage is typically based on its containing block; percentage heights need a resolvable containing-block height. If a percentage appears to compute unexpectedly, check the containing block and the property-specific rule.

Viewport units: vh and dvh

vh is a viewport-percentage unit. On mobile browsers, browser controls can expand and collapse, so the meaning and visible result of viewport units may differ from the currently visible area. Dynamic viewport units such as dvh track the dynamic viewport as browser UI changes:

css
.hero {
  min-height: 100vh;  /* fallback */
  min-height: 100dvh;
}

Because dvh can change while browser controls move, layouts may resize during scrolling. Use min-height rather than a fixed height when content may be taller than the screen. Consider svh for the smallest viewport and lvh for the largest viewport when that behavior is more appropriate.

Unit choice in practice

Goal Often a good starting point
Text and spacing that scale with the root preference rem
A component detail that should scale with its own text em
A readable measure for prose ch
A fluid fraction of a containing block %
A panel at least as tall as the visible mobile screen min-height: 100dvh, with a fallback
A fixed one-pixel border px

No single unit is inherently accessible. Preserve zoom, avoid clipping, and test with larger text and narrow viewports.

Custom Properties and Design Tokens

CSS custom properties store reusable token values. They participate in the cascade, inherit by default, and are evaluated where they are used:

css
:root {
  --color-brand: #2457a6;
  --space-card: 1.5rem;
}

.card {
  border-color: var(--color-brand);
  padding: var(--space-card);
}

The :root selector provides document-wide defaults. A descendant can override a token for its own subtree:

css
.theme-dark {
  --color-surface: #172033;
  --color-text: #f4f6fb;
}

.panel {
  color: var(--color-text, #18202b);
  background: var(--color-surface, white);
}

The fallback after the comma is used when the referenced custom property is missing or otherwise has its guaranteed-invalid value. A fallback is not a general-purpose recovery for any invalid final property value: if a defined token expands into a value invalid for the consuming property, the declaration can become invalid at computed-value time. Define tokens with the kind of value their consumers expect and test the actual computed result.

Custom properties are case-sensitive. --Color and --color are distinct. Unlike Sass variables, custom properties remain in the runtime CSS cascade and can be changed by themes, component scopes, and JavaScript.

css
.button { background: var(--button-bg, var(--color-brand, blue)); }
.button--danger { --button-bg: firebrick; }

This nested fallback chain gives the component a local override, then the shared brand token, then a literal last resort. Keep token names semantic (--color-danger) rather than tied to one page or one exact shade (--red-700) when the token represents a role across the product.

For stricter contracts, @property can register a custom property with a syntax, inheritance behavior, and initial value. It is useful for values that need typed interpolation or a non-inheriting default, but it is unnecessary for ordinary tokens and should be used only when its behavior is wanted.

Cascade Layers with @layer

Cascade layers provide an explicit order for groups of CSS rules. They help establish an override policy across resets, vendor styles, components, and utilities without escalating selector specificity.

css
@layer reset, vendor, base, components, utilities;

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

@layer base {
  body { margin: 0; color: #202735; }
}

@layer components {
  .button { background: navy; color: white; }
}

@layer utilities {
  .text-danger { color: firebrick; }
}

For normal declarations in named layers, later layers have higher priority than earlier layers. Normal unlayered declarations outrank normal declarations in every named layer. This makes layers especially helpful for organizing third-party CSS: import it into an early layer, then keep application styles in later layers.

Important declarations reverse the layer order: important declarations in earlier layers outrank those in later layers, and layered important declarations outrank unlayered important declarations. This reversal helps preserve foundational important rules, but is another reason not to sprinkle !important through the application.

Declare layer order once near the top of the stylesheet, before the layers are first used. If a new named layer is introduced later, its position is determined by first appearance unless it was already included in the initial order statement. Unlayered CSS can still override normal layered CSS, so be deliberate when integrating a legacy stylesheet.

Complete Example: A Predictable Card

The following stylesheet combines a layer policy, tokens, box sizing, and responsive-friendly units:

css
@layer reset, base, components, utilities;

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

@layer base {
  :root {
    --color-ink: #202735;
    --color-paper: #ffffff;
    --color-accent: #2457a6;
    --space-card: 1.25rem;
  }

  body {
    margin: 0;
    color: var(--color-ink);
    background: #f3f5f8;
    font-family: system-ui, sans-serif;
    line-height: 1.5;
  }
}

@layer components {
  .course-card {
    width: min(100% - 2rem, 42rem);
    margin: 2rem auto;
    padding: var(--space-card);
    border: 1px solid #d7dce5;
    border-radius: 0.75rem;
    color: var(--color-ink);
    background: var(--color-paper, white);
  }

  .course-card__title {
    margin-block: 0 0.5em;
    font-size: clamp(1.25rem, 1rem + 1vw, 1.75rem);
  }

  .course-card__description {
    max-width: 62ch;
  }
}

@layer utilities {
  .text-accent {
    color: var(--color-accent);
  }
}

box-sizing: border-box makes the card's width include its padding and border. min(100% - 2rem, 42rem) leaves side space on narrow screens while capping the card on wide ones. margin: 2rem auto centers the block in its available width. em in the title margin scales with the title's computed font size; ch limits the paragraph's line length. The token fallback makes the surface declaration robust if the token is absent, while the layer order describes which categories are intended to override others.

The clamp() title is included to show how a property can blend a minimum, fluid middle value, and maximum; responsive design will explore fluid sizing further.

Common Traps, Anti-patterns & Debugging

Fighting specificity instead of finding the winner

Adding #page .content .card button may appear to fix one override, but it creates a harder rule to beat later. In DevTools, inspect the property and see which declaration is active and which declarations are crossed out. Then check origin, importance, layer, and specificity. Prefer changing the owning component rule, moving it to the intended layer, or using a lower-specificity default such as :where().

Assuming !important always wins

!important changes cascade priority; it does not ignore origin, layers, or other important declarations. An important user preference can override an important author style. Important declarations in layers have reversed layer ordering. Remove unnecessary importance and establish a clearer layer or component contract.

Forgetting box-sizing

Two columns that should total 100% may overflow after padding and borders are added. Confirm the computed box-sizing and measured border box in DevTools. A project-wide border-box baseline often makes layout arithmetic more predictable.

Unexpected collapsing margins

If the space between two blocks is smaller than the sum of their margins—or a child's margin seems to escape its parent—check whether they are in normal block flow and whether margin collapsing applies. Use parent padding, a border, or a deliberate formatting context when the layout requires separation. Avoid layout hacks that clip content.

Percentages without a useful reference

A percentage height does not necessarily mean a percentage of the visible screen. Trace the containing block and its computed size. For screen-height sections, use viewport units intentionally; for children, ensure their containing block has a defined size when the property requires one.

Unexpected em compounding

Nested elements that each specify font-size: 1.2em grow at every nesting level. Use rem for a root-based scale or inspect the computed font size of the parent when local scaling is intentional.

Debugging in DevTools

  1. Use the element picker to select the element whose appearance is wrong.
  2. In Computed, confirm the final property value, its source rule, and box dimensions.
  3. In Styles, expand the property and inspect competing declarations. Crossed-out declarations lost the cascade; hover or follow the source link to find their stylesheet and line.
  4. Toggle a declaration or selector to test a hypothesis before editing source files.
  5. Check whether a rule is in a layer, whether another rule is unlayered, and whether !important changes the ordering.
  6. Inspect box-sizing, margins, padding, border, min/max sizes, and the containing block before changing dimensions.
  7. Test at multiple viewport widths and zoom levels. A fix that works at one width may hide overflow at another.

Best Practices Summary

  • Read the cascade from broad priority to detail: relevance, origin/importance, layer, specificity, scope proximity, then source order.
  • Keep selectors understandable and specificity low. Prefer component ownership and explicit layer order over escalating selectors.
  • Treat !important as an exceptional tool, not a normal override strategy.
  • Use a consistent box-sizing: border-box baseline when it fits the project.
  • Remember that adjacent vertical margins may collapse in normal block flow.
  • Choose units by reference: rem for root-scaled values, em for local scaling, ch for approximate line length, % for a containing reference, and dvh for the dynamic viewport.
  • Use semantic, scoped custom properties for design tokens and provide fallbacks only when a real fallback is meaningful.
  • Use @layer to make stylesheet ownership and override order explicit; account for unlayered styles and reversed important-layer precedence.
  • Debug the computed value and box in DevTools before adding another declaration.
  • Preserve user font scaling and zoom; do not fix overflow by clipping focus or content.

Practice Exercises

Exercise 1 — Predict and repair the cascade

Assume all rules are relevant author declarations, there are no transitions or animations, and the button is inside #checkout:

css
@layer base, components, utilities;

@layer components {
  #checkout .button { color: maroon; }
}

@layer utilities {
  .text-green { color: green; }
}

.button { color: navy; }

The element is:

html
<button id="pay" class="button text-green">Pay now</button>
  1. What color wins, and why?
  2. Change the CSS so the utility can override the component color without !important or an ID selector.

Exercise 2 — Diagnose a sizing and spacing problem

The design calls for a panel whose outside width is at most 36rem, with 1.5rem padding and a 2px border. It should have at least the height of the visible mobile viewport, allow content to extend when necessary, and have a readable text measure. A child heading's top margin should not appear outside the panel.

Write CSS that meets these requirements. Explain:

  1. Why the original content-box width would exceed the intended maximum.
  2. Which unit you used for minimum height and why.
  3. How your CSS prevents the heading margin from collapsing through the parent.

Solutions

Solution 1

  1. The button is navy. For normal declarations, unlayered author styles outrank declarations in named layers. The unlayered .button rule therefore wins before specificity is compared. If that rule were removed, the utility declaration would win over the component declaration because utilities comes later than components.

  2. Remove the unlayered .button declaration and make component defaults low-specificity:

css
@layer base, components, utilities;

@layer components {
  .button { color: maroon; }
}

@layer utilities {
  .text-green { color: green; }
}

Now the later utilities layer wins over the components layer for normal declarations without !important or an ID selector. The important lesson is to evaluate layers before specificity. A high-specificity selector does not rescue a declaration in a lower-priority normal layer.

Solution 2

css
.panel {
  box-sizing: border-box;
  width: min(100% - 2rem, 36rem);
  min-height: 100vh;
  min-height: 100dvh;
  padding: 1.5rem;
  border: 2px solid #ccd2dc;
  display: flow-root;
}

.panel__copy {
  max-width: 62ch;
}

border-box makes the declared maximum include the padding and border. Under content-box, a 36rem content width would render as 36rem + 3rem + 4px, exceeding the target. The min() expression keeps side gutters on narrow screens while capping the outside width on larger screens; an auto horizontal margin can be added if the panel should be centered.

min-height allows the panel to grow beyond the viewport when content needs more room. 100vh is the fallback, and 100dvh tracks the dynamic viewport on browsers that support it. The flow-root formatting context prevents the first child's top margin from collapsing through the panel. The paragraph measure is capped at approximately 62 characters using ch.