Engineering

CSS and Layout

From global cascade to constraint-based systems: how modern CSS actually works

Learning Objectives

By the end of this module you will be able to:

  • Choose between Flexbox and CSS Grid for a given layout requirement and articulate why one fits better than the other.
  • Use container queries to make a component adapt to its container rather than the viewport.
  • Explain how cascade layers eliminate specificity conflicts without resorting to !important or selector gymnastics.
  • Describe how CSS Modules provide compile-time scoping and why this is preferable to runtime CSS-in-JS for most production use cases.
  • Use CSS custom properties to implement a basic design token system and a dark/light theme switcher.

Core Concepts

The Cascade Is Not Your Enemy

The reputation CSS has among backend engineers — unpredictable, global, resistant to encapsulation — is largely a product of how it was used before modern tooling existed. The cascade itself is a well-defined resolution algorithm. When you understand its rules, it becomes predictable.

Specificity has historically been the main source of pain: a class selector with high specificity in a third-party library quietly wins over your lower-specificity rule and there is no compiler error to tell you why. Modern CSS addresses this directly through two mechanisms — cascade layers for architectural control, and CSS Modules for build-time scoping.

Layout: One Dimension vs. Two

CSS has had three generations of layout primitives:

  1. Floats — designed for text flow around images, repurposed for full page layouts. The fundamental problem was float leakage: floated children did not participate in parent height, collapsing the container to zero. Clearfix hacks (.clearfix::after { content: ""; clear: both; display: table; }) became standard practice — a sign the tool was being used against its intent.

  2. Flexbox — a one-dimensional layout model. Items are arranged along a single axis (row or column). Flexbox is content-out: item sizes are determined by their content, and Flexbox distributes the remaining space around them.

  3. CSS Grid — a two-dimensional layout system. You define rows and columns explicitly, then place content into cells. Grid is layout-in: the structure is defined first; content conforms to it.

CSS Grid was the first layout system explicitly designed from the ground up for two-dimensional layout — built with the express purpose of adapting to different screen sizes through intentional structural constraints.

These are not competing tools. The modern best practice combines them: Grid for the overall page skeleton, Flexbox within individual grid cells for aligning and distributing component content.

Flexbox in Detail

Flexbox has a main axis (the primary direction, controlled by flex-direction) and a cross axis (perpendicular to it). Two properties do most of the work:

  • justify-content — distributes items along the main axis: flex-start, flex-end, center, space-between, space-around, space-evenly.
  • align-items — aligns items on the cross axis: stretch (default), flex-start, center, flex-end, baseline.

flex-wrap allows items to wrap onto new lines when they overflow, enabling fluid single-axis responsive behavior — card rows that reflow without explicit media queries.

When to reach for Flexbox: navigation bars, toolbars, button groups, form rows — any situation where you are distributing items in one direction and content should drive the spacing.

CSS Grid in Detail

Grid introduces track sizing through grid-template-columns and grid-template-rows. The fr unit represents a fraction of available space after fixed and intrinsic tracks are allocated.

The minmax() function combined with auto-fit or auto-fill enables intrinsic responsive grids without media queries:

grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));

This automatically adjusts column count as the container grows or shrinks. The distinction between auto-fit and auto-fill matters:

  • auto-fit — collapses empty tracks so content columns stretch to fill available space. Ideal for card wrapping.
  • auto-fill — creates placeholder columns even when empty. Better for structured layouts like forms where column positions should remain stable.

CSS Subgrid (grid-template-columns: subgrid) allows a nested grid to inherit track sizing from its parent. Without subgrid, aligning content across independent nested grids is impossible — feature rows in pricing tables misalign when list lengths differ. Subgrid solves this by anchoring children to the parent's track lines.

When to reach for Grid: page-level layouts (header, sidebar, content, footer), card grids, any structure requiring simultaneous row and column control.

Cascade Layers

@layer declarations establish a named ordering of style buckets. Once declared, layer order beats specificity:

@layer reset, base, components, utilities;

A utility class in the utilities layer will reliably override a complex component selector in the components layer — regardless of how many classes, IDs, or pseudo-elements that component selector contains. Specificity calculations are effectively bypassed between layers.

!important reversal

!important declarations reverse layer precedence: earlier-defined layers win when !important is applied. Avoid mixing !important into layered systems — it breaks the architecture and creates exactly the kind of unpredictability cascade layers were designed to eliminate.

Unlayered CSS (styles outside any @layer) automatically overrides all layered styles. This is by design — it lets legacy code coexist with a new layered system without requiring a big-bang rewrite. You can introduce @layer incrementally; unlayered styles won't be clobbered.

