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.
@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.
@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.
.titlecould be anything;.card__titlecould not. - Keep selectors flat.
.card__titleis one class;.page .content .card h3is a rule that breaks when the markup moves. - Prefix state classes so they read as state:
is-open,has-error,is-loading.
.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.
@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.
@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
.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);
}
}
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.
@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);