039: CSS Architecture and Tooling — BEM, Sass, PostCSS, CSS Modules, and CSS-in-JS
Learning outcomes
By the end of this lesson, you should be able to organize CSS as a codebase grows, explain the BEM naming convention, and describe what Sass and PostCSS contribute. You should also be able to weigh the trade-offs of CSS Modules and CSS-in-JS, choose tooling against a project's actual constraints, and recognize when a tool is being used to conceal gaps in CSS fundamentals.
Prerequisites and retrieval
Before continuing, retrieve selector specificity from 019–020, custom properties from 034, and the component-ownership ideas from the capstone in 032. Those earlier concepts are the foundation for deciding where a rule belongs and how much power it should have.
Architecture first: define ownership
The first architecture question is not which preprocessor or framework to install. It is whether the stylesheet makes ownership understandable. A maintainable stylesheet should let you answer:
- Which selector owns this visual rule?
- Is this a global default, layout relationship, component, state, or utility?
- How can a component be changed without knowing five ancestors?
- Where should tokens live?
- How are third-party styles controlled?
One straightforward source order is:
/* 1. reset */
/* 2. base */
/* 3. layout */
/* 4. components */
/* 5. utilities */
/* 6. overrides, if truly necessary */
This order is a convention, not a substitute for understanding the cascade. Later, modern cascade layers can express the intended order directly and make it easier to keep categories from fighting each other.
BEM
BEM means Block, Element, Modifier. A block is an independent component, an element is a meaningful part of that block, and a modifier describes a variation of the block or element.
<article class="card card--featured">
<h2 class="card__title">Pro plan</h2>
<p class="card__price">$29</p>
</article>
.card {}
.card__title {}
.card__price {}
.card--featured {}
The class names make the ownership relationship visible: card__title belongs to card, while card--featured is a variation of the block. That explicit relationship helps when markup is reused in more than one page.
Strengths:
- class ownership is explicit;
- selectors stay low-specificity;
- styles are relatively independent of DOM depth.
Costs:
- class names can be verbose;
- teams can over-formalize tiny components;
- BEM naming does not solve every cascade or design-system issue.
Use BEM as a naming convention, not a religion. It gives a team a shared vocabulary, but it cannot decide whether a rule is a layout concern, a state, or a component concern for you.
Worked example: fragile selector to component API
This selector reaches through the page structure to find an element:
Fragile:
.pricing section article > div h3 {
color: purple;
}
Its meaning depends on several ancestors, a direct-child relationship, and the element being an h3. A small markup change can silently stop matching it.
Stable:
.price-card__title {
color: purple;
}
The second selector exposes a component API instead. Markup can change from <h3> to another appropriate heading level without coupling presentation to a remote page structure. The semantic heading choice remains an HTML decision; the component class owns the visual rule.
Sass / SCSS
Sass is a preprocessor: it accepts Sass syntax and compiles it into CSS that the browser can consume.
SCSS example:
$brand: #2563eb;
.card {
border: 1px solid #cbd5e1;
&__title {
color: $brand;
}
&--featured {
border-color: $brand;
}
}
The compiled output is ordinary CSS. The browser does not know that the source used $brand or &__title; those conveniences are resolved during the build.
Historically, Sass supplied variables, nesting, mixins, functions, modules, and other features before native CSS had comparable alternatives. Native CSS now includes custom properties, nesting, math functions, cascade layers, and more. That does not make Sass useless, but it does mean each Sass feature should earn its place with clear build-time value.
Sass variable versus CSS custom property
A Sass value looks like this:
$brand: #2563eb;
It is resolved at build time. Once Sass has compiled the file, the browser cannot change $brand through a selector or at runtime.
A CSS custom property looks like this:
:root {
--brand: #2563eb;
}
It exists at runtime and participates in the cascade and inheritance. A component or theme can therefore provide a different value without recompiling the stylesheet.
Choose between them based on the requirement. Use a Sass variable for a build-time decision; use a custom property when the value must change dynamically by selector, theme, or runtime state.
PostCSS
PostCSS is a tool ecosystem that parses CSS and transforms it through configured plugins. It is not a single replacement CSS language with one fixed feature set.
A build may use PostCSS for:
- vendor prefix insertion;
- future-syntax transformations depending on policy;
- linting/optimization pipelines;
- framework processing.
The plugins determine the behavior. One project's PostCSS setup may add prefixes, while another may lint, transform syntax, or optimize output. Treat the configuration as part of the browser-support contract and inspect it when debugging generated CSS.
Do not think of PostCSS as one CSS language. Its behavior depends on configured plugins.
CSS Modules
A component stylesheet might look like this:
/* Card.module.css */
.card {
padding: 1rem;
}
.title {
font-weight: 700;
}
In an application build, imported class names can be scoped locally and hashed. The source can use short names such as .card without assuming that every other component has a different global name.
Conceptual React-style usage:
import styles from "./Card.module.css";
export function Card() {
return <article className={styles.card}>...</article>;
}
Benefits:
- local class-name scope;
- fewer global naming collisions;
- ordinary CSS syntax.
Costs:
- build-tool dependency;
- global/shared styling needs deliberate handling;
- generated class names can complicate debugging until the team understands the mapping.
CSS Modules reduce accidental collisions; they do not remove the cascade, inheritance, tokens, or global base-style decisions. Those still need an intentional architecture.
CSS-in-JS
Conceptually, a CSS-in-JS library may let a component define styles from JavaScript values:
const Button = styled.button`
background: ${props => props.primary ? "blue" : "gray"};
`;
The label covers several different implementations: runtime injection, build-time extraction, object syntax, tagged templates, atomic classes, and others. Do not assume that all CSS-in-JS libraries have the same performance or rendering behavior.
Potential benefits:
- co-location with components;
- dynamic values from application state;
- scoped/generated styles.
Potential costs:
- runtime overhead in some approaches;
- framework/library lock-in;
- server rendering/hydration complexity;
- harder browser-native debugging;
- duplicated abstractions when CSS already handles the state.
Do not choose CSS-in-JS simply because a project uses React. The useful question is whether its particular architecture solves a real dynamic styling or component-system problem better than ordinary CSS or another scoped approach.
Native CSS can now handle many old tooling use cases
Several jobs that once strongly suggested a preprocessor are now available in the platform.
Custom properties:
:root {
--brand: #2563eb;
}
Native nesting:
.card {
padding: 1rem;
& .card__title {
font-weight: 700;
}
}
Cascade layers:
@layer reset, base, components, utilities;
Container queries:
@container (width >= 30rem) { ... }
The point is not that native CSS replaces every tool. Start with the platform capability that fits the problem, then add tooling for the problems that remain. Tooling should solve a project constraint, not add ceremony by default.
Choosing an approach
Plain CSS
Strong choice when:
- project size is manageable;
- platform CSS is sufficient;
- no scoping/build transform is needed.
BEM
Useful when:
- global CSS files need naming discipline;
- classes are hand-authored across templates/components.
Sass
Useful when:
- build-time functions/mixins/modules provide real value;
- existing codebase already depends on it.
PostCSS
Useful when:
- transformation/linting/prefixing pipeline is required.
CSS Modules
Useful when:
- component-local class scoping fits the framework/build.
CSS-in-JS
Useful when:
- a chosen library solves dynamic/co-location/design-system needs better than simpler alternatives.
These choices are not mutually exclusive. For example, a project can use CSS Modules for component ownership, custom properties for runtime themes, and PostCSS for a documented browser-support policy.
Worked example: architecture with layers plus BEM
@layer reset, base, components, utilities;
@layer base {
body {
font-family: system-ui, sans-serif;
}
}
@layer components {
.alert {
padding: 1rem;
border-inline-start: 4px solid var(--alert-accent);
}
.alert--danger {
--alert-accent: #dc2626;
}
}
@layer utilities {
.sr-only {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
The BEM names describe component ownership and variation. The layers describe cascade precedence. Those are separate concerns, so a naming convention and cascade architecture can solve different problems while coexisting.
Deep dive: architecture is a dependency graph
CSS architecture is less about folder names than about controlling dependency direction. A component should not need distant page structure to remain correct, and a broad rule should not unexpectedly depend on a narrow component.
A useful order is:
- reset/normalization;
- design tokens;
- element defaults;
- layout primitives;
- components;
- utilities;
- narrowly scoped overrides.
Cascade layers can make that order explicit:
@layer reset, tokens, base, layout, components, utilities;
Selectors inside a component should depend primarily on the component's own API, not on distant page structure.
Fragile:
.dashboard .right-column .orders .card h3 {
...
}
Stronger:
.order-card__title {
...
}
The stronger selector still needs sensible markup and semantic HTML, but its visual contract is much easier to find, reuse, and change. That is the dependency-graph improvement: fewer unrelated ancestors participate in the rule.
Sass depth: use language features to remove repetition, not hide CSS
Sass can provide modules, functions, mixins, loops, and compile-time variables. Those features are useful when they express a repeated build-time rule clearly.
@use "tokens";
@mixin focus-ring {
outline: 3px solid tokens.$focus;
outline-offset: 2px;
}
.button:focus-visible {
@include focus-ring;
}
Do not create a mixin for every two-line declaration block. Excessive abstraction makes the generated CSS harder to trace and hides the rule a browser developer still has to debug. If native custom properties, nesting, or functions solve the problem, prefer the simpler runtime platform.
Sass variables are resolved at build time. CSS custom properties participate in runtime cascade and inheritance. They are not interchangeable conveniences; they solve different problems.
PostCSS depth: transformation pipeline
PostCSS is an ecosystem for parsing and transforming CSS. Common uses include:
- Autoprefixer based on a browser support policy;
- linting;
- minification;
- syntax transformations;
- custom build-time conventions.
The key architecture question is not “Do we use PostCSS?” It is “Which transformations are in the pipeline, why are they there, and what browser contract do they implement?” That question gives you something concrete to inspect when source CSS and delivered CSS differ.
Do not manually add prefixes just because you remember old browser bugs. Let an explicit support policy drive transformation.
CSS Modules: deeper architecture
A CSS Module typically scopes class names to an imported module:
/* ProductCard.module.css */
.card {
border: 1px solid var(--border);
}
import styles from "./ProductCard.module.css";
element.className = styles.card;
Benefits:
- local naming;
- lower accidental global collision;
- works well with component systems.
Costs:
- still need cascade and inheritance knowledge;
- global tokens/base styles need an intentional home;
- generated names can complicate debugging if tooling is poorly configured.
When a class does not appear in the DOM with its source name, inspect the module mapping and generated stylesheet rather than assuming the rule was discarded. Local scoping changes naming; it does not change how CSS is matched or inherited.
CSS-in-JS: distinguish runtime and extracted approaches
“CSS-in-JS” covers different architectures rather than one implementation.
Runtime style generation can make styles depend directly on JavaScript state, but it may add runtime work, framework coupling, and server-rendering complexity.
Build-time/extracted approaches can provide component-local authoring while emitting static CSS.
Evaluate the implementation, not just its syntax. Compare:
- runtime cost;
- server rendering/hydration;
- caching;
- critical CSS behavior;
- theming;
- type safety;
- debugging;
- team familiarity;
- framework lifecycle.
For static rules, ordinary CSS may be easier to cache and inspect. For stateful styles, a runtime solution may be justified, but the decision should follow measured requirements and the rendering model rather than the presence of a framework alone.
Practical decision matrix
| Need | Start with |
|---|---|
| Small/static site | plain CSS + layers |
| Reusable naming convention | BEM or equivalent component convention |
| Build-time functions/modules | Sass |
| Browser-policy transformations | PostCSS |
| Component-local class names | CSS Modules |
| State-heavy framework styling | evaluate CSS Modules, extracted CSS-in-JS, or runtime CSS-in-JS based on measured needs |
Tooling should make ownership clearer. If it makes ordinary cascade behavior impossible to explain, the architecture has become weaker rather than stronger.
Common mistakes
- Adopting Sass to avoid learning the cascade.
- Using nested selectors five levels deep because SCSS permits it.
- Assuming CSS Modules eliminate all global CSS concerns.
- Using CSS-in-JS for static rules that could be ordinary CSS.
- Introducing a framework before understanding the underlying CSS.
- Treating BEM as a replacement for semantic HTML.
- Letting tool configuration silently change browser support without documentation.
Practice set
- Refactor a deep selector into BEM-style component classes.
- Write the same theme value as Sass variable and CSS custom property; explain runtime differences.
- Sketch a CSS Modules component API.
- List which project requirements would justify PostCSS.
- Compare plain CSS + layers versus a preprocessor architecture for a small site.
Recap
CSS architecture is about ownership and predictable change. BEM supplies naming; Sass and PostCSS operate in the build; CSS Modules provide local scoping; and CSS-in-JS describes a family of application-styling approaches. Learn the platform CSS first, then add tooling where it earns the complexity it introduces.
Official references
- Sass documentation — https://sass-lang.com/documentation/
- PostCSS — https://postcss.org/
- CSS Modules repository — https://github.com/css-modules/css-modules
