RizTech Academy logo
RizTech Academy
Best PracticesLesson 2 of 530 min

Organising CSS so it stays maintainable

A stylesheet becomes unmaintainable in a specific way: you add a rule, it does not apply, so you make the selector more specific. That works, so the next person does the same, and within a year every rule is three classes deep and the only way to change anything is !important.

This lesson is the structure that prevents it.

The order that matters

CSS is read top to bottom and later wins on a tie, so file order is a design decision. The conventional order, from most general to most specific:

1. reset / normalize        the browser's defaults, flattened
2. tokens                   custom properties
3. base                     bare element styles: body, h1, a, p
4. layout                   the page skeleton, containers, grids
5. components               cards, buttons, fields, nav
6. utilities                single-purpose overrides

Each layer is allowed to override the one above it, and the order means it can do so without extra specificity. A utility at the bottom beats a component by source order alone, which is exactly what a utility needs to do.

Get this order wrong — utilities before components — and .p-0 cannot beat .card, so somebody writes .p-0 { padding: 0 !important } and the rot starts.

Cascade layers make the order explicit

From module 2, now with the reason. @layer moves the ordering out of file order and into a declaration:

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

One line at the top, and after that the order of your files no longer matters. A later layer beats an earlier one regardless of specificity:

@layer components {
  .card .card__title.is-large { font-size: 2rem; }    /* 0,3,0 */
}

@layer utilities {
  .text-sm { font-size: 0.875rem; }                   /* 0,1,0 — and it wins */
}

That is the thing !important was being misused for, done properly. A one-class utility beating a three-class component is now the design, not a fight.

Three things to know:

Anything outside a layer beats everything inside one. This is the opposite of what most people guess, and it is deliberate — it means you can drop an unlayered override in and have it win. It also means a third-party stylesheet you cannot edit will beat your layers unless you import it into one:

@import url("some-framework.css") layer(vendor);

That single line is the best reason to use layers at all: it puts a framework's CSS underneath yours permanently, so you never fight its specificity again.

Within a layer, normal rules apply — specificity, then source order.

!important reverses layer order. An !important in an earlier layer beats an !important in a later one. It is a strange rule with a sensible reason (the reset should be able to insist on something), and it is another reason not to use !important.

Layers are supported in every current browser.

Keep specificity flat

The aim: most of your rules are one class, 0,1,0. Then anything can override anything by being later or in a later layer, and you never need to escalate.

/* 0,3,2 — and it breaks when somebody adds a wrapper */
.page .sidebar ul li a { color: var(--colour-accent); }

/* 0,1,0 */
.sidebar-link { color: var(--colour-accent); }

Practical rules:

One class per selector, wherever you can. A second class is for a state or a modifier — .card.is-selected — not for reaching into a structure.

Never style by id. #main p is 1,0,1 and only another id or !important can beat it. Ids are for fragment links and for/aria-labelledby, not for styling.

Avoid long descendant chains. Two levels is plenty. If you need four, the thing you are reaching for wants a class.

Use :where() for defaults, from module 6, because it is always zero specificity:

:where(ul, ol) { margin: 0; padding: 0; list-style: none; }
.bulleted { list-style: disc; padding-inline-start: 1.5rem; }   /* wins trivially */

Remember nesting does not help. .card { & .title { } } is 0,2,0, exactly as .card .title is — and nesting makes a deep chain easier to write by accident. One or two levels.

State and variants

Two conventions, and mixing them is the problem.

.card--featured { }        /* a variant: a permanently different kind of card */
.card.is-open { }          /* a state: temporary, usually toggled */
.card[aria-expanded="true"] { }   /* better: style the attribute that already exists */

Prefer styling the attribute over a parallel class. If aria-expanded is already there for accessibility — and module 5 says it must be — then [aria-expanded="true"] cannot drift out of sync with it. A separate .is-open class can, and when it does the visual state and the announced state disagree, which is worse than either being wrong alone.

The same applies to :disabled over .is-disabled, :checked over .is-checked, and :user-invalid over .has-error.

Where to put a media query

From module 4, as a rule: next to the rule it modifies, not grouped at the bottom.

.cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1rem;
}

@media (width >= 40rem) {
  .cards { grid-template-columns: repeat(2, 1fr); gap: 1.5rem; }
}

Or, with nesting from module 6, inside the block:

.cards {
  display: grid;
  grid-template-columns: 1fr;

  @media (width >= 40rem) {
    grid-template-columns: repeat(2, 1fr);
  }
}

Everything about .cards in one place. The extra @media blocks cost nothing — they compress away, and the parse cost is negligible against the cost of hunting through four locations to change one component.

Comments worth writing

The same rule as every other language: code says what, comments say why.

/* ============================================
   Cards
   ============================================ */

.card {
  /* 26rem, not 24: below this the two-column variant's image
     becomes too small to read the product label. */
  container-type: inline-size;
}

@container (width >= 26rem) { … }

/* Safari drops list semantics when list-style is none, so the
   role is restored in the HTML. See module 6.
   https://bugs.webkit.org/show_bug.cgi?id=170179 */
.nav-list { list-style: none; }

/* z-index scale is in tokens.css. Do not use a bare number. */
.dropdown { z-index: var(--z-dropdown); }

Section banners are genuinely useful in CSS in a way they are not in other languages, because a stylesheet is one long document you scroll. Use them.

And the comments to delete: anything restating the declaration (/* set the colour to green */), anything about change history, and commented-out declarations, which sit there for two years because nobody knows if they matter.

Dead CSS

CSS accumulates dead rules faster than any other kind of code, because deleting a rule feels risky — you cannot see everything it might affect.

Two ways to find it:

The Coverage panel. In devtools, open the command menu, run "Show Coverage", then reload and click through the site. It reports the percentage of each stylesheet that was never used. It is not proof — a rule for a state you did not visit shows as unused — but a file at 15% coverage is worth looking at.

Search before you assume. grep -r "card__badge" . across your HTML tells you whether a class is referenced anywhere. If it is not, it is dead.

Delete it. Git remembers, and a stylesheet you are afraid of is worse than one that is slightly too small.

A structure worth starting from

/* main.css */
@layer reset, tokens, base, layout, components, utilities;

@import url("base/reset.css")       layer(reset);
@import url("base/tokens.css")      layer(tokens);
@import url("base/typography.css")  layer(base);
@import url("layout/page.css")      layer(layout);
@import url("components/button.css") layer(components);
@import url("components/card.css")   layer(components);
@import url("utilities/hidden.css")  layer(utilities);

In development this is readable and each file is small. For production, concatenate — the serial round trips from the previous lesson are real, and one file is one request.

For a site the size of the capstone, a single well-sectioned styles.css with layer blocks is entirely reasonable and avoids the build step:

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

@layer reset { /* … */ }
@layer tokens { /* … */ }
/* … */

Check your work

How a stylesheet rots. A rule does not apply, so the selector gets more specific, and that becomes the habit.

The layer order. Reset, tokens, base, layout, components, utilities.

Why the order matters without layers. A utility must be able to win on source order alone.

What @layer changes. A later layer beats an earlier one regardless of specificity.

Where unlayered CSS sits. Above everything layered — which is the opposite of the guess.

The best single reason to use layers. @import … layer(vendor) puts a framework permanently underneath your CSS.

What !important does to layers. Reverses their order.

The specificity aim. Most rules at 0,1,0.

Why never style by id. 1,0,1 can only be beaten by another id or !important.

What :where() is for here. Zero-specificity defaults anybody can override.

Whether nesting helps specificity. No — it is identical to the descendant selector.

Why style the attribute rather than a parallel class. [aria-expanded="true"] cannot drift out of sync with the accessibility state; .is-open can.

Where media queries go. Next to the rule they modify, or nested inside it.

Why section banners are useful in CSS specifically. A stylesheet is one long scrolling document.

Two ways to find dead CSS. The Coverage panel, and grepping for the class name.

Practice

  1. Put your utilities above your components and try to override a card's padding with a one-class utility. Then swap the order.
  2. Declare @layer reset, base, components, utilities; and move your CSS into layers.
  3. Make a one-class utility in a later layer beat a three-class component rule. Check the specificity of both in devtools.
  4. Put a declaration outside all layers and confirm it beats everything inside them.
  5. Import a CSS framework into layer(vendor) and confirm your own single-class rules now beat its rules.
  6. Put !important in an earlier layer and another in a later one. Work out which wins.
  7. Find your highest-specificity selector. Flatten it to one class, then add a wrapper div and confirm it still works.
  8. Find an id used for styling and replace it with a class.
  9. Write a default with :where() and override it with a single class.
  10. Nest four levels and read the resulting specificity.
  11. Replace an .is-open class with [aria-expanded="true"], then toggle only the attribute and confirm the styling follows.
  12. Move all your media queries next to their components.
  13. Write one comment explaining a magic number in your CSS, and delete three comments that restate their declaration.
  14. Run the Coverage panel on your site, click through every page and state, and note the unused percentage.
  15. Pick the most suspicious unused class, grep for it in your HTML, and delete it if it is genuinely dead.

Official documentation

Next: markup that survives real content.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship