Skip to content

Organising and scaling CSS

docs.scrimba.com

The first stylesheet on a new project is a pleasure to write. A few hundred lines in, something shifts: you change the colour of one button and three other buttons change too, a heading refuses to move until you paste !important next to it, and every new rule feels like it might break an old one. The CSS itself is still correct. What has run out is the structure holding it together, and that structure, not the syntax, is what decides whether a stylesheet stays workable at a thousand lines or a hundred thousand.

Why CSS gets hard to scale

CSS is global by default. Every rule you write can reach any matching element anywhere on the page. Write p { color: navy; } and every paragraph in the whole project turns navy, whether you meant them all or not. On a small page that is convenient. As the project grows, it is the root of most of the trouble.

The problem shows up as rules colliding. Two stylesheets both target p, or a general rule and a specific one both apply to the same element, and now you have to work out which one wins. You saw the machinery that decides this in how CSS works: the browser resolves conflicts by specificity and order. Scaling CSS well is mostly about not creating those conflicts in the first place.

CSS has no built-in scoping. A rule is global: it applies to every element that matches its selector, across every file the page loads. Nothing stops one component's styles from reaching into another's, because the language has no notion of "this rule belongs to that component". That freedom is what makes CSS quick to start with and awkward to grow.

As a codebase grows, two forces push against you. Rules collide, because more selectors mean more overlap, and the browser settles each clash with the rules from how CSS works: specificity first, then source order. And specificity tends to creep upward, because the quickest way to win a clash today is to write a slightly more specific selector, which raises the bar for whatever has to override it tomorrow. Left unchecked you end up with selectors nobody can beat without !important, which is the point where a stylesheet stops feeling maintainable.

CSS ships one flat, global namespace. Every selector competes in the same space, and the cascade, the algorithm that picks a winning declaration when several apply to one element, resolves each conflict by origin, then specificity, then source order. There is no module boundary in the language itself, so a selector written for one component is free to match elements in any other. Every scaling technique in this chapter exists to impose a boundary the language does not give you.

The failure mode is worth naming precisely, because the habits below are all defences against it. Under pressure, the fastest fix for a rule that will not apply is to make its selector more specific: add a parent, chain another class, fall back to an id. Each of those wins the immediate clash and raises the specificity floor for everything downstream, so the next override has to be more specific still. This is a specificity war: a one-way ratchet where selectors only ever climb, ending in !important because nothing else is left to beat them. The escape is not to win these fights more cleverly but to keep specificity low and flat enough that they rarely start, and, where the language now allows it, to move ordering out of specificity entirely with cascade layers, covered at the end of this chapter.

css
/* One global rule reaching every paragraph on the page */
p {
  color: navy;
}

/* A second rule, elsewhere, competing for the same elements */
.notice p {
  color: crimson;   /* wins inside .notice: more specific */
}
JunoWhy CSS gets hard to scale CSS rules are global: one rule can style elements all over the page, which is handy at first and messy later. As a project grows, rules start colliding, and the browser has to pick a winner using specificity and order. Most of organising CSS is about writing rules that do not fight each other in the first place.
JunoWhy CSS gets hard to scale There is no scoping in CSS, so every rule is global and free to reach any matching element. As the codebase grows two things bite: rules collide, and specificity creeps up because the quick fix for a clash is always a more specific selector. Keep that creep in check and the rest of scaling gets a lot easier.
JunoWhy CSS gets hard to scale CSS is one flat global namespace with no module boundary, so the cascade settles every clash by origin, then specificity, then order. The trap is the specificity ratchet: each quick override raises the floor, so the next one has to climb higher, and you bottom out at !important. Every technique in this chapter is a way to keep specificity low enough that the ratchet never starts turning.

Keep specificity low and flat

The single most useful habit is to style with classes, and mostly one class at a time. A class like .card is quick to apply, reusable, and painless to override later if you need to, because a single class is a low, gentle level of specificity.

The trouble comes from two things: ids and long chains of selectors. An id like #header is much harder to override than a class, and a chain like .sidebar ul li a is tied to one exact structure. Prefer a plain class you can put wherever you want:

