CSS Architecture Without a Framework: What Actually Scales
Custom properties for tokens, a handful of layout classes, and components that own their own styles. You can get a long way before you need Tailwind — if you are disciplined about three things.
Utility frameworks are fine tools, but they are not the only path to maintainable CSS. Some of the calmest stylesheets I maintain are plain CSS — organised around three disciplines. This post expands each one into the actual code: a token layer you can copy, the five layout primitives that cover most pages, and the naming habits that keep the cascade from feeling haunted.
1. Tokens first
Every colour, spacing unit, and font stack lives in a custom property. The payoff is not theoretical theming — it is that a rebrand, a dark mode, or a client's "can the orange be a bit deeper?" becomes a five-line change instead of a find-and-replace across forty files.
The token layer, in full
:root {
/* ---- Raw palette: the only place literal colours are allowed ---- */
--ink: oklch(0.24 0.012 60);
--paper: oklch(0.98 0.005 85);
--accent: oklch(0.55 0.16 35);
--muted: oklch(0.55 0.02 60);
--line: oklch(0.88 0.01 70);
/* ---- Type ---- */
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'Cascadia Code', monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: clamp(1.75rem, 4vw, 2.5rem); /* fluid headings, no media query */
/* ---- Space: one scale, used everywhere ---- */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
/* ---- Layout ---- */
--measure: 680px; /* readable prose width */
--container: 1120px;
--radius: 8px;
}
The rule that gives this teeth: component CSS may not contain a literal colour or pixel value. If a card needs a border, it uses var(--line); if it needs breathing room, it picks from the space scale. The first time you write #e8e2d9 inside a component file, the system has started leaking.
Semantic aliases: the layer that makes dark mode free
Raw palette tokens name what a colour is; a second layer names what it's for. Components only ever touch the second layer:
:root {
--bg: var(--paper);
--text: var(--ink);
--text-muted: var(--muted);
--border: var(--line);
--link: var(--accent);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.18 0.01 60);
--text: oklch(0.92 0.005 85);
--text-muted: oklch(0.65 0.015 60);
--border: oklch(0.32 0.01 60);
--link: oklch(0.7 0.14 35);
}
}
/* Every component was already written in semantic tokens,
so dark mode required editing exactly one block. */
.card {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
}
And the client's "deeper orange"? One line in the raw palette, and every button, link, and focus ring updates together:
--accent: oklch(0.48 0.17 35); /* the entire rebrand */
2. A small set of layout primitives
Layout classes are the ones you reuse constantly; give them names and never redefine centring again. Everything else — cards, buttons, forms — belongs to its component. Five primitives cover almost every page I ship:
/* Page width, centred, with gutters that breathe on mobile */
.container {
width: min(var(--container), 100% - 2 * var(--space-3));
margin-inline: auto;
}
/* Readable prose — line length is a design decision, make it once */
.measure {
max-width: var(--measure);
}
/* Vertical rhythm without margin whack-a-mole */
.stack > * + * {
margin-block-start: var(--space-3);
}
.stack-lg > * + * {
margin-block-start: var(--space-5);
}
/* A row of things that wraps gracefully: tags, buttons, nav items */
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
/* Responsive card grid with zero media queries */
.grid-auto {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: var(--space-4);
}
Two of these earn extra attention. The .stack owl selector (> * + *) puts space between siblings instead of on them, which ends the classic bug where the last card in a column drags a phantom margin below it. And .grid-auto is a whole responsive system in three lines — cards get at least 260px, fill the row, and wrap, with no breakpoints to maintain.
A page assembles from primitives; components slot in without knowing where they are:
<main class="container stack-lg">
<article class="measure stack">
<h1>Post title</h1>
<p>…</p>
</article>
<section class="grid-auto">
<div class="card">…</div>
<div class="card">…</div>
<div class="card">…</div>
</section>
</main>
3. Selectors stay shallow
One class, occasionally two. The moment you write .sidebar ul li a span, you have created a rule that can only be safely edited by someone holding the entire DOM in their head. Flat selectors keep specificity boring, and boring specificity is why the cascade stops feeling haunted.
Name the part, don't describe the path
/* ❌ A rule welded to today's markup. Move the span, lose the style. */
.sidebar ul li a span {
font-size: var(--text-sm);
color: var(--text-muted);
}
/* ✅ A name. The markup can change shape; the style follows the class. */
.nav-label {
font-size: var(--text-sm);
color: var(--text-muted);
}
The convention I use is BEM without the ceremony — a component block, dash-separated parts, and a single modifier pattern:
.card { … }
.card-title { … }
.card-meta { … }
/* Modifiers: one extra class, co-located with the block */
.card.is-featured {
border-color: var(--link);
}
.button.is-loading {
opacity: 0.6;
pointer-events: none;
}
One file per component, tokens only
The folder structure mirrors the discipline — tokens and primitives are global, everything else is owned:
styles/
tokens.css /* custom properties, both layers */
base.css /* resets, body, headings, links */
layout.css /* the five primitives */
components/
card.css
button.css
form.css
/* components/button.css — no literals, no descendants, no surprises */
.button {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
background: var(--link);
color: var(--paper);
font-size: var(--text-base);
border: none;
cursor: pointer;
}
.button:hover { filter: brightness(1.08); }
.button:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
.button.is-ghost {
background: transparent;
color: var(--link);
border: 1px solid currentColor;
}
When specificity fights anyway: layers
Third-party CSS — a date picker, an embed — arrives with selectors you don't control. Rather than escalating with !important, declare the order of authority once with cascade layers:
@layer reset, vendor, base, layout, components;
@import url('vendor/datepicker.css') layer(vendor);
@layer components {
/* Everything here beats the vendor layer regardless of
selector specificity — a .card outranks their #widget-container div */
.card { … }
}
Layer order wins over specificity, so your one-class selectors stay one class even when overriding someone else's ID-riddled stylesheet.
A stylesheet scales exactly as well as its naming discipline — no better, no worse.
The whole system on one page
- Tokens: raw palette → semantic aliases → components. Literal values are allowed in exactly one file.
- Primitives:
.container,.measure,.stack,.cluster,.grid-auto. Pages are assembled; components don't do layout. - Selectors: one class, named parts,
.is-*modifiers, cascade layers for the third-party fights.
Frameworks encode these disciplines so you don't have to. But they are disciplines, not products; you can adopt them in vanilla CSS on the next project you start.