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: fourteen decisions, each with a default, a rationale, and a rule of thumb, covering tooling, file structure, tokens, color, type, spacing, radius, layout, naming, the cascade, generated markup, icons, and modern CSS. It closes with the checks that keep each decision honest once it meets a real codebase.

css3-logo

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; global.css is the ordered entry point; if you bundle, a test keeps the bundle in that order One mega-file or one file per component Ownership is the #1 predictor of maintainable CSS
Values Primitive → semantic (incl. status/severity pairs) → (rare) component tokens Literal colors/sizes in components Change once, propagate everywhere; theming for free
Theme :root = default; [data-theme="dark"] overrides only deltas; script sets it before first paint; color-scheme per block Restating full palette per theme; toggling a class after paint Duplication drifts; deltas don't; no FOUC (flash of unwanted content)
Color oklch(), referenced by role; convert → verify → tune Hex / 8-digit hex / HSL; retuning during conversion Perceptual uniformity + native manipulation
Sizing rem on a 62.5% (10px) root (except in media queries) Hard-coded px for type/spacing Respects user font-size setting + zoom; math is trivial
Type scale Modest ratio (~1.25) + clamp() Eyeballed sizes; steep ratios Predictable hierarchy that survives long content
Spacing 8px/4px scale as rem tokens; aliases for repeated intent; px nudges for optics Bare margins/padding/gaps; an alias per selector Consistent grouping; one-line rhythm changes
Radius Scale of tokens; nested radii derived via RO = R + P Literal radii; eyeballed inner corners Corners stay mathematically consistent as tokens change
Breakpoints 600 / 900 / 1200 / 1800px (only the ones you need); @container for components Device-named breakpoints; rem assuming a 10px root Screens aren't three buckets; rem is 16px in a query
Naming Lowercase kebab-case, by component; one name, one meaning Descendant/element-qualified/ID selectors (app code); shared state/element names 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 Disable the library's injected styles, then scope yours in content/integrations Mixed into component CSS; overriding injected <style> You don't own that HTML
Icons Inline SVG (+ mask-image for decorative accents); aria-hidden on decorative marks Icon fonts (Material, IcoMoon) Accessible, crisp, no font loading
Modern CSS Adopt one at a time, only if it deletes code; record each decision Bulk-adopting features Keeps regressions traceable

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:

  1. a global stylesheet that everything dumps into, and
  2. 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 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 (Decision 10).

Linking straight to global.css works, though the browser resolves @imports one file at a time in series. If you already have a build or server step, concatenate the files in global.css order into one stylesheet and add a test that fails when the bundle order and global.css disagree.

Also give components.css a size budget. When it passes ~800–1,000 lines, split it by UI region (components-table.css, components-dialogs.css, components-forms.css), not by component, and add each to the ordered list.

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:

  1. Primitive tokens — raw, meaningless-on-their-own values; e.g. --blue-500, --space-4 (the palette, spacing steps, radii, font families, motion durations)
  2. Semantic tokens — roles that describe intent, pointing at primitives; e.g. --color-surface, --color-text, --color-border, --color-action
  3. Component aliases — only when needed — e.g. a search-input surface token that genuinely can't share a semantic role

The semantic tier needs more than surface, text, border, and action. Almost every real app also needs a status and severity family: --color-success, --color-warning, --color-danger, and, if you rank things, --color-severity-critical / -high / -medium / -low. Each of these comes as a pair: the text/icon color and a -surface tint it sits on (--color-warning, --color-warning-surface). Define the pairs together, in both themes, and check contrast on the text-on-tint combination, not just text-on-page-surface. Tinted backgrounds are where status colors most often fail 4.5:1.

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 a light selector, and listing the entire dark palette under both a media query and a dark selector. Four copies of everything, guaranteed to drift.

Do this instead:

/* :root IS the default theme. State every role once. */
:root {
  color-scheme: light;
  --color-surface: oklch(0.99 0.01 95);
  --color-text: oklch(0.25 0.02 260);
  --color-border: oklch(0.85 0.01 260);
}

