Skip to content

CSS Architecture for Large Projects

Most CSS architecture advice is a workaround for features the browser now ships natively. Here is what a large stylesheet should actually look like in 2026, built on cascade layers, container queries and scope.

Share
Layered abstract diagram representing a scalable CSS architecture

Nearly every piece of CSS architecture advice written between 2012 and 2022 exists for one reason: the language was missing features, and we invented conventions to paper over the gaps. BEM was a naming convention that simulated scoping. ITCSS was a file-ordering convention that simulated the cascade layers we didn't have. Sass variables simulated custom properties. Media queries at the page level simulated the component-level queries nobody could write yet.

Almost all of those gaps have closed. Cascade layers, container queries, native nesting, :has(), @scope, subgrid, OKLCH colours and logical properties are all Baseline now, which means every major browser engine has shipped them and they have been stable long enough to use without a feature query. That changes the job. Architecture in 2026 is less about inventing conventions to protect yourself from the cascade, and more about deciding deliberately how you want the cascade to behave.

This is what that looks like in practice on a codebase big enough that no single person has read it all.

What actually goes wrong at scale

Before reaching for a structure, be precise about the failure modes. Large stylesheets rarely collapse because they are large. They collapse in three specific ways.

Specificity becomes a negotiation. Someone needs a button to look different inside a modal. The design system's selector is more specific than theirs, so they add a parent class. Then someone else does it again. Six months later, the file is full of selectors nobody can safely simplify, and !important has become the only reliable tool in the building.

Nobody can delete anything. This is the real cost. When you cannot tell which markup a rule serves, you never remove it; you only add. A codebase where deletion is unsafe grows monotonically until a rewrite becomes cheaper than maintenance. Judge every architectural decision below against this: does it make a rule safe to delete?

Components only work where they were designed. A card built against @media (min-width: 48rem) looks correct in the main column and broken in the sidebar, because the viewport does not know the difference. The usual fix is a modifier class, then a modifier of the modifier, then a props API that exists solely to pass layout context down a tree.

Everything that follows targets one of those three.

Layer one: write down the cascade contract

Cascade layers are the single highest-leverage change you can make to an existing codebase, and they are close to free to adopt.

You declare the precedence order once, at the top of your entry stylesheet, before any rules exist:

@layer reset, tokens, base, layout, components, overrides, utilities;

From that point on, precedence is decided by layer, not by selector weight. A single class in utilities beats a three-selector chain in components. A vendor stylesheet you import into reset cannot outrank anything, no matter how it was written. Specificity still applies, but only within a layer, where it is a local problem rather than a global one.

Two details matter more than the syntax.

Unlayered styles win over every layer, always. This is useful during migration, since existing CSS keeps working while you move it in piece by piece, but it is a trap if you forget. Set a lint rule that requires every declaration in your own source to sit inside a layer, and treat anything unlayered as a bug.

Third-party CSS can be layered at import time, which is the part teams tend to miss:

@import url("vendor/date-picker.css") layer(vendor);

That one line converts an entire dependency from something you fight with into something you sit above by default. If you have ever shipped a wrapper class purely to outrank a library, this deletes it.

Layer names should describe intent, not folders. components is a promise about precedence. cards is not.

Layer two: treat tokens as the public API

The tokens layer is where the design decisions live, and it should be the only place a raw value appears. Everything downstream consumes variables.

@layer tokens {
  :root {
    --space-unit: 0.25rem;
    --space-3: calc(var(--space-unit) * 3);
    --space-6: calc(var(--space-unit) * 6);

    --hue-brand: 205;
    --colour-brand: oklch(62% 0.16 var(--hue-brand));
    --colour-brand-quiet: color-mix(in oklch, var(--colour-brand) 20%, canvas);

    --surface: light-dark(oklch(99% 0 0), oklch(21% 0.02 250));
    --ink: light-dark(oklch(25% 0.01 250), oklch(94% 0 0));
  }
}

Three things are doing real work here. OKLCH keeps lightness perceptually consistent across hues, so a tint that reads correctly on blue also reads correctly on yellow, which is exactly the problem that made hand-tuned palettes so expensive to maintain. color-mix() derives variants at runtime, so a themed surface does not need a build step to regenerate forty hex codes. light-dark() collapses the usual duplicated dark-mode block into a single declaration.

For any custom property you intend to animate or constrain, register it:

@property --card-elevation {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

Registration gives the property a type, which means the browser can interpolate it. An unregistered custom property is just a string, and animating it produces a hard jump at the halfway point. It also gives you a guaranteed initial value, which removes a whole class of "undefined variable resolves to nothing and the layout falls over" bugs.

Naming is worth some discipline. Two tiers work well: primitives (--blue-500, --space-3) that describe what a value is, and semantic tokens (--colour-danger, --space-card-gutter) that describe what it is for. Components consume semantic tokens only. When the brand changes, you retheme by editing one tier, not by grepping the components layer.

Layer three: components that read their own context

Container queries fix the third failure mode, and the mental shift is real: a component stops asking how wide the window is and starts asking how much room it has been given.

@layer components {
  .card-grid {
    container: card-grid / inline-size;
    display: grid;
    gap: var(--space-3);
  }

  @container card-grid (inline-size > 34rem) {
    .card {
      grid-template-columns: 12rem 1fr;
      gap: var(--space-6);
    }
  }
}

Drop that grid into a 280px sidebar, and it stacks. Drop it into a full-bleed section, and it goes horizontal. No modifier class, no prop threading, no layout knowledge leaking upward into the page template.

Container units are the other half. cqi resolves against the container's inline size rather than the viewport, so type and spacing inside a component can scale with the component:

.card__title {
  font-size: clamp(1rem, 0.8rem + 1.4cqi, 1.5rem);
}

Style queries extend the same idea to non-dimensional context. An ancestor sets a custom property, and descendants respond to it without needing a class applied to each one:

.panel--dense { --density: compact; }

@container style(--density: compact) {
  .card { padding: var(--space-3); }
}

This is the cleanest replacement I have found for the deep modifier chains that used to accumulate around theming and density variants. One property at the top, and the subtree adapts.

Two caveats worth knowing before you commit. Setting container-type: inline-size establishes containment, so the element no longer sizes itself from its children's inline dimensions, which occasionally produces a surprise the first time. And a component cannot query itself, only an ancestor, so most components need a wrapper element to act as the container. That wrapper is a small structural tax, and it is generally worth paying.

Layer four: scope, so that deletion is safe

@scope is the feature that most directly attacks the "nobody can delete anything" problem, because it lets you state in CSS where a block of rules applies and where it stops.

@layer components {
  @scope (.prose) to (.embed, .code-sample) {
    a { text-decoration-thickness: 0.08em; }
    p + p { margin-block-start: var(--space-3); }
  }
}

That reads as: style links and paragraphs inside .prose, but stop at any .embed or .code-sample. It is the donut scope pattern, and it solves the problem that made bare element selectors unusable in shared codebases. Long-form article styling is the obvious case: you want loose element selectors for content you do not control, and a hard boundary so they never leak into a widget someone embedded halfway down the page.

Scope also has a proximity rule. When two scoped rules match, the one whose scope root is nearer in the DOM wins, regardless of specificity. Nested themes no longer need escalating selector weight to override each other.

A practical note: this is not a replacement for CSS Modules or the equivalent in whatever framework you use. Build-time scoping guarantees uniqueness and gives you dead-code detection from your bundler. @scope gives you runtime boundaries and proximity, which build-time scoping cannot express. Large projects usually want both, each doing a different job.

Where BEM still earns its keep

Given layers and scope, is a naming convention still worth the typing? Partly.

The part BEM existed to solve, preventing collisions, is now handled better by the platform and by build-time scoping. Keeping the full block__element--modifier grammar purely for collision safety is redundant work.

The part still worth keeping is the readability of the block/modifier distinction. card__title tells you at a glance that a rule belongs to a component and is not a general heading style, which matters when you are reading a diff and not a file tree. What I would drop is deep element chains: card__body__list__item__link They describe DOM structure, not intent, and they break the moment the markup is refactored, which is precisely the fragility the convention was supposed to prevent.

A reasonable 2026 position: block and modifier names for components, native nesting for internal structure, no element names more than one level deep.

Nesting, and how deep is too deep

Native nesting removed the last common reason to run a preprocessor, and it brings the same hazard preprocessors did. Nesting mirrors the DOM by default, and DOM-mirroring selectors are brittle and expensive.

The rule I hold to is to go two levels inside a component block, and use & explicitly rather than letting relationships be implied:

.card {
  padding: var(--space-3);

  &:hover { --card-elevation: 1; }

  & .card__title { font-weight: 600; }

  @container card-grid (inline-size > 34rem) {
    padding: var(--space-6);
  }
}

The last block is the underrated part. Nesting an at-rule inside the component keeps the responsive behaviour next to the thing it modifies, instead of in a breakpoint block six hundred lines away. That single habit does more for maintainability than any naming scheme.

Also worth internalising: :is() and :where() are specificity tools, not just shorthands. :where() contributes zero specificity, which makes it ideal for reset and base layers where you want defaults that anything can override without effort.

What is left for Sass

Less than you would expect, and not nothing.

Nesting, variables, colour manipulation and file concatenation are all native. The remaining genuine gaps are loops, build-time conditionals and mixins that accept blocks of declarations. If you are generating a fifty-step spacing scale or a matrix of utility classes, a preprocessor or a small build script still saves real work.

CSS custom functions are closing the logic gap too, though they are newer than the Baseline features above and want a fallback:

@function --space(--steps) {
  result: calc(var(--steps) * var(--space-unit));
}

.card {
  padding: 0.75rem;          /* everyone gets this */
  padding: --space(3);       /* used only where supported */
}

An unsupported function call makes that second declaration invalid, and the browser discards it and keeps the first. Same cascade-based progressive enhancement we have relied on for two decades.

If you are starting fresh, the common stack now is plain CSS compiled with Lightning CSS, which is fast, handles nesting fallbacks, and minifies in one pass. Reaching for Sass by reflex is no longer the default answer.

Utilities, and being honest about Tailwind

Utility-first frameworks and structured CSS are usually presented as opposing camps, which is not how they behave in a real codebase. Tailwind v4 is itself built on custom properties and cascade layers, and it defines its tokens in CSS with @theme rather than in a JavaScript config. It sits inside the same architecture described here rather than replacing it.

The useful distinction is what belongs where. Utilities are excellent for one-off spatial adjustments at the call site, where writing a named component would be ceremony around a single margin value. Components are better wherever a pattern repeats and needs to change in one place later. The failure mode is the middle ground: a twenty-class string that is really an unnamed component, copied into nine templates, which cannot be changed centrally because it was never given a name.

Either way, put your utilities layer last in the layer order. That is what makes an override at the call site work without !important, and it's why cascade layers and utility CSS fit together so naturally.

Performance in 2026: measure the right thing

The old advice was minify and compress. Do that, obviously, but your build tool already does, and it is rarely where the time goes now.

The costs that actually show up in a trace on a large site are different. Chained @import statements create a request waterfall, because the browser cannot discover the second file until the first has parsed, so bundle your imports for production. Unused CSS costs parse and style-recalculation time on every navigation, so measure coverage rather than guessing. Expensive selectors matter far less than they used to, with one exception: broad rules that force style recalculation across a very large DOM during animation still hurt.

Two properties are worth knowing well. content-visibility: auto on long off-screen sections lets the browser skip rendering work until it is needed, which is one of the largest single wins available on content-heavy pages. And contain: layout on components with independent internal layout keeps a change inside a card from triggering layout on the whole document.

Critical CSS inlining still helps for first render, but it is a build concern, not an architecture concern. Do not let it distort how you organise source files.

How you know it is working

Architecture that is not enforced decays back to whatever is fastest to type. A few checks are worth wiring into CI.

Lint for unlayered declarations, !important outside a defined escape hatch, and hard-coded colours or spacing values outside the tokens layer. Stylelint handles all three with existing rules, so you don't need custom tooling.

Set a specificity budget and fail the build when it is exceeded. Once layers are in place, a rising specificity number reliably signals that someone is fighting the cascade instead of using it, and it is much easier to catch as a number than in review.

Run visual regression tests on components at multiple container widths, not just at your old viewport breakpoints. This is the part teams skip after adopting container queries, and it is exactly where the new bugs live: a component that renders correctly at 1440px viewport and breaks at 320px container width will not be caught by any test suite that only resizes the window.

Track deletions. If your CSS only ever grows, the architecture isn't doing its job, no matter what the file structure looks like.

Where to start on an existing codebase

You don't need a rewrite, and you shouldn't attempt one. The order that works:

Declare your layer order at the top of the entry stylesheet and wrap third-party imports in a vendor layer. Nothing else changes yet, since unlayered styles still win, but you now have somewhere to put things.

Move your reset and base styles into layers next, because they are low risk and immediately let you delete the defensive specificity that accumulated around them.

Extract hard-coded values into a tokens layer, one category at a time. Start with colour, since it has the clearest boundaries.

Then convert components as you touch them, moving each into the components layer and replacing its viewport media queries with container queries. Do this when you're already changing a component for other reasons, rather than as a dedicated project, because the diff is easier to review and the regression risk is contained.

Add @scope last, and only where leakage is a real problem you have actually hit. It is the feature most likely to be adopted for its own sake rather than for a need.

The point of all this

Good CSS architecture in 2026 is not a folder structure or a naming convention. It is a set of explicit decisions about precedence, ownership and context, written in a language that can finally express all three directly.

Layers say who wins. Tokens say where values come from. Container queries say how a component adapts. Scope says where a rule stops. Get those four right, and the naming conventions become a matter of taste rather than a load-bearing part of the system.

The measure that matters has not changed, though. A healthy stylesheet is one where a new engineer can add a component without reading the whole codebase, and delete an old one without holding their breath.