EduFlow
Modern CSS Mastery
0%

Section 4 · Motion and Architecture

CSS Architecture at Scale

19 min3 min readCode playground

Lesson at a glance

Organise a growing stylesheet with cascade layers, token tiers, single-class components and a small set of utilities, so a team can change CSS without fear.

  1. 1The layer stack is the architecture
  2. 2Tokens in three tiers
  3. 3Component rules
  4. 4Utilities: few and final
  5. 5Scoping with @scope
  6. 6Your turn

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Waypoint’s front end started with one developer and one styles.css. Two years later there are five developers, forty components, a sponsor microsite that reskins the schedule, and a stylesheet nobody dares to delete from. Every bug fix is a new override, every override is a little heavier than the last, and the !important count only goes up.

That’s not a discipline problem. It’s an architecture problem, and everything in this course so far gives you the pieces to fix it.

The layer stack is the architecture

Start the entry stylesheet with one statement that names every kind of CSS you have, in priority order:

main.css
@layer reset, tokens, base, layouts, components, utilities;

@import url("reset.css") layer(reset);
@import url("tokens.css") layer(tokens);
@import url("base.css") layer(base);
@import url("layouts/schedule.css") layer(layouts);
@import url("components/session-card.css") layer(components);
@import url("components/speaker.css") layer(components);
@import url("utilities.css") layer(utilities);

Each layer has one job:

Layer Contains Example
reset Normalising browser defaults *, *::before { box-sizing: border-box; }
tokens Custom properties only --color-brand, --space-m
base Bare element styles, low specificity :where(a) { color: var(--link); }
layouts Page and region structure .schedule { display: grid; gap: … }
components One block per component .session-card { … }
utilities Single-purpose overrides .u-hidden, .u-visually-hidden

Because layers decide conflicts between kinds, selectors within each layer can stay flat: one class for a component, :where() for base styles. Nobody needs an ID or a three-class chain to win, so nobody writes one.

Three token tiers connected by arrows: palette values such as violet 600 feed semantic tokens such as color brand, which feed component tokens such as card accent. A sponsor wrapper overrides card accent. Beside them, the layer stack from reset to utilities. --violet-600 --color-brand --card-accent .theme-sponsor Layer order 1 reset 2 tokens 3 base 4 layouts 5 components 6 utilities later wins
Tokens flow from raw values to roles to components; layers decide which kind of rule wins

Tokens in three tiers

A flat list of 200 custom properties is as hard to maintain as 200 hex codes. Tier them:

  1. Palette: raw values, named by what they are. --violet-600: oklch(52% 0.22 290);
  2. Semantic: roles, named by what they’re for. --color-brand: var(--violet-600); --surface: light-dark(…);
  3. Component: a component’s public dials, defaulting to semantic tokens. --card-accent: var(--color-brand);

Keep tier 1 small and boring: a dozen hues with a handful of steps each covers most products. The interesting decisions happen in tier 2, where a designer and a developer agree on what “brand”, “surface” or “danger” means in each theme.

Components only read tiers 2 and 3. Then a rebrand changes tier 1, dark mode changes tier 2, and a sponsor page changes tier 3 on a wrapper:

CSS
@layer tokens {
  :root {
    --color-brand: oklch(52% 0.22 290);
    --card-accent: var(--color-brand);
  }
  .theme-sponsor {
    --card-accent: oklch(55% 0.11 210);
  }
}

Every card inside .theme-sponsor turns teal: border, badge, hover state, all of it, because they all read --card-accent.

Component rules

A handful of conventions keeps forty components predictable:

  • One root class, with parts nested inside it: .session-card { h3 { … } .badge { … } }. Nesting stays one or two levels deep.
  • State through attributes: [aria-pressed="true"], [data-state="live"], :has(:checked). The attribute is also the accessible state, so CSS and assistive technology can’t disagree.
  • No outer margins on the root. The component doesn’t know where it lives.

Utilities: few and final

Utilities are single-purpose classes the HTML can apply directly: .u-hidden, .u-visually-hidden, .u-flow (vertical rhythm). Put them in the last layer and they win over any component without !important. Keep the set small and boring. If you find yourself writing .u-mt-17, you’re building a second styling language inside the first; a design-token-driven utility framework is a valid choice, but make it a deliberate one, not drift.

Scoping with @scope

When a component must not leak styles into nested content, such as a CMS-rendered abstract inside a card, @scope limits rules to a subtree and can stop at an inner boundary:

CSS
@scope (.session-card) to (.cms-content) {
  p { margin-block: 0.5rem; }
}

The paragraph rule applies inside cards but not inside .cms-content. @scope became Baseline in March 2026, so it’s a recent tool; layers and single-class components already solve most scoping problems, and @scope handles the remaining “donut” cases.

Your turn

The exercise is a small slice of the old stylesheet: ID selectors, !important, a component with its own margin. Refactor it into layers with a token for the card accent, a sponsor theme, layout-owned spacing and a utility that wins cleanly. The final lesson looks at what all this CSS costs the browser, and how to keep animations and layouts fast.

Key takeaways

  • A layer order such as reset, tokens, base, layouts, components, utilities decides conflicts between kinds of CSS, so selectors can stay at one class.
  • Tokens work best in tiers: raw palette values, semantic roles like --color-brand, and component-level properties like --card-accent that a parent can override.
  • Components should not set their own outer margins; the layout around them owns the spacing, usually with gap.
  • Keep utilities few and single-purpose, in the last layer, so they win without !important.

Further reading

Check your understanding

3 questions

Question 1 of 3

A sponsor page needs every session card’s accent in teal instead of violet. In a token-based architecture, what’s the right change?

Question 2 of 3

Why shouldn’t .session-card set margin-bottom: 16px on itself?

Question 3 of 3

Where should a .u-hidden { display: none; } utility live so it always beats component display rules without !important?

Practice

Code playground

Refactor the card into an architecture

Refactor this slice of the old stylesheet. Keep the HTML as it is.

  1. Declare layers including reset, tokens, components, utilities in that order (you may add layouts or others in between), and put every rule in a layer.
  2. No ID selectors and no !important anywhere.
  3. The card’s accent border reads a --card-accent token that defaults to #6d28d9; .theme-sponsor sets it to #0e7490.
  4. .u-hidden still hides the Draft badge.
  5. Cards have no margins of their own; the .schedule layout puts 16px between them.
<main id="schedule" class="schedule">
  <div class="theme-sponsor">
    <article class="session-card" id="sponsored">
      <h3>Sponsor Talk: Shipping at Scale</h3>
      <span class="badge u-hidden">Draft</span>
    </article>
  </div>
  <article class="session-card" id="regular">
    <h3>CSS Architecture at Scale</h3>
    <span class="badge">Platform</span>
  </article>
</main>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Layers reset, tokens, components, utilities are declared in order
  • No ID selectors and no !important
  • Regular cards use the violet --card-accent token
  • Sponsor cards are teal through the token
  • Changing --card-accent re-themes the card
  • The u-hidden utility still hides the Draft badge
  • Cards have no margins; the layout spaces them 16px apart