The core habit for keeping CSS maintainable is to keep specificity low and flat: style with single classes, and avoid the two things that spike specificity, ids and deep descendant chains. A single class is the sweet spot. It is specific enough to target what you mean and weak enough that another single class can override it later without a fight.

Ids score far higher than classes, so an #id rule is painful to override and forces you upward. Long descendant chains cause a different problem: .sidebar nav ul li a both raises specificity and welds the rule to one exact HTML structure, so it breaks the moment the markup changes. Give the element its own class and target that directly.

Keeping specificity low and flat is the habit that prevents specificity wars rather than winning them. Low means each rule scores as little as it can while still selecting correctly; flat means rules cluster around the same low weight, so any of them can override any other by source order alone. A stylesheet where almost everything is a single-class selector has this property: nothing is hard to beat, because nothing is scored above its neighbours.

Two constructs break flatness and both are worth avoiding by default. Ids contribute an order of magnitude more specificity than a class, so a single #id rule sits above any stack of class rules and can only be beaten by another id or by !important; style with classes and reserve ids for fragment links and JavaScript hooks. Deep descendant chains raise specificity per selector and couple the rule to a fixed ancestry, so .sidebar nav ul li a is both hard to override and brittle: change the markup and it silently stops matching. The methodology in the next section exists largely to let you name an element directly, so you never reach for a chain to find it.

css
/* Prefer: a single class, low and flat */
.nav-link {
  color: navy;
}

/* Avoid: an id, hard to override later */
#nav-link {
  color: navy;
}

/* Avoid: a deep chain, brittle and higher specificity */
.sidebar nav ul li a {
  color: navy;
}
JunoKeep specificity low and flat Style with classes, and mostly one class at a time, like .card or .nav-link. A single class is quick to reuse and painless to override later, which is exactly what you want. Steer clear of ids like #header and long chains like .sidebar ul li a, because both are much harder to change.
JunoKeep specificity low and flat Single classes are the sweet spot: specific enough to hit what you mean, weak enough that another class can override them without a fight. Ids spike specificity and drag you upward, and deep chains like .sidebar nav ul li a break the moment the markup shifts. When a rule is hard to place, give the element its own class and target that.
JunoKeep specificity low and flat Low and flat means every rule scores near the same small weight, so source order alone can settle any clash. Ids sit an order of magnitude above classes and only !important or another id beats them, so keep them for fragment links and JS hooks. Deep chains both raise specificity and weld a rule to one HTML shape, which is why naming the element directly beats hunting for it through its ancestors.

A naming convention

Once you are styling with classes, the next question is what to call them. Names like .blue or .thing2 fall apart fast, because they say nothing about what the class is for. A naming convention is an agreed way to name classes so that the name tells you what a class does and where it belongs.

The widely used one is called BEM, which stands for Block, Element, Modifier. A block is a component like a card. An element is a part inside it, written with two underscores. A modifier is a variation, written with two dashes:

With classes as the styling unit, naming becomes the thing that keeps them organised. A naming convention is a shared pattern for class names, and its job is to make names predictable and collision-free: you can tell from a class name what component it belongs to and what part it styles, and two components never accidentally reuse the same name.

The most widely adopted convention is BEM (Block, Element, Modifier). The block is the component (.card), an element is a part of it joined with two underscores (.card__title), and a modifier is a variant joined with two dashes (.card--featured). The payoff is flat specificity: because every part gets its own single class, you never need a descendant chain to reach .card__title, so BEM and the low-and-flat habit reinforce each other.

A naming convention substitutes for the scoping the language lacks. By encoding a component boundary into the class name itself, it gives you collision-free, self-documenting names without any language feature: read a class and you know its component, its part, and its variant. Any consistent convention delivers this; the value is in the consistency, not the exact punctuation.

BEM (Block, Element, Modifier) is the most widely used. The block names the component (.card), an element names a part with a double underscore (.card__title), and a modifier names a variant with a double dash (.card--featured). What makes BEM pull its weight beyond naming is that it keeps specificity flat by construction: every element gets its own single-class selector, so you address .card__title directly instead of writing .card .title, and every rule in the component sits at the same weight. That is the same low-and-flat property from the previous section, now falling out of the naming scheme for free. The cost is verbose class lists in the HTML, which is a trade most teams accept for the predictability it buys.

css
/* Block: the component itself */
.card { }

/* Element: a part of the block, two underscores */
.card__title { }
.card__body { }

/* Modifier: a variant of the block, two dashes */
.card--featured { }
html
<article class="card card--featured">
  <h2 class="card__title">Weekend workshop</h2>
  <p class="card__body">A short intro to layout.</p>
</article>
JunoA naming convention A naming convention is an agreed way to name classes so the name tells you what it is for. BEM is the common one: a block like .card, an element inside it like .card__title with two underscores, and a variant like .card--featured with two dashes. You do not have to use BEM, but pick some consistent scheme and stick to it.
JunoA naming convention A convention makes class names predictable and collision-free, so a name tells you its component and its part. BEM is the widely used one: block .card, element .card__title, modifier .card--featured. It also keeps specificity flat, because every part gets its own single class instead of a descendant chain.
JunoA naming convention A convention stands in for the scoping CSS never gave you: the component boundary lives in the class name, so names stay collision-free and self-documenting. BEM encodes block, element, and modifier as .card, .card__title, .card--featured, and its real payoff is flat specificity by construction, since every part is a single class. The price is wordy class lists in the markup, which is usually worth the predictability.

Structuring the files

As a stylesheet grows, one long file becomes hard to move around in. The common fix is to split CSS into a few folders by what the rules do: base styles (the defaults for plain elements like body and headings), components (the cards, buttons, and other pieces), and utilities (tiny single-purpose helpers like a spacing or text-alignment class).

One detail matters when you split files: the order you load them in. When two rules have the same specificity, the one that comes later wins, so a stylesheet loaded later can override an earlier one. Load your files from most general to most specific:

Splitting CSS into folders by role keeps a growing codebase navigable. A common structure is three groups: base (resets and element defaults such as body, headings, and links), components (self-contained pieces like .card and .btn), and utilities (single-purpose helpers like .text-center or .mt-4).

The order you concatenate or import these files in is not cosmetic, because source order is a cascade tiebreaker: when two rules have equal specificity, the later one wins. So you load from least to most specific, base first, then components, then utilities last, so that a utility can override a component and a component can override a base default without anyone raising specificity to force it. Getting the order right is what lets you keep everything at single-class specificity and still have overrides land where you expect.

Structuring files by role is how you keep low-and-flat CSS navigable at scale, and the load order is load-bearing. A conventional split is base (resets and bare-element defaults), components (encapsulated pieces, one per file), and utilities (atomic single-property helpers). The ordering rule follows directly from the cascade: with specificity held flat on purpose, source order becomes the primary tiebreaker, so the sequence in which files load decides which equal-weight rule wins.

That is why the order runs least to most specific in intent, base, then components, then utilities, so that later groups can override earlier ones without any specificity bump. A .text-center utility must beat a component's own text alignment, and it can, purely because it loads last at the same weight. This works, but it is a convention enforced by discipline: nothing in the language stops someone importing utilities before components and quietly inverting the whole scheme. Relying on source order across a large team is fragile for exactly that reason, which is what motivates making the ordering explicit rather than positional, the subject of the last section.

css
/* main.css: order runs least to most specific */
@import "base/reset.css";        /* element defaults */
@import "base/typography.css";

@import "components/card.css";    /* self-contained pieces */
@import "components/button.css";

@import "utilities/spacing.css";  /* loaded last so they can override */
@import "utilities/text.css";
JunoStructuring the files Split CSS into folders by what the rules do: base for element defaults, components for the pieces like cards and buttons, and utilities for tiny helpers. Then watch the order you load them in, because when specificity ties, the later rule wins. Load general first and specific last, so the helpers can override the pieces.
JunoStructuring the files Group files by role: base, components, then utilities. Load them least to most specific, because source order is the tiebreaker when specificity is equal, so a utility loaded last can override a component without any specificity bump. Getting the order right is what lets you keep everything at single-class weight and still have overrides land.
JunoStructuring the files Once specificity is flat on purpose, source order becomes the primary tiebreaker, so file load order decides which equal-weight rule wins. Base, then components, then utilities means later groups override earlier ones for free, no specificity bump needed. The catch is that it is convention held by discipline: import utilities too early and you invert the whole scheme, which is exactly why the next step makes the ordering explicit.

Making the cascade explicit

Everything so far keeps the cascade manageable by convention: low specificity, good names, careful file order. There is more here as you go deeper, and it is worth knowing the direction of travel. Modern CSS lets you take control of ordering directly instead of relying on where a file happens to sit, and it gives you ways to keep the cascade predictable as more people work on the same stylesheet. You will meet these tools as your projects grow, and the habits above are what prepare you for them.

The techniques so far manage the cascade indirectly, through specificity and source order. Newer CSS lets you manage it directly. A cascade layer, written with the @layer rule, is a named ordering bucket: you declare the layers up front in the order you want them to win, and every rule inside a layer beats every rule in an earlier layer, regardless of specificity.

css
/* Declare the winning order once, most-losing to most-winning */
@layer base, components, utilities;

@layer components {
  .card { padding: 16px; }
}

@layer utilities {
  .p-0 { padding: 0; }   /* wins over .card despite equal specificity */
}

Because a later layer always wins, you no longer need file order to be perfect, and you rarely need !important to force an override. That is the practical benefit: layers move ordering out of the fragile territory of "which file loaded first" and into an explicit declaration everyone can read.

Source order is fragile because it is positional: it works until someone reorders an import. Cascade layers, the @layer rule, replace that with explicit ordering that sits above specificity in the cascade. You declare the layer order once, and the cascade consults layer order before it ever looks at specificity, so a rule in a later layer beats a rule in an earlier one even when the earlier rule is more specific. Ordering becomes a named, readable decision instead of a side effect of file position.

css
/* One line fixes the winning order for the whole codebase */
@layer reset, base, components, utilities;

@layer components {
  .card__title { font-size: 1.25rem; }
}

@layer utilities {
  .text-lg { font-size: 1.5rem; }   /* wins: utilities is the later layer */
}

This reshapes two habits. First, it defuses the specificity war: because layer order outranks specificity, you stop reaching for more specific selectors to win a clash, and you almost never need !important, whose entire job was to escape a specificity you could not otherwise beat. (Layers even tame !important itself, reversing its precedence so an important declaration in an earlier layer wins, but the goal is to need it so rarely that this stays trivia.) Second, it clarifies the long-running choice between two organising styles: a utility-first approach, composing pages from many atomic single-property classes, and a component approach, bundling styles behind one semantic class per piece. Most production codebases run both, and layers let them coexist by design: put components in a components layer and utilities in a later utilities layer, and a utility reliably overrides a component without either side escalating specificity. The decision stops being "which one wins the cascade fight" and becomes "which reads better for this piece of UI", because the cascade is settled by the layer order, not by who wrote the pushier selector. That predictability is the real prize as a team grows: the winning order is one declaration everyone can read, not tribal knowledge about import sequence. When you need to share values like colours and spacing across these layers, custom properties let you define them once instead of duplicating them.

JunoMaking the cascade explicit Everything so far tames the cascade with habits: low specificity, clear names, careful load order. As you go further, CSS gives you tools to set the winning order directly instead of leaning on which file loaded first. You do not need them yet, and the habits in this chapter are exactly what get you ready for them.
JunoMaking the cascade explicit A cascade layer with @layer is a named ordering bucket: declare the layer order up front and a later layer beats an earlier one no matter the specificity. That means file order stops having to be perfect and you rarely need !important to force a win. It moves ordering out of fragile file position and into one line everyone can read.
JunoMaking the cascade explicit Cascade layers sit above specificity, so a later layer wins even against a more specific rule, which releases the specificity ratchet and retires most of your !important uses. They also let utility-first and component styles coexist: components in one layer, utilities in a later one, and a utility overrides a component without either escalating. The win as a team grows is that the ordering is one readable declaration, not tribal knowledge about import sequence.