CSS Architecture & Naming Conventions
BEM and other naming systems, organizing large stylesheets, and the patterns that keep CSS maintainable as a project grows.
The Problem That Only Shows Up Once a Project Grows
A single-page project with 200 lines of CSS does not need a naming convention — you can see every rule at once, and any class name that is reasonably descriptive works fine. That stops being true almost immediately once a project grows past a handful of files, several contributors, and hundreds of components. Two specific problems appear, and they compound each other: name collisions (two unrelated components both reach for .title, .header, or .active, and one silently overrides the other) and unclear ownership (looking at .item in a stylesheet gives no information about which component it belongs to, whether it is safe to change, or what else on the page might break if you touch it).
/* components/UserCard.css */
.title { font-size: 18px; font-weight: 700; }
/* components/ProductCard.css, added six months later by a different engineer */
.title { font-size: 14px; color: var(--muted); }
/* Both files load on the same page. Whichever loads LAST wins — for BOTH
components — and neither engineer necessarily knows the other's .title
rule even exists. */Naming conventions exist to solve exactly this class of problem, systematically, before it happens — not by being clever, but by making every class name self-describing enough that a collision becomes structurally difficult to create by accident, and by making it obvious at a glance which component and state a given class belongs to.
Block, Element, Modifier — The Three Concepts BEM Is Built On
BEM organizes every class name around three concepts, applied consistently across an entire codebase. A Block is a standalone, reusable component — something that makes sense on its own, like a card, a form, or a navigation menu. An Element is a part of a block that has no meaning outside of it — a card's title, a form's submit button, a menu's individual item. A Modifier is a flag that changes a block or element's appearance or behavior — a card that is featured, a button that is disabled, a menu item that is active.
.block { }
.block__element { }
.block--modifier { }
.block__element--modifier { }
/* Two underscores connect a block to its element.
Two hyphens connect a block (or element) to its modifier.
This is a strict, memorable convention — not a loose guideline. */The double-underscore and double-hyphen separators are deliberate — they need to be visually distinct enough from a normal single hyphen used inside an ordinary multi-word name (like card-wrapper) that a reader can immediately tell, just from the punctuation alone, whether they are looking at a block, an element within a block, or a modifier on either one.
Building a Product Card in BEM, Start to Finish
The clearest way to actually absorb BEM is watching it applied to one real component from start to finish. Here is a product card with an image, a title, a price, a "sale" state, and an add-to-cart button — first the markup, then the full BEM-named stylesheet for it.
<div class="product-card product-card--sale">
<img class="product-card__image" src="/sneaker.jpg" alt="Running sneaker, coral colorway">
<div class="product-card__body">
<h3 class="product-card__title">Trail Runner Pro</h3>
<p class="product-card__price product-card__price--discounted">
<span class="product-card__price-original">$129</span>
<span class="product-card__price-current">$89</span>
</p>
<button class="product-card__button product-card__button--disabled" disabled>
Sold Out
</button>
</div>
</div>.product-card {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.product-card--sale {
border-color: #d32f2f;
}
.product-card__image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.product-card__body {
padding: 16px;
}
.product-card__title {
font-size: 16px;
font-weight: 700;
margin: 0 0 8px;
}
.product-card__price {
font-size: 14px;
}
.product-card__price-original {
text-decoration: line-through;
color: var(--muted);
margin-right: 8px;
}
.product-card__price-current {
color: #d32f2f;
font-weight: 700;
}
.product-card__button {
width: 100%;
padding: 10px;
border-radius: 6px;
background: var(--accent);
}
.product-card__button--disabled {
background: var(--muted);
cursor: not-allowed;
}Notice what is genuinely different here compared to writing this the "obvious" way: every single selector is exactly one class, with zero nesting and zero reliance on parent-child relationships in the CSS itself (like .product-card .title). The relationship between the card and its title is expressed entirely in the name — product-card__title — not in the selector's structure. This is the specific design choice that makes BEM classes safe to reuse: a .product-card__title rule only ever matches an element explicitly given that exact class, regardless of where it happens to sit in the DOM.
block__element__subelement. If a piece of markup feels like it needs to go two levels deep, that is usually a sign it deserves to be its own block (a .price-tag block, in the example above, rather than product-card__price__current), which keeps every block's internal structure flat and independently reusable.The Specificity Payoff — Comparing BEM Against the "Obvious" Nested Approach
It is worth being explicit about exactly what BEM's flat, single-class approach buys you, because the alternative genuinely looks more natural to someone who has not been burned by it yet.
.product-card .title { font-size: 16px; }
.product-card .price { font-size: 14px; }
.product-card .price.discounted { color: #d32f2f; }
.product-card .button.disabled { background: var(--muted); }This looks reasonable in isolation, but it silently creates two compounding problems as the project grows. First, every rule's specificity is now higher than a single class (two classes: 0,0,2,0 for .price.discounted), which makes these rules progressively harder to override later without reaching for even more specific selectors or !important — exactly the specificity escalation problem covered in the Selectors Deep Dive module (Module 20). Second, and just as damaging: .title, .price, and .button are now generic enough that they are highly likely to collide with an unrelated component reusing the same short, common name, exactly like the .title collision shown in Part 01.
.product-card__title { font-size: 16px; }
.product-card__price { font-size: 14px; }
.product-card__price--discounted { color: #d32f2f; }
.product-card__button--disabled { background: var(--muted); }
/* Every selector is ONE class — specificity 0,0,1,0, flat and consistent
across the entire codebase. And "product-card__title" is specific
enough as a NAME that it will essentially never collide with an
unrelated component's class, without needing extra selector nesting
to disambiguate it. */This is the core insight BEM is built around: instead of using selector nesting (multiple combined classes, or descendant combinators) to make a rule specific enough to be safe, BEM makes the class name itself specific enough that a single, flat class selector is all you ever need. The disambiguation work moves from the selector's structure into the name's vocabulary.
The Two Habits That Undo Everything a Naming Convention Buys You
A consistent naming convention only pays off if it is paired with actual specificity discipline — otherwise the same old problems creep back in through a different door. Two habits do more damage to a large CSS codebase's maintainability than almost anything else: chaining selectors more specific than necessary, and reaching for !important to force a rule to win instead of addressing why it was losing in the first place.
/* Written because ".button" alone wasn't winning against some
other rule elsewhere in the stylesheet */
div.product-card .body .button.primary { background: blue; }
/* Six months later, someone needs to override THIS — and the only
way to beat 0,0,3,2 without !important is to write something
even MORE specific, and the arms race continues indefinitely */.button {
background: blue !important;
}
/* Now NOTHING can override this normally — not a more specific
selector, not a later rule, nothing except another !important
with source-order priority. Every future engineer who needs to
change this button's color in one specific context is forced
to either add their own !important (compounding the problem)
or resort to inline styles with an even higher priority. */Both of these are almost always symptomatic fixes for a root cause that a naming convention is specifically designed to prevent: a selector losing to something it should never have been competing against in the first place, because two unrelated rules ended up targeting overlapping, poorly-scoped class names. With BEM, the honest fix is very rarely "make this selector more specific" — it is almost always "give this specific case its own modifier class," which keeps specificity flat and the intent explicit in the name itself.
.product-card__button {
background: var(--accent);
}
.product-card__button--primary {
background: blue;
/* Same specificity as any other single class — no nesting,
no !important, and the name documents exactly what this
variant IS, rather than where it happens to sit in the DOM */
}!important is not banned outright in every real codebase — it occasionally has a legitimate, narrow use overriding a third-party library's inline styles you cannot otherwise touch. But inside your own project's own stylesheets, reaching for it is very often a sign that the actual problem — usually an overly generic class name, or a rule that was allowed to be more specific than it needed to be — was never actually fixed, just papered over.Splitting One Giant Stylesheet Into Files That Mirror the Naming Structure
A naming convention solves the "what do I call this class" problem; file organization solves the equally real "where do I even find this rule" problem. A single, several-thousand-line styles.css file is one of the most common sources of friction on a growing project — finding anything requires searching by class name and hoping you guessed the right term, and two engineers editing different components in the same giant file constantly produce merge conflicts even when their actual changes never overlap logically.
styles/
base/
_reset.css
_typography.css
_variables.css
components/
_product-card.css
_nav-menu.css
_button.css
_form-field.css
layout/
_header.css
_footer.css
_grid.css
pages/
_checkout.css
_product-listing.css
main.css /* imports everything else, in a deliberate order */@import "base/variables";
@import "base/reset";
@import "base/typography";
@import "layout/grid";
@import "layout/header";
@import "layout/footer";
@import "components/button";
@import "components/nav-menu";
@import "components/form-field";
@import "components/product-card";
@import "pages/product-listing";
@import "pages/checkout";The leading underscore on each partial filename (_product-card.css) is a convention borrowed directly from Sass — covered in full in the next module — signaling "this file is a partial, meant to be imported, not compiled or linked on its own." The organizing principle worth internalizing here: one file per BEM block. Because every rule for a given block already shares the same class-name prefix, the file boundary and the naming boundary reinforce each other — finding every rule that affects .product-card means opening exactly one file, every time, with no searching required.
A CSS Rewrite at a Chicago Media Company's Subscriber Portal
A Chicago-based media company's subscriber account portal had grown, over four years and a rotating cast of engineers, into an 11,000-line styles.css file with no organizing convention at all — plain, short class names like .title, .active, and .disabled reused across dozens of unrelated components, layered with 340 separate !important declarations accumulated one emergency fix at a time.
What the audit found
A new front-end lead is asked to fix a styling bug on the billing page: changing a button's color there was inexplicably also changing an unrelated button's color on the account settings page. Tracing it back, both buttons shared the plain class .btn-active, defined once with a specificity fight already baked in via nested selectors, and a well-meaning previous fix had added !important to force the billing page's version to win — silently making it impossible for the settings page to ever look different without its own, higher-priority override.
/* billing.css */
.panel .btn-active { background: green !important; }
/* settings.css, loaded on a different page but sharing the SAME
plain class name coincidentally */
.settings-panel .btn-active { background: blue; }
/* This rule can never win — the !important above outranks it
regardless of specificity or source order, even though the
two rules are meant to style two completely unrelated buttons */The fix
Rather than patching the immediate bug with yet another !important, the team adopted BEM going forward and began migrating components one at a time as they were touched for other reasons — a pragmatic, incremental approach rather than a risky big-bang rewrite of 11,000 lines at once.
/* components/_billing-panel.css */
.billing-panel__button--active { background: green; }
/* components/_settings-panel.css */
.settings-panel__button--active { background: blue; }
/* Zero specificity fight, zero !important needed — the two rules
were never actually competing in the first place, once each
button's class name unambiguously named which component owns it */Eighteen months into the incremental migration, the team had eliminated the majority of the !important declarations simply as a side effect of components no longer needing them once their class names stopped colliding, and new engineers consistently reported that finding and safely modifying a component's styles took a fraction of the time it used to. Nothing about this required exotic tooling — it was purely the naming discipline plus the file-per-block organization from Part 06, applied consistently over time.
Four Misconceptions About CSS Architecture
5 Interview Questions — With Complete Answers
Architecture Mistakes Engineers Make Constantly
.card__title__text {
/* an element nested inside an element — not valid BEM */
font-weight: 700;
}.card__title {
/* if "title" needs internal parts of its own, it usually
deserves to be promoted to its own block instead */
font-weight: 700;
}.nav-item.active {
/* a bare, generic modifier class with no block prefix at all —
".active" alone WILL eventually collide with an unrelated component */
color: blue;
}.nav-item--active {
/* the modifier is scoped to its block by name, not just by
accidentally being written near it in the same file */
color: blue;
}.header nav ul li a.current {
/* five levels deep, mixing tag selectors and one class —
high specificity, and fragile if the markup structure ever changes */
font-weight: 700;
}.main-nav__link--current {
/* one flat class, independent of markup structure entirely —
still works even if the underlying HTML tags change */
font-weight: 700;
}.button {
background: blue !important;
/* forced to win against SOMETHING — but now every future
override needs its own !important too */
}.button--primary {
background: blue;
/* give the specific case its own modifier instead of forcing
a generic class to behave differently via !important */
}Real Bugs This Topic Produces — And Exactly Why
🎯 Key Takeaways
- ✓BEM organizes class names around Block (a standalone component), Element (a part of a block, connected with __), and Modifier (a variant flag, connected with --).
- ✓BEM selectors stay flat — a single class, never a chain of nested descendant selectors — which keeps specificity uniform and low across the entire codebase.
- ✓The disambiguation work that nested selectors used to handle is moved into the class NAME itself, which is exactly what makes accidental collisions between unrelated components structurally unlikely.
- ✓Over-specific selectors and !important both treat symptoms, not causes — the honest fix for a losing rule is almost always giving the specific case its own properly-scoped modifier class.
- ✓!important makes its own declaration nearly impossible to override normally, which tends to force the next engineer who needs to change it into adding their own !important, compounding the problem over time.
- ✓Organize stylesheets as partials, ideally one file per BEM block, assembled through a single entry point in a deliberate general-to-specific import order — this reduces both search time and merge conflicts.
- ✓CSS specificity conflicts fail completely silently, with no console error — the browser DevTools computed/styles panel is the tool for seeing exactly which rule won and why.
- ✓Adopting a naming convention costs very little on a small project and compounds in value as that project grows — retrofitting it onto an already-large, unconventioned codebase is far more expensive, as shown in the Real World example.
What comes next
Module 36 covers Sass — the preprocessor that came before native CSS variables, with real $variables, nesting, mixins, and why many production codebases still reach for it today.
Module 36 → Intro to Sass — Variables, Nesting, MixinsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.