CSS architecture at scale
What CSS architecture controls
CSS architecture is a set of boundaries for class names, files, and overrides. It becomes visible when two teams both create .title, when deleting a component leaves uncertain global rules behind, or when a new selector needs extra specificity to win.
The browser does not recognize BEM, CSS Modules, CSS-in-JS, or Tailwind as separate cascade systems. Each approach eventually produces declarations and selectors. The cascade still decides which declaration applies.
Wrong "A locally scoped class cannot be overridden by other CSS."
Right Local scoping prevents accidental name collisions. A generated selector still competes through origin and importance, layers, specificity, scoping proximity, and order of appearance.
BEM makes global names predictable
BEM divides a component name into a block, an optional element, and an optional modifier. The documented forms are .block, .block__element, .block--modifier, and .block__element--modifier.
<article class="card card--featured">
<h2 class="card__title">Architecture notes</h2>
</article>
.card { padding: 1rem; }
.card__title { font-size: 1.25rem; }
.card--featured { border-color: gold; }
The base class stays on an element when a modifier is added. BEM's naming guide also recommends class selectors without tag names or IDs, and it shows .block__elem instead of .block .block__elem. That keeps the element class usable without encoding the current DOM nesting in the selector.
Wrong "BEM scopes a class to its component at build time."
Right BEM is a naming convention. .card__title remains a global class name, so consistent naming and review enforce the boundary.
CSS Modules transform local names
A CSS Module is a CSS file whose class and animation names are locally scoped by default. Importing the module from JavaScript returns an object that maps local names to generated global names.
/* Card.module.css */
.title {
font-size: 1.25rem;
}
import styles from "./Card.module.css";
export function Card({ children }) {
return <h2 className={styles.title}>{children}</h2>;
}
Another module may also define .title without producing the same global class name. The gain is collision resistance and an explicit dependency from the component to its stylesheet. The CSS is still ordinary CSS after transformation.
Utility classes put the choice in markup
Tailwind styles an element by combining single-purpose presentational classes in markup. Its utilities come from a predefined design system, and variants apply utilities under conditions such as a hover state or a breakpoint.
<button class="rounded bg-sky-500 px-4 py-2 hover:bg-sky-700">
Save
</button>
This is different from a browser style attribute. A utility class can represent a pseudo-class or a media query, while an inline style cannot target those conditions by itself.
Wrong "Utility CSS has inline-style specificity because the class appears on the element."
Right The markup contains class names. Tailwind generates CSS selectors for those classes, so their declarations follow the cascade like other stylesheet rules.
CSS-in-JS takes another route. For example, styled-components creates a component that combines an element with CSS rules, generates unique class names, and can derive styling from props. That is library behavior, not a new browser cascade.
const Notice = styled.aside`
color: ${(props) => (props.$urgent ? "crimson" : "inherit")};
`;
The first architecture question is therefore concrete: where does a style belong, how does it receive a name, and which other styles is it allowed to override?
A collision-free name can still lose
Suppose a CSS Module generates a unique class for a button, yet the button keeps a color from another stylesheet. Local naming solved one problem. It did not change how the browser ranks declarations.
The cascade filters relevant rules, then considers origin and importance. Within an origin it accounts for cascade layers. Specificity is compared only among declarations in the origin and layer that has precedence. If specificity ties, scoping proximity can matter, followed by order of appearance.
@layer components, utilities;
@layer components {
#checkout .button {
color: navy;
}
}
@layer utilities {
.text-red {
color: red;
}
}
For normal author declarations, .text-red wins on an element matching both rules. utilities was declared after components, and layer precedence is resolved before the ID-heavy selector's specificity is considered.
Wrong "Specificity is the first comparison whenever two selectors match."
Right Origin, importance, and layer precedence can discard a declaration before specificity is compared.
Reserve cascade layer order once
The statement form of @layer establishes order without adding rules. Later normal layers have greater precedence than earlier layers.
@layer vendor, reset, base, components, utilities, overrides;
Files can append rules to a named layer without changing that original order.
@import url("third-party.css") layer(vendor);
@layer components {
.dialog__title {
font-size: 1.25rem;
}
}
@layer utilities {
.text-sm {
font-size: 0.875rem;
}
}
Putting third-party normal CSS in an early layer gives later application layers precedence without copying its selectors or escalating specificity. This works across architecture styles: a BEM component can live in components, and custom utility rules can live in utilities.
There is a migration trap. Normal declarations outside every layer outrank all normal declarations inside named or anonymous layers in the same origin.
@layer components {
.card .title { color: navy; }
}
/* This unlayered normal rule has higher cascade precedence. */
.title { color: tomato; }
Adding layers only around new code can therefore make old global CSS unexpectedly stronger. Move legacy rules into the layer plan, or accept that they remain a higher precedence tier during a staged migration.
Important declarations reverse the layer order. Among important declarations, an earlier layer has precedence over a later one, and important layered declarations outrank important declarations outside layers. This reversal protects earlier concerns from later important overrides, but it also means a layer plan needs an explicit policy for !important.
Scoping approaches solve different ownership problems
BEM records ownership in a readable global name. Its element selector stays flat:
.search-form__submit { /* ... */ }
CSS Modules transform local class and animation names and expose the mapping to JavaScript:
import styles from "./SearchForm.module.css";
return <button className={styles.submit}>Search</button>;
Runtime CSS-in-JS libraries can bind rules to component definitions and compute rules from props. In styled-components, the library generates unique class names and injects styles. Its documentation recommends declaring styled components outside render; declaring one inside render creates a new component identity and injects new rules on each render.
const Submit = styled.button`
background: ${(props) => (props.$primary ? "navy" : "gray")};
`;
function SearchForm() {
return <Submit $primary>Search</Submit>;
}
Tailwind moves most composition to markup by combining primitive utilities. State and responsive variants produce conditional CSS selectors and media-query rules.
<button class="bg-sky-900 px-4 py-2 hover:bg-sky-800 md:px-6">
Search
</button>
These approaches can coexist. A module may use Tailwind utilities for layout and a CSS Module for a selector that reads more clearly as component CSS. The architecture still needs rules for layer placement, global styles, runtime-injected styles, and the point at which repeated utilities become an extracted component in that codebase.
Wrong "Choosing a scoped styling tool removes the need for cascade governance."
Right Scoping controls names and ownership. Layers control precedence groups. Specificity and source order still resolve conflicts within the winning group.
Architecture is an override contract
At scale, the useful question is not which styling approach wins a popularity contest. The useful question is which code owns a declaration, how its selector reaches the page, and which precedence tier it occupies.
A mixed application might contain a reset, third-party widgets, BEM legacy components, CSS Modules, runtime-generated component styles, and Tailwind utilities. Each source can be reasonable in isolation. Without a shared override contract, teams compensate for uncertain precedence with selector duplication and !important.
@layer vendor, reset, base, components, utilities, overrides;
For normal declarations, that order gives later layers more precedence. A selector in utilities can beat a more specific selector in components. Within the winning layer, normal specificity rules still apply. Equal specificity can proceed to scoping proximity and then order of appearance.
Wrong "A low-specificity architecture means every selector must have the same weight."
Right Keep selectors replaceable, then use layers to express cross-category precedence. Specificity remains available for relationships within a category.
Put every source on the map
Third-party CSS can enter an early layer at import time.
@import url("vendor-datepicker.css") layer(vendor);
An anonymous layer also has lower normal precedence than unlayered styles, but it cannot be reopened later. Named layers are easier to govern across files because rules can be appended by redeclaring the name without changing its established position.
The unlayered tier needs deliberate treatment. Normal unlayered author styles outrank normal styles in every named and anonymous layer. During migration, a forgotten global file can therefore override a carefully ordered overrides layer.
@layer overrides {
.account-name { color: navy; }
}
/* Higher precedence than every normal layered author rule. */
.account-name { color: tomato; }
A safe migration demonstrates the intended destination in code:
@layer legacy, components, utilities, overrides;
@layer legacy {
.account-name { color: tomato; }
}
@layer overrides {
.account-name { color: navy; }
}
Important declarations need a separate audit. Their layer order is reversed, so an important declaration in vendor outranks an important declaration in overrides. Important declarations inside layers also outrank important declarations outside layers. Treating !important as an escape hatch can therefore defeat the normal layer policy in a less obvious direction.
Runtime CSS-in-JS has an insertion boundary
styled-components documents that it injects a <style> tag at the end of <head> by default. Those declarations are unlayered unless an integration explicitly puts them in a layer, so normal declarations from styled-components outrank normal declarations in the named author layers shown above. The named layer order does not control that conflict.
When an unlayered global class and a generated styled-component class have equal specificity, the generated rule can win through later source order. A host selector with greater specificity can still beat the generated single-class selector.
/* A host selector with a class and two type selectors. */
body.host button {
padding: 1.5rem;
}
const Button = styled.button`
padding: 1rem;
`;
The generated class name prevents accidental name duplication. It does not provide immunity from host CSS.
Dynamic rule generation is another boundary. styled-components documents that a dynamic interpolation can generate a new class when its value changes. Its current guidance uses a static rule plus an attribute for a bounded state, and an inline style for highly unique per-item values in server-rendered lists.
const Card = styled.article`
&[data-elevated] {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
}
`;
function Result({ elevated, hue }) {
return (
<Card
data-elevated={elevated || undefined}
style={{ background: `hsl(${hue}, 70%, 90%)` }}
/>
);
}
This is library-specific behavior. "CSS-in-JS" describes a family of approaches, so build-time extraction and runtime injection must not be discussed as if they have identical costs or ordering.
Utility CSS still needs component boundaries
Tailwind combines single-purpose classes in markup and generates selectors for variants such as hover: and responsive breakpoints. Its predefined design system constrains the values available to those utilities. That can reduce ad hoc declarations, but the classes remain stylesheet selectors.
<button class="rounded bg-sky-500 px-4 py-2 hover:bg-sky-700 md:px-6">
Save
</button>
Tailwind's documentation supports adding component classes to its components layer so utility classes can override them. That gives a codebase a documented escape from repeating a stable group while preserving utility precedence.
@layer components {
.btn-primary {
border-radius: var(--radius-md);
background-color: var(--color-sky-500);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
}
}
Wrong "BEM, CSS Modules, CSS-in-JS, and utilities are mutually exclusive architectures."
Right They govern different concerns. BEM governs global naming, CSS Modules transform local names, runtime CSS-in-JS binds generation to component execution, utilities provide composable presentational selectors, and cascade layers order precedence groups.
The enforceable senior answer names the exceptions too: which global selectors are allowed, where vendor CSS enters, whether runtime-injected styles remain unlayered, and how the team verifies that production style order matches the policy.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.