/* The dark theme overrides ONLY the roles that actually change. */
:root[data-theme="dark"] {
  color-scheme: 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);
}

Three details make this work in practice:

  • Use data-theme on <html>, not .light/.dark classes. Theme is a single value; dataset.theme = theme sets it in one assignment and can't leave both classes on <html> at once.
  • Set it from an inline <head> script before the stylesheet. Read any stored choice, fall back to matchMedia('(prefers-color-scheme: dark)'), and set data-theme before first paint. Because that script handles prefers-color-scheme and runs before anything renders, there is no flash and your CSS needs only one dark block, not a second copy inside @media (prefers-color-scheme: dark).
  • Set color-scheme in each block. It switches native scrollbars, form controls, and the page canvas with the theme so they don't stay light in dark mode.

If light is the default, a light block should not restate :root. 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:

  1. 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.
  2. 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. If every hover and soft state already has its own token, that is fine too; the question is which one lets you delete values. A handful of color-mix() calls that replace a dozen near-duplicate tokens earn their place. A color-mix() whose result you then have to tune by hand in dark mode anyway does not, so give that state a token.

Perceptual uniformity makes contrast more predictable across hues, but it doesn't guarantee accessibility. Verify contrast in both light and dark themes, and on every text-on-tint pair (status text on its -surface, badge text on its badge), not only body text on the page surface. 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, underline, or text label. Automate the check: run axe-core (the open-source accessibility engine behind Playwright's @axe-core/playwright and Chrome Lighthouse, which tests the rendered DOM against WCAG rules) in light, dark, and a narrow phone layout, with each dialog open, so a token change that breaks contrast fails a build instead of a user.

Migrating an existing palette

Converting hex to OKLCH is a chance to break the UI without noticing. Do it in three ordered steps, and don't skip the middle one:

  1. Convert exactly. Translate each hex value to its precise OKLCH equivalent. No rounding to "nicer" numbers yet.
  2. Verify identical. Screenshot before and after (headless Chrome is enough) in light, dark, and a ~360px phone layout. The diff should be empty.
  3. Then tune. Now that OKLCH is the source of truth, adjust lightness steps, hues, and contrast as separate, reviewable commits.

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 of 10px at default settings (html { font-size: 62.5%; }, which is 62.5% of the browser's 16px default) and size all dimensions in rem.

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 (62.5%) 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. Writing the root as 62.5% rather than a hard 10px means if a user raises their browser's default font size from 16px to 20px, the root becomes 12.5px and every rem scales with it. 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.

One exception to the divide-by-ten arithmetic is media queries. Inside @media (…), rem and em resolve against the browser's initial font size (16px by default), not your root font-size. 60rem in a media query is 960px, not 600px. Decision 7 covers more on how to write breakpoints.

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 is 600 / 900 / 1200 / 1800px, written in px. Do not write them as 60rem and friends: Decision 5's 10px root does not apply inside a media query, where rem resolves against the browser's 16px initial font size, so 60rem is 960px. A mix of min-width and max-width queries is fine; you don't have to convert everything to mobile-first.

You also don't have to use all four. A single-layout app with one breakpoint at 900px is following this decision correctly. The set above is a menu of proven constraints, not a quota.

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: 900px) {
  .site-menu-desktop {
    display: initial;
  }
}

Breakpoints can't be tokens

