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.
- 1The layer stack is the architecture
- 2Tokens in three tiers
- 3Component rules
- 4Utilities: few and final
- 5Scoping with @scope
- 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:
@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.
Tokens in three tiers
A flat list of 200 custom properties is as hard to maintain as 200 hex codes. Tier them:
- Palette: raw values, named by what they are.
--violet-600: oklch(52% 0.22 290); - Semantic: roles, named by what they’re for.
--color-brand: var(--violet-600); --surface: light-dark(…); - 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:
@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:
@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, utilitiesdecides 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-accentthat 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.