Organising CSS

Organise a stylesheet so that changing it in a year is not frightening.

24of 26
≈ 11Min read
9Parts

The real problem

CSS does not get hard because of the properties. It gets hard because everything is global: any rule can affect any element, nothing tells you what a rule was for, and deleting something is risky because you cannot prove nothing else needed it.

Every convention below exists to answer one question: can I change this safely?

A layer order, declared once

Layers remove specificity from the equation between groups. A component rule can never be beaten by a base rule, however many ids the base rule has, and a utility always wins over a component. That single line prevents most of the fights that produce !important.

  • reset — normalise the browser defaults.
  • tokens — the custom properties. Colour, space, type, radius, easing.
  • base — bare element styles: body, h1, a, table.
  • layout — wrappers, grids, page shells.
  • components — cards, buttons, fields. The bulk of it.
  • utilities — single-purpose classes that must win.
the first line of the stylesheet
@layer reset, tokens, base, layout, components, utilities;

Tokens

A token named --grey-12 has to be renamed when the theme changes. A token named --line does not. Roles survive redesigns; values do not.

Keep the set small. Twenty to forty tokens is a design system; two hundred is a second language nobody will learn.

name them by role, not by value
@layer tokens {
  :root {
    /* wrong: --grey-12, --orange-500, --size-14 */
    --bg: #ffffff;
    --ink: #15161a;
    --muted: #6b7078;
    --line: rgb(0 0 0 / .12);
    --accent: #f1521f;

    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 1rem;
    --space-4: 1.5rem;
    --space-5: 2.5rem;

    --radius: 12px;
    --ease: cubic-bezier(.2, .7, .1, 1);
  }
}

Naming components

  • Whatever convention you pick, the point is that reading a class name tells you where it lives. .title could be anything; .card__title could not.
  • Keep selectors flat. .card__title is one class; .page .content .card h3 is a rule that breaks when the markup moves.
  • Prefix state classes so they read as state: is-open, has-error, is-loading.
BEM, or something like it
.card { }                /* block */
.card__title { }         /* element inside it */
.card--featured { }      /* modifier */

/* the same thing with custom properties, which is usually cleaner */
.card { --card-bg: var(--bg); background: var(--card-bg); }
.card.is-featured { --card-bg: var(--accent-soft); }

One component, one file

A consistent order inside a rule — layout, box, type, state — means you can find a property without reading the whole block.

the shape of a component file
@layer components {
  .card {
    /* 1. the tokens this component exposes */
    --card-bg: var(--bg);
    --card-pad: var(--space-3);

    /* 2. layout */
    display: flex;
    flex-direction: column;
    gap: var(--space-2);

    /* 3. box */
    padding: var(--card-pad);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--card-bg);

    /* 4. type */
    font-size: 0.95rem;
  }

  .card__title { font-size: 1.15rem; font-weight: 600; }

  .card:hover { border-color: var(--line-2); }

  .card.is-featured { --card-bg: var(--accent-soft); }
}

Utilities

A small set of utilities for things that genuinely recur is useful. A complete utility vocabulary is a different methodology — that is what Tailwind is — and mixing half of one with half of the other gives you the drawbacks of both.

a handful, in the top layer
@layer utilities {
  .flow > * + * { margin-block-start: var(--flow, var(--space-3)); }
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .text-muted { color: var(--muted); }
  .wrap { width: min(70rem, 100% - 3rem); margin-inline: auto; }
}

Nesting

native nesting, used with restraint
.card {
  padding: var(--space-3);

  & .card__title { font-size: 1.15rem; }

  &:hover { border-color: var(--line-2); }

  &.is-featured { --card-bg: var(--accent-soft); }

  @media (min-width: 48rem) {
    padding: var(--space-4);
  }
}
Nesting is a specificity machine

Three levels of nesting produce a three-class selector you then have to beat. Nest for state and media queries; do not nest to mirror the shape of the HTML.

Deleting CSS

  • Write a comment saying what a non-obvious rule is for, not what it does. The properties already say what it does.
  • Keep a component's CSS next to its markup — same folder, same name — so deleting the component deletes its styles.
  • Search for the class before deleting a rule. If nothing uses it, delete it; unused CSS is read by everyone who comes after you.
  • Coverage in devtools shows which rules the current page never used. It is a starting point, not proof: another page may need them.

A file order that works

@import costs a round trip per file, so bundle for production. In development this structure is worth the cost: it is the file list that tells a newcomer how the site is organised.

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

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

Practice