Two related pseudo-classes give fine-grained control:

  • :where() — enforces zero specificity regardless of selector complexity. Useful for third-party CSS that consumers should be able to easily override.
  • :is() — adopts its most specific argument's specificity. Use when you want specificity sensitivity to apply.

Cascade layers have been supported in all major browsers since 2022 (Chrome 99+, Firefox 97+, Safari 15.4+, Edge 99+).

Container Queries

Media queries answer the question "how wide is the viewport?" Container queries answer "how wide is this element's container?" The distinction matters enormously for reusable components.

A card component placed in a sidebar, a main content area, and a full-width grid needs different layouts in each context. With media queries, the card has no way to know which context it's in — the viewport width is the same regardless. With container queries, the card responds to its actual available width.

To use container queries, you must explicitly declare containment on the parent element:

.card-wrapper {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    flex-direction: row;
  }
}

Without container-type, @container rules are ignored.

Container queries support two types:

  • Size queries — based on container dimensions. Require explicit container-type declaration.
  • Style queries — based on CSS or custom property values. Can apply to any non-empty element, but currently lack cross-browser support (Chrome/Edge only; Safari and Firefox do not support them as of 2026).

Container query units cqi and cqb behave like vw/vh but relative to container dimensions — 1% of inline size (width) and block size (height) respectively. cqi is preferred over cqw for international audiences because it adapts to the document writing mode.

Container size queries are production-ready: approximately 93% global browser support across major browsers as of late 2024, with stable support since Chrome 107, Safari 16.6, and Firefox 110.

CSS Modules

CSS Modules reframe the problem of global scope at the build step, not at runtime. Every class name in a .module.css file is hashed to a locally unique identifier computed from the file path and original class name:

/* Button.module.css */
.button { background: blue; }
import styles from './Button.module.css';
// styles.button === "Button_button_3f8a2" (or similar)

The original class name never reaches the browser. Collisions cannot occur in production — the build system guarantees it. This is directly analogous to module-private names in backend languages: you get the isolation guarantee from the type system (here, the build system) rather than from convention and discipline.

Compared to runtime CSS-in-JS:

  • CSS Modules produce plain CSS at build time with zero runtime overhead.
  • Runtime CSS-in-JS solutions add 15–45KB to JavaScript bundles and incur parsing and DOM injection costs at runtime.
  • Applications with 500+ components show 20–35% better performance with CSS Modules.

The :global() escape hatch lets you target external library classes from within a module file. The composes keyword enables DRY reuse — one class can inherit styles from another within or across module files, similar to mixins without the abstraction cost.

BEM naming conventions remain best practice inside CSS Modules: collision prevention is guaranteed by the build, but clear naming still improves readability and organization.

CSS Custom Properties

CSS custom properties are often described as "CSS variables," but the critical distinction is when they resolve: at runtime in the browser, not at compile time. Preprocessor variables (Sass, Less) are substituted before the browser sees the CSS. Custom properties participate in the live cascade.

:root {
  --color-primary: #0070f3;
}

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

Because they participate in the cascade, they inherit through the DOM tree like any other property. A child element can override an inherited custom property locally without affecting global scope — this makes scoped design system implementations straightforward.

JavaScript can read and write them natively:

element.style.setProperty('--color-primary', '#ff0000');
getComputedStyle(element).getPropertyValue('--color-primary');

Theme switching uses class-based or attribute-based overrides:

:root { --color-bg: white; --color-text: black; }
:root[data-theme="dark"] { --color-bg: #1a1a1a; --color-text: white; }

The prefers-color-scheme media query lets you set the default without JavaScript:

@media (prefers-color-scheme: dark) {
  :root { --color-bg: #1a1a1a; }
}

Best practice combines both: system preference as default, user override stored in preferences.

The @property at-rule upgrades custom properties to typed, animatable values by declaring their syntax. This allows browsers to interpolate values during transitions. @property reached universal browser support in July 2024 (Baseline Newly Available), making it safe for production without polyfills. Custom properties themselves have over 98% global browser support as of 2026.

Case sensitivity pitfall

Custom property names are case-sensitive. --primary-color and --Primary-Color are different properties. Use the convention --color-primary-base (lowercase, hyphen-separated) throughout to avoid silent bugs.

Worked Example

Scenario: A card component used in three contexts — a narrow sidebar, a main content grid, and a full-width feature row. It must stack vertically in tight spaces and go horizontal in wider containers. Colors must support dark mode.

Step 1: Define design tokens

/* tokens.css */
:root {
  --color-surface: #ffffff;
  --color-text-primary: #111111;
  --color-border: #e0e0e0;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --radius-card: 8px;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-surface: #1a1a1a;
    --color-text-primary: #f5f5f5;
    --color-border: #333333;
  }
}

Step 2: Set up the page layout with Grid

.page-layout {
  display: grid;
  grid-template-columns: 240px 1fr;
  grid-template-rows: auto 1fr auto;
  grid-template-areas:
    "header  header"
    "sidebar main"
    "footer  footer";
  min-height: 100vh;
}

Step 3: Register the card wrapper as a container

/* CardGrid.module.css */
.wrapper {
  container-type: inline-size;
  container-name: card-context;
}

Step 4: Write the card styles with container query

/* Card.module.css */
.card {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-card);
  padding: var(--space-6);
  color: var(--color-text-primary);
}

@container card-context (min-width: 480px) {
  .card {
    flex-direction: row;
    align-items: flex-start;
  }

  .card__image {
    flex: 0 0 160px;
  }
}

What this achieves:

  • The card is vertical in the sidebar (container is 240px wide) and horizontal in the main area (container is wider than 480px) — the same component, no duplicated implementations.
  • Dark mode works via custom property override — no JavaScript required.
  • Grid handles the macro structure; Flexbox handles alignment inside the card.
  • CSS Modules ensure .card never collides with any other .card in the codebase.

Common Misconceptions

"Container queries replace media queries." They solve different problems. Media queries handle page-level layout decisions: switching from single-column to multi-column, collapsing navigation drawers, global font-size scaling. Container queries handle component-level adaptation: card layouts, widget styling, navigation labeling based on available room. Use both, for their respective responsibilities.

"Cascade layers are a way to avoid thinking about specificity." They are a way to make specificity irrelevant between layers. Within a single layer, specificity still applies normally. The practical benefit is that you establish one predictable ordering at the top of your stylesheet and stop fighting specificity by escalating selector complexity.

"CSS Modules mean you don't need naming conventions." Collision prevention is guaranteed by the build, but human readability is not. BEM or a similar naming convention inside CSS Modules still helps the next developer understand intent without opening the component file.

"CSS custom properties are just like Sass variables." The key difference is runtime resolution. Sass variables are compile-time substitutions — they cannot change after the CSS is generated. Custom properties are live: they respond to cascade overrides, JavaScript assignments, and media query changes. Theme switching that requires Sass to generate multiple full stylesheets requires only a single property override block with custom properties.

"Grid is overkill unless you have a complex layout." grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) is a single line that produces a fully responsive card grid without a single media query. Grid is not exclusively for complex structures — it is also the shortest path to intrinsic responsive layouts.

Active Exercise

Exercise: Port a media-query card to container queries with design tokens

Start with this media-query-based card CSS:

.card {
  display: flex;
  flex-direction: column;
  background: white;
  color: #111;
  border: 1px solid #ddd;
  padding: 24px;
  border-radius: 8px;
}

@media (min-width: 600px) {
  .card {
    flex-direction: row;
    align-items: flex-start;
  }
}

Tasks:

  1. Extract background, color, border-color, padding, and border-radius values into CSS custom properties on :root. Name them using a --category-variant convention (e.g., --color-surface, --space-card).

  2. Add a dark mode block using @media (prefers-color-scheme: dark) that overrides the relevant color tokens.

  3. Create a wrapper element for the card and declare it as a container with container-type: inline-size.

  4. Convert the @media (min-width: 600px) rule to an @container rule. Think carefully: should the breakpoint value change? (Hint: container widths are typically smaller than the viewport widths you used for media queries.)

  5. Verify the card stacks vertically in a narrow wrapper (< 400px) and goes horizontal in a wider one, independently of viewport width.

Reflection questions:

  • What happens if you place two instances of this card in different-width containers on the same page at the same viewport width? Would the media-query version handle this correctly?
  • If you want a component library consumer to be able to override --color-surface for their specific instance of the card without affecting others, where would you put the custom property definition?

Key Takeaways

  1. Flexbox is one-dimensional (content-out), Grid is two-dimensional (layout-in). Compose them: Grid for page structure, Flexbox for component internals.
  2. Container queries make components truly reusable by decoupling responsive behavior from viewport width. A single container-type declaration on the parent is all you need to unlock @container rules.
  3. Cascade layers give architectural control over CSS precedence. Layer order beats specificity. A standard reset → base → components → utilities sequence eliminates specificity conflicts without !important.
  4. CSS Modules provide build-time scoping with zero runtime cost. This is the module-private-names guarantee applied to CSS — enforced by the build system, not by convention.
  5. CSS custom properties resolve at runtime and participate in the cascade. This makes them the right primitive for design tokens and theme switching — they enable dark mode without JavaScript and dynamic updates without CSS-in-JS overhead.

Further Exploration

Guides and References

Tools