Custom properties don't work inside a media query condition. @media (min-width: var(--bp-md)) is invalid, so the breakpoint value is the one design constant you will repeat. Handle it deliberately:

  • For components, prefer @container. Container queries respond to the parent's width, so a card doesn't need to know the page breakpoint at all. This removes most repetitions.
  • For page-level queries, repeat a single constant and check it. Pick the literal (900px), use it verbatim everywhere, and add a grep or lint rule that fails on any other min-width/max-width value not in the agreed set. Twenty copies of the same number that a test guards are fine; twenty slightly different numbers are not.
  • Responsive tokens are the pattern, not responsive queries. If spacing or sizing should change at a breakpoint, re-declare the token inside the media query in tokens.css (@media (max-width: 900px) { :root { --space-gutter: var(--space-3); } }) and let every consumer follow. That keeps the breakpoint in one more place but keeps the layout change in one.

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-case for class names, the CSS-native default
  • camelCase for JS/TS-facing IDs
  • SCREAMING_SNAKE for 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 h3 breaks the moment an h3 appears somewhere unexpected, so give it a class like .sidebar-title. There are exactly three allowed cases for a descendant selector: (1) prose/CMS content you don't control - see Decision 10; (2) state on an ancestor, set by script or ARIA, like .is-collapsed .cell, [aria-expanded="true"] .chevron, or :root[data-theme="dark"] .logo, where the state genuinely lives on the parent; (3) bare element selectors for genuine global defaults in base.css.
  • Don't qualify a class with an element to win specificity. div.header and h1.title are code smells, so just use .header and .title.
  • Never style on an ID. IDs win every specificity fight and can't be reused. IDs stay in the markup: as anchors, as ARIA targets (aria-controls, aria-labelledby), as script hooks, and as targets for libraries like htmx (hx-target="#rows"). None of that is a reason for CSS to select on them. Make the rule checkable: no # in any selector in any stylesheet. For non-ID JS hooks, a js-/ts- prefixed class like .js-search-button keeps 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).
  • One name, one meaning. A class name must mean a single thing in a single place. The real-world failure: a checkbox label and the page-level state both named .show-ignored. Ticking the box set the state class on <html>, the label rule matched too, and the whole page turned grey. State classes get a prefix that says so (.is-, .has-, or a data- attribute); element classes never share a name with them.
  • 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 .hide or .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:

  1. base / default rules,
  2. component or content rules,
  3. state and interaction selectors (:hover, :focus-visible, [aria-expanded="true"], .is-open),
  4. responsive overrides,
  5. 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. It also applies to interaction libraries whose only footprint is a few classes they toggle on your elements: htmx's .htmx-request / .htmx-indicator, Alpine's x-cloak, a transition library's enter/leave classes. Those are integrations too, even if they're only eight lines.

Step one is to turn the library's injected styles off, not to override them. Many libraries inject a <style> block at runtime with their own defaults. Overriding it means fighting specificity you didn't choose, in a cascade position you don't control. Most libraries have a switch (htmx: includeIndicatorStyles: false in htmx-config); flip it, then write the few rules you need in integrations.css. A strict Content-Security-Policy without 'unsafe-inline' will block the injected <style> anyway, so doing this up front saves a confusing debugging session later.

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 first of Decision 8's three allowed cases). Isolating those rules means:

  • Your component CSS stays flat and class-driven.
  • The one place !important might 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 and mark any inline-SVG equivalent aria-hidden="true" (sort arrows, chevrons, bullets) so screen readers don't announce it. Never reach for an icon font. Let the same axe-core run from Decision 4 enforce this: it flags images without names and names that duplicate adjacent text.

📚 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-size for 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.
  • :has() — parent/sibling state without a script-set class, e.g. .row:has(:checked) or form:has(:invalid) .submit.

The test: does it delete code?

"Solves a demonstrated problem" is easy to rationalize. The sharper question is would adopting this feature let me delete code? A worked example from one real app:

Feature Would delete Decision
Logical properties Nothing yet, but every new rule is written once for LTR and RTL Adopt for all new rules
clamp() / min() Several breakpoint-specific width and font-size overrides Adopt
:has() Three .js- state classes and the script that toggled them Adopt
@layer Nothing: file order already encoded precedence and specificity was flat Not yet
Nesting Nothing: it reindents the same rules without removing any Not yet

Record the decision for each feature in the same place you record these fourteen, so the next person doesn't re-litigate it. "Not yet" is a valid answer with a reason attached; "we don't use that" without one is not.

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…

Decision 13: Spacing — a scale, not a feeling

Define a small primitive spacing scale as custom properties, stepped on an 8px grid with a 4px sub-unit for tight spots, and layer semantic aliases on top for intents that repeat (card padding, feed gaps, section rhythm). Never hand-write a bare rem or px value for a margin, padding, or gap.

Under Decision 5's 10px root, the 8px grid is just as easy to hold in your head as the raw pixel values:

:root {
  --space-1:  0.4rem;  /*  4px — sub-grid, tight gaps */
  --space-2:  0.8rem;  /*  8px — base unit */
  --space-3:  1.2rem;  /* 12px */
  --space-4:  1.6rem;  /* 16px */
  --space-6:  2.4rem;  /* 24px */
  --space-8:  3.2rem;  /* 32px */
  --space-12: 4.8rem;  /* 48px */
  --space-16: 6.4rem;  /* 64px */
  --space-24: 9.6rem;  /* 96px */

  --space-card-padding: var(--space-4);
  --space-feed-gap: var(--space-6);
  --space-section: var(--space-8);

  /* Optical nudges: sub-grid, for alignment only. Never for layout rhythm. */
  --space-nudge-1: 1px;
  --space-nudge-2: 2px;
  --space-nudge-3: 3px;
}

Why it matters

Spacing chosen "one element at a time, by eye" drifts in a way that's obvious side-by-side. A scale is Decision 3's primitive/semantic split applied to space instead of color — pick the step once, reuse it everywhere, and a "tighten things up" request becomes a one-line token change instead of a find-and-replace across five files.

The scale also encodes a rule that's easy to violate without noticing: spacing inside a group should read smaller than spacing between groups. If a card's internal padding is larger than the gap between cards, the eye can't tell where one card ends and the next begins. Keep internal steps (--space-2–--space-4) below external ones (--space-6 and up), and be suspicious of any card padding pushing past 32px: that isovercorrection, not generous whitespace.

Components consume the semantic aliases (--space-card-padding) rather than the raw step (--space-4) wherever an intent repeats, for the same reason Decision 3 keeps components off primitive colors: the alias documents intent, so a future "make cards roomier" change touches one line instead of every selector that happens to use 16px today.

When an alias is worth it

Aliases grow fast. A real app that started with the three above ended with about twenty (--space-inline, -stack, -gutter, -dialog, -control-x/-y, -cell-x/-y, -list-row-x/-y, …), and still used raw steps directly about as often as aliases. That is the expected steady state, not a failure, so treat "components consume aliases" as a goal with a test, not a hard ban on raw steps:

  • Create an alias when the same step is used by two or more rules for the same reason, or a designer has named the role ("control padding", "table cell inset"). Those are the places a future change will want one knob.
  • Use the raw step directly when it's a single-use layout gap with no name. gap: var(--space-3) between two buttons in one toolbar does not need --space-toolbar-button-gap. The raw step is still on the scale, still themeable, and still searchable.
  • The hard rule is the bare value. padding: 1.3rem or margin-top: 7px is never acceptable in a component. Everything goes through a token, alias or raw.

Sub-grid nudges

Optical alignment sometimes genuinely needs 1–3px: an icon baseline, a border that makes a button sit a pixel low, text that needs lifting next to a taller control. The "never type a rem value" rule has no answer for this, and silence here is how bare numbers creep back in. So give nudges a home: a tiny --space-nudge-* family (shown above) declared in px, because an optical correction should not scale with the root font size. Using one is a signal to add a comment saying what it aligns to. If you find yourself reaching for a nudge in layout rhythm (between cards, around sections), the scale is wrong, not the nudge.

Rule of thumb

If you're about to type a bare rem or px value for margin, padding, or gap, stop and reach for a spacing token instead. If no token fits, that's a sign the scale is missing a step, not a license to eyeball it. And check mobile: a scale that only gets tested at desktop width will quietly grow cramped or bloated the first time someone resizes the viewport.

📚 MDN: Using CSS custom properties

Decision 14: Radius — a committed scale, and nested corners derived from it

Define a small radius primitive scale as custom properties, and commit to it the same way Decision 13 commits to a spacing scale. Never hand-write a one-off px/rem radius in a component rule.

:root {
  --radius-sm:   0.4rem;  /*  4px — inline code, focus ring, tiny chips */
  --radius:      0.8rem;  /*  8px — default component radius */
  --radius-lg:   1.6rem;  /* 16px — cards, prominent surfaces */
  --radius-pill: 99rem;   /* oversized absolute length, for non-square stadium shape — pills, badges */
}

Radius has one wrinkle that spacing doesn't: nested rounded corners. The moment a rounded element sits inset inside another rounded element, like a thumbnail inside a card or an avatar inside a header, picking the inner radius "by eye" almost always looks slightly wrong, even when the two values are individually reasonable. Two corners with the same radius value don't actually match once one is inset by padding. The inner arc reads as oversized because it doesn't share the outer arc's center of curvature.

There's a formula for getting this right:

outer radius (RO) = inner radius (R) + padding between them (P)

Which means: don't choose the inner radius directly. Derive it from the outer radius and the real padding token that separates the two elements.

.card {
  padding: var(--space-card-padding);
  border-radius: var(--radius-lg);
}

.card figure {
  border-radius: calc(var(--radius-lg) - var(--space-card-padding));
}

Why it matters

An eyeballed inner radius (calc(var(--radius-lg) - 0.6rem), or worse, a bare 10px) is a magic number wearing a calc() costume: it isn't tied to the padding that actually separates the two corners, so it silently goes stale the moment either the outer radius or the padding token changes. Deriving it from the real tokens means the relationship self-corrects. Tighten the card's padding, and the inner corner automatically re-nests correctly, with zero visual cost.

Sometimes the honest result of the formula is 0 (a square inner corner), when the padding is as large as the outer radius. That's not a bug to work around with an arbitrary minimum, it's the formula telling you the padding and outer radius you chose don't leave room for a visible relative inner curve. Treat that as a real design signal, and either accept the square corner, or revisit the padding/radius pairing until the math gives you a radius worth keeping.

Circles (border-radius: 100%, on a square element) and pills (var(--radius-pill)) are exempt — they're not "nested corners" in the sense the formula addresses, so don't force the calculation on them. var(--radius-pill)'s oversized absolute length is what actually gets clamped to a true semicircular cap on each end while keeping the top/bottom edges flat. That's the whole reason the token exists as a length instead of a percentage.

If your UI has no nested rounded corners yet, there is nothing to derive, and a stylesheet with zero calc(var(--radius…)) is correct. Keep the formula for the day a card gets an inset image - when an eyeballed calc() would otherwise appear.

Rule of thumb

If you're about to subtract a bare number from a radius token in a calc(), stop. That number should be the actual padding/gap token separating the two elements, not a value you nudged until it looked right in the browser. If two radii don't line up and you don't know why, check whether one is inset from the other — that's almost always the piccalilli formula waiting to be applied.

📚 Piccalilli: Relative rounded corners

Keep the decisions honest

A decision nobody can verify is a preference. Each of these is a few lines in a test suite or CI script, and each one closes a real-world gap:

Check Guards Decision
If bundled: bundle file order equals the @import order in global.css Cascade drift 2
components.css under the agreed line budget Mega-file creep 2
No # in any selector IDs as styling hooks 8
Every min-width/max-width literal is in the agreed breakpoint set Breakpoint drift 7
No bare rem/px in margin, padding, gap, border-radius outside tokens.css Off-scale values 13, 14
No literal color outside tokens.css Primitives leaking into components 3, 4
axe-core passes in light, dark, and ~360px, with each dialog open Contrast, icon names, focus 4, 11
Screenshot diff is empty after a "convert exactly" migration step Silent visual change 4

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.

External References

Accessibility

Design

Frameworks

Concepts

Colors

Role: Principal Software Engineer

Setting: Providence St. Joseph Health

Location: Portland, Oregon

Year: 2026