No one starts out to make their CSS unmaintainable. It just happens, one reasonable-looking rule at a time. Someone adds a global style. Someone else overrides it. A third person reaches for !important to win a fight with the cascade. Every new rule becomes a contest of personal preferences settled by whoever wins the argument, not by true alignment.
The problem typically arises from a lack of written agreement on the pivotal decisions and why they matter. That gap hits two audiences differently: engineers need to know why a default exists, not just what it is, while agents generating CSS need a precise default to apply.
This guide gives both what they need: twelve decisions, each with a default, a rationale, and a rule of thumb, covering tooling, file structure, tokens, color, type, layout, naming, the cascade, generated markup, icons, and modern CSS.

Decision 1: Plain CSS, or a preprocessor/framework?
Use standards-based CSS with native custom properties (CSS variables). Do not reach for Tailwind, Sass/SCSS, Less, CSS Modules, or a CSS-in-JS runtime.
Why it matters
This is the decision most teams get wrong, so it's worth slowing down.
Most CSS pain does not come from CSS being weak or too complex. It comes from tools that were invented to patch weaknesses and complexities that the platform has since fixed.
The case for using a Sass/Less compiler for readability was right for its time; teams needed variables, nesting, and color math. But today's browsers have closed the gap. They ship natively with var(), CSS nesting, and color-mix() / oklch(), so the readability argument no longer requires a compiler. Adopting a preprocessor today, therefore, means taking on an extra build step, dependency, and dialect.
The frameworks fail in a different, more subtle way. A large chunk of "our CSS is a mess" stories trace back to two habits working together:
- a global stylesheet that everything dumps into, and
- a heavy reliance on per-component styles that each try to correct the global one.
This is the classic React/Angular apps problem. Global styles grow without an owner. Component styles fight for ever-higher specificity, and utility frameworks like Tailwind move styles into markup as long strings of classes. As a result, the "why" of a rule disappears, and cross-cutting changes require editing every element. Engineers may think CSS-in-JS communicates savviness, but it adds a runtime cost and couples styling to a component framework that may not keep.
The goal is to get back to the basics. Plain CSS keeps the migration surface small and keeps CMS/third-party markup stylable. It means the next engineer (or agent) can read the raw stylesheet and understand it with zero toolchain knowledge.
Revisit only if: you need independently versioned, reusable UI packages with true style isolation that documented global layers and naming genuinely can't provide.
Rule of thumb
If you're about to add a tool to write CSS, first ask "what native feature am I avoiding, and why?" Usually the honest answer is "none."
📚 MDN: Using CSS custom properties
📚 MDN: Using CSS nesting
📚 MDN: Color mixer
Decision 2: One giant stylesheet, or one file per component?
Both extremes are traps.
One giant global.css has no ownership. Thousands of lines, hundreds of selectors. Nobody can tell where a rule should go, so it gets added to the bottom. Source order slowly becomes load-bearing in ways no one documented.
One file per component sounds tidy but couples your styles to your component tree. The moment a CMS emits markup that spans components, or two components share a card style, you're duplicating and fighting yourself again.
Split styles by responsibility instead, and keep one intentional global entry point that imports them in a documented order. A structure like this generalizes well:
- global.css — ordered imports ONLY; this is the one file you read to understand the cascade
- tokens.css — design vocabulary with primitive + semantic custom properties; the single file you edit to construct or swap a theme (light, dark, or anything else)
- base.css — reset,
@font-face,:root/body, element defaults; this is where you stop re-declaring what the browser already gives you - layout.css — header, footer, container, the responsive shell; this is where you reach when the page frame needs to change
- components.css — navigation, search, cards, tags, controls; this is where your app-owned UI lives, one obvious home per component
- content.css — CMS/prose body, syntax highlighting; this is where you style markup you don't author, so it never leaks into your components
- integrations.css — third-party generated markup (diagrams, embeds); this is where you contain other people's code so it can't contaminate yours
- utilities.css — documented, single-purpose helpers (
.hide,.center); this is where you reach when a rule isn't a component, just a one-line tool
Why it matters
The next time someone asks "where do I add this rule?" you can answer in one word: components.css, tokens.css, layout.css, etc. That clarity of ownership is the single biggest predictor of whether CSS stays healthy. The one global entry point means production still ships a single, cache-friendly stylesheet, and the import order is written down in one place so the cascade is intentional rather than accidental.
Note the deliberate seam between components.css (markup you write) and content.css / integrations.css (markup a CMS or library emits). That boundary matters enough to get its own decision below.
Rule of thumb
Every new rule should have one obvious file. If you can't name the file without hesitating, you haven't decided what the rule is yet.
Decision 3: Where do design values live?
This is the highest-leverage decision of all. If you get it right, then theming, dark mode, and rebrands become almost free. Get it wrong, and you'll be find-and-replacing hex codes forever.
Here's the approach. Put every root and theme design value in a token layer with three tiers:
- Primitive tokens — raw, meaningless-on-their-own values; e.g.
--blue-500,--space-4(the palette, spacing steps, radii, font families, motion durations) - Semantic tokens — roles that describe intent, pointing at primitives; e.g.
--color-surface,--color-text,--color-border,--color-action - Component aliases — only when needed — e.g. a search-input surface token that genuinely can't share a semantic role
Application and component rules always consume semantic tokens, never raw colors. A button, for example, says background: var(--color-action), not background: var(--blue-500) and definitely not #1e70b3.
Why it matters
Indirection is the point. Define the default once, and override only the delta.
When a designer says "make the accent a bit more teal," you can change one primitive and every semantic role that references it updates. When you add dark mode, you don't restyle components at all, you just repoint semantic tokens.
The mistake to avoid: listing the entire light palette under :root and again under .light, and listing the entire dark palette under both a media query and .dark. Four copies of everything, guaranteed to drift.
Do this instead:
/* :root IS the default theme. State every role once. */
:root {
--color-surface: oklch(0.99 0.01 95);
--color-text: oklch(0.25 0.02 260);
--color-border: oklch(0.85 0.01 260);
}
/* .dark overrides ONLY the roles that actually change. */
.dark {
--color-surface: oklch(0.18 0.02 260);
--color-text: oklch(0.95 0.01 95);
--color-border: oklch(0.35 0.02 260);
}
If light is the default, then .light should not restate :root. Let device or system preference (prefers-color-scheme) apply only when the user hasn't made an explicit choice, and let an explicit .light/.dark class on <html> be authoritative once your theme script runs. One source of truth. One delta per theme.
When should you make a variable? Not for everything. Reach for a variable / token when a value repeats, carries meaning, or is a theme decision. This usually applies to colors, breakpoints, border and radius, box-shadow, font families, and motion durations. A one-off z-index: 3 inside a single component does not need to become --z-index-that-one-thing.
Rule of thumb
Components reference semantic tokens. Themes override semantic tokens. Primitives are referenced only by tokens, never by components. If a component contains a literal color, that's a bug waiting to happen.
📚 MDN: color-scheme
📚 web.dev: Design tokens & CSS custom properties
Decision 4: How do you write color?
Define new palette values in OKLCH. Expose them through semantic tokens. Prefer modern alpha syntax like oklch(0.55 0.18 255 / 12%) over opaque 8-digit hex.
Why it matters
Hex and HSL describe color the way a machine stores it, not the way an eye sees it.
Hex has been preferred "because it's easier for the team to edit". It is the default design tool output, the most compact and universally interoperable format, and the right choice for one-off exact sRGB values. But a hex value is opaque. It tells you nothing about how a color relates to its neighbors, and building an even shade scale means eyeballing each step. OKLCH also reaches colors on modern wide-gamut displays that hex simply can't express.
HSL has been preferred "for programmatic control", because it has separate hue/saturation/lightness channels. But its lightness is geometric, not perceptual. Yellow and blue at the same L read as very different brightnesses. OKLCH improves palette and token work by giving you both the channel control of HSL as well as the perceptual uniformity HSL lacks.
The advantages of OKLCH are:
- Sane manipulation. Want a hover state 10% darker? In OKLCH you subtract from lightness and the hue stays put. In hex, there's no such operation without a preprocessor or a color library, which is one of the main reasons teams historically adopted Sass in the first place. OKLCH plus
color-mix()removes that reason. - Perceptual uniformity. The first number is lightness as humans actually perceive it. Nudging lightness by the same amount looks like the same visual step across every hue, which is exactly what you need for building a consistent palette and hitting contrast targets.
When using OKLCH for systematic palette control, use named tokens for the majority of your surface, text, and border colors and use color-mix() sparingly for soft/hover variants.
Perceptual uniformity makes contrast more predictable across hues, but it doesn't guarantee accessibility. Verify contrast in both light and dark themes. Aim for at least 4.5:1 for normal text and 3:1 for large text. Never use color as the only signal of state: pair it with an icon, weight, or underline.
Rule of thumb
New color? Write it in OKLCH, name it by role, and check it in a contrast tool before you commit.
📚 MDN: oklch()
📚 OKLCH color picker
📚 Evil Martians: OKLCH in CSS — why we moved from RGB and HSL
📚 W3C CSS Color 4
Decision 5: Sizing — the 10px root and why everything is rem
Set a global root font size and size all dimensions in rem. The convention here is html { font-size: 10px; }
Why rem
rem is relative to the root font size, so when the browser's default font size is increased for readability, the whole UI scales with it. Hard-coded px ignores user intent and does not scale.
Why 10px specifically
It's a mental-math cheat code. With a 10px root, rem values are just the pixel value divided by ten: 16px = 1.6rem, 24px = 2.4rem, 250px = 25rem. You get all the scaling benefits of rem without doing division-by-16 in your head every time, which is the usual reason people give up and go back to px.
Rule of thumb
Use rem for typography, spacing, and component sizing. Reach for other units only where they're genuinely more appropriate like % and viewport/container units for fluid layout, and px for things that should not scale, like hairline borders.
If it's type, spacing, or a component dimension, it's rem. If you catch yourself typing a pixel value for those, convert it (divide by ten) or ask why it must be fixed.
📚 MDN: font-size & rem units
Decision 6: Typography scale — restraint over precision
Base heading sizes on a modest typographic scale. A Major Third (ratio 1.25) works well as a guide for this, not as a rigid formula. Use fluid sizing (clamp()) for headings so they breathe between mobile and desktop, and keep heading line-height around 1.1–1.25.
Why a scale
Sizes chosen by "eh, that looks bigger" produce muddy hierarchy. A ratio gives you a harmonious, predictable set of steps: body × ratio = h-small, × ratio again = the next step, and so on. A gentle ratio like 1.25 keeps h4/h5/h6 distinguishable without the top headings becoming billboards. Steeper ratios look dramatic on a landing page and fall apart in long-form content.
Why fluid
clamp(min, preferred-vw, max) lets one declaration cover the whole viewport range, so you can avoid a pile of breakpoint-specific font-size overrides. Preserve semantic heading order (h1 → h2 → h3) regardless of visual size. This order is for machines and assistive tech, not just for looks.
Rule of thumb
Pick a ratio, generate the steps, then trust your eyes to adjust. Don't ship a scale so aggressive that h5 and h6 are indistinguishable.
📚 type-scale.com
📚 Spencer Mortensen: The typographic scale
📚 MDN: clamp()
Decision 7: Breakpoints — describe layouts, not devices
Use a small, shared set of breakpoints tied to layout constraints, not device names. A proven set of constraints is 600 / 900 / 1200 / 1800px (which, under the 10px root, are the tidy 60rem / 90rem / 120rem / 180rem). A mix of min-width and max-width queries is fine; you don't have to convert everything to mobile-first.
Why "layout, not device."
"Phone / tablet / desktop" is a model that stopped being true years ago. Screens don't fall into three buckets. The honest question is "at what width does this specific layout stop looking good?" Add a breakpoint there, and name breakpoints after the constraint, not the device so they stay meaningful as hardware changes, e.g. "The feed goes two-up here" survives a new iPad; "iPad" does not.
Why these numbers
They sit between common device widths rather than on top of them, which avoids the classic bug where a device's exact width lands on a query boundary and flickers.
Placement
Keep media queries next to the rule they modify. Don't banish all responsive code to the bottom of the file, separating cause from effect. The base rule and its overrides should be readable together:
.site-menu-desktop {
display: none;
}
@media (min-width: 60rem) {
.site-menu-desktop {
display: initial;
}
}
Rule of thumb
Only add a breakpoint when you can point at the thing that breaks. "It's a tablet" is not a reason.
📚 MDN: Using media queries
📚 MDN: Container queries
Decision 8: Naming and text case — make selectors self-documenting
Use concise, descriptive, lowercase kebab-case class names (.site-header-mobile, .search-result-card), organized by the block/feature/component they describe.
It also helps to agree on which case goes where, so the team stops relitigating it:
kebab-casefor class names, the CSS-native defaultcamelCasefor JS/TS-facing IDsSCREAMING_SNAKEfor constants, never selectors
Consistency matters more than which convention you pick.
Good names make a selector readable; simple ones keep it predictable. These habits cost nothing and keep any rule from being hard to override later:
- Avoid descendant selectors for components you own.
.sidebar h3breaks the moment anh3appears somewhere unexpected, so give it a class like.sidebar-title. Narrow, scoped descendants are fine for prose/CMS content you don't control; see Decision 10. - Don't qualify a class with an element to win specificity.
div.headerandh1.titleare code smells, so just use.headerand.title. Bare element selectors are fine only for genuine global defaults. - Avoid IDs as styling hooks. IDs win every specificity fight and can't be reused, so use a class instead and reserve IDs for anchors and JS/TS hooks. For non-ID JS hooks, a
js-/ts-prefixed class like.js-search-buttonkeeps styling and behavior untangled. - Name into blocks, avoid one-offs. One-off classes scatter a component's styles across the file, so collect related selectors under the component, ordered to mirror the visual hierarchy (
.card,.card-header,.card-header-image,.card-body). - Keep utilities genuinely cross-cutting. A utility that only one component uses is just a component class in disguise, so keep utilities to single-purpose modifiers you can add to any element, like
.hideor.center.
Why it matters
A good selector name replaces a comment. .search-result-card tells the next person (or agent) what it is, where it lives, and what it shouldn't touch. Specificity stays flat, so the cascade stays predictable, and nobody needs !important.
Rule of thumb
If you need a comment above a selector to explain what it's for, the name isn't doing its job. Rename the selector until the comment becomes redundant.
Decision 9: Declaration order and the cascade — remove the guesswork
Two kinds of order matter, and confusing them causes a lot of "why won't this rule apply" pain.
Order within a selector: alphabetical. Sort declarations alphabetically, and place any vendor-prefixed property immediately before its standard equivalent:
.site-header {
align-items: center;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: flex-start;
padding: 0.5rem 2rem;
-webkit-transition: 1s;
transition: 1s;
width: 100%;
z-index: 100;
}
Alphabetical order is arbitrary but decided. You always know where to look for a property and where to insert a new one, so diffs stay clean and merge conflicts shrink. The vendor-prefix rule ensures the standard property "wins" when both are supported.
Order across rules: general to specific. When two rules are equally specific, the one written last wins. That makes source order a tool rather than a hazard, as long as you sequence it deliberately. Within each file, order rules:
- base / default rules,
- component or content rules,
- state and interaction selectors (
:hover,:focus-visible,[aria-expanded="true"],.is-open), - responsive overrides,
- preference overrides (
prefers-reduced-motion, high contrast).
Prefer accurate shorthands (border: 1px solid var(--color-border)), and prefer logical properties (margin-block, padding-inline, border-inline-start) when they express intent more clearly, because they document layout direction and adapt to right-to-left languages for free.
The golden rule: avoid !important. It's not banned because it's evil; it's avoided because each one escalates the next fight and eventually everything is !important and nothing is. The only sanctioned exception is overriding third-party/generated markup you can't otherwise reach. Make sure every such case in the integration file where it belongs gets a comment explaining why.
Rule of thumb
If you're typing !important on markup you authored, stop: a flatter selector or a corrected source order is the real fix.
📚 MDN: Specificity
📚 MDN: Cascade layers
📚 MDN: Logical properties
Decision 10: The stuff you didn't author — treat CMS & third-party markup as a boundary
Keep styles for generated markup out of your component CSS, in dedicated content.css / integrations.css files. This applies to a CMS's content classes, syntax-highlighter tokens, diagram libraries, injected embeds, and all markup you receive rather than author.
Why it matters
This markup has a property yours doesn't: you don't control the HTML. You can't add a class to it, so scoped descendant selectors are legitimately necessary here (the exception to Decision 8). Isolating those rules means:
- Your component CSS stays flat and class-driven.
- The one place
!importantmight be justified is quarantined and documented. - A search for a selector points to one authoritative home instead of scattered overrides.
Before you change these styles, test against representative real content like the cards, galleries, code blocks, and diagrams your CMS actually emits, because the coupling is to its output, not to your components' output.
Rule of thumb
Ask yourself: "Do I own this HTML?" If the answer is no, the styles go in the content/integration layer, scoped tightly to their container, with a comment on every hack.
Decision 11: Icons — inline SVG, not an icon font
Ship icons as inline SVG (e.g. a small Icon component that renders SVG paths). Avoid adopting an icon font like Google Material Icons or IcoMoon.
Why it matters
Icon fonts were a clever hack for their era: one small file, versionable, tintable with color. But they carry real costs, like an extra font to load (with a flash of missing glyphs), icons mapped to arbitrary characters that screen readers may announce as junk, and blurry rendering at some sizes. Inline SVG is accessible (you can add a title/aria-label), crisp at any scale, individually version-controlled, and styleable with currentColor and fill, with no font loading and no glyph-code bookkeeping.
The one allowed use of a CSS-driven icon is a purely decorative mark attached to already-accessible text, for example an external-link arrow after a link. Implement it with a mask-image pseudo-element so it inherits the semantic color, and make sure it duplicates no meaning that's missing from the accessible name:
.outside-link::after {
background-color: currentColor; /* inherits the link color */
content: "";
display: inline-block;
height: 1em;
width: 1em;
mask-image: url("/icons/external.svg");
mask-repeat: no-repeat;
mask-size: contain;
}
Rule of thumb
If the icon carries meaning, use inline SVG with an accessible name. If it's a purely decorative accent on already-labeled text, use a mask-image pseudo-element. Never reach for an icon font.
📚 MDN: mask-image
📚 MDN: SVG accessibility
Decision 12: When to reach for modern CSS
You don't need the following CSS features to be productive, and adding them mechanically can quietly change your cascade. Adopt each only when it solves a demonstrated problem, and introduce one category at a time so any regression has an obvious cause:
- Cascade layers (
@layer) — once your file order is stable, name the precedence explicitly (reset, tokens, base, layout, components, content, integrations, utilities) so intent is visible and specificity stops escalating. Don't add layers while moving rules around. - Native nesting — keep a component's base, states, and responsive rules together. Limit to ~one level deep so specificity doesn't creep.
:where()— zero-specificity defaults for prose/reset/generated content that components should override effortlessly.- Logical properties —
margin-block,padding-inline,inline-sizefor new layout work. - Intrinsic sizing —
min(),max(),clamp(),minmax(),repeat(auto-fit, …)to delete breakpoint-specific width rules. - Container queries — for reusable cards/results that should respond to their parent's width, not the viewport's. Keep viewport media queries for page-level concerns like the header.
Rule of thumb
Never add a framework, preprocessor, CSS-in-JS runtime, or icon font just to get one of these features. Keep styles for generated markup as explicit integration rules. And whenever you touch tokens, cascades, layout queries, or transitions, be sure to test them across both themes, mobile and desktop, and reduced-motion.
📚 MDN: @layer
📚 MDN: :where()
📚 Can I use…
Cheat sheet for humans in a hurry
| Decision point | Do this | Not this | Because |
|---|---|---|---|
| Tooling | Plain CSS + custom properties | Tailwind / Sass / Less / CSS-in-JS | The platform now ships variables, nesting, color math |
| File layout | Split by responsibility, one ordered entry point | One mega-file or one file per component | Ownership is the #1 predictor of maintainable CSS |
| Values | Primitive → semantic → (rare) component tokens | Literal colors/sizes in components | Change once, propagate everywhere; theming for free |
| Theme | :root = default; .dark overrides only deltas |
Restating full palette per theme/class | Duplication drifts; deltas don't |
| Color | oklch(), referenced by role |
Hex / 8-digit hex / HSL | Perceptual uniformity + native manipulation |
| Sizing | rem on a 10px root |
Hard-coded px for type/spacing |
Respects user zoom; math is trivial |
| Type scale | Modest ratio (~1.25) + clamp() |
Eyeballed sizes; steep ratios | Predictable hierarchy that survives long content |
| Breakpoints | 60/90/120/180rem, by layout constraint | Device-named breakpoints | Screens aren't three buckets |
| Naming | Lowercase kebab-case, by component | Descendant/element-qualified/ID selectors (app code) | Flat specificity, self-documenting |
| Declarations | Alphabetical; vendor prefix before standard | Random order | Predictable diffs and insertion points |
| Cascade | General → specific; state & queries near base | Overrides dumped at file end | Cause next to effect |
!important |
Avoid; document the rare integration exception | Reflexive use on your own markup | Each one makes the next override harder |
| Generated markup | Scoped, in content/integrations files |
Mixed into component CSS | You don't own that HTML |
| Icons | Inline SVG (+ mask-image for decorative accents) |
Icon fonts (Material, IcoMoon) | Accessible, crisp, no font loading |
| Modern CSS | Adopt one at a time, to solve real problems | Bulk-adopting features | Keeps regressions traceable |
Summary
The goal is to produce CSS that doesn't rot. Start by writing down the decisions, so the next disagreement has a documented answer instead of another argument. Do that well and the result is predictable, maintainable, and easy for engineers and agents to reason about.
Plain CSS, one home per rule, tokens instead of literals, a deliberate cascade, and a hard boundary around markup you don't own — none of these decisions are exotic. They just advocate for spending the least amount of energy for the most impact.
