CSS Best Practices
Quality patterns for CSS architecture, specificity management, theming, and layout. Applies to plain CSS, SCSS, and CSS Modules.
When to Use
- Reviewing CSS or SCSS for scalability and maintainability
- Evaluating naming conventions (BEM, utility, custom properties)
- Designing dark mode or theming strategy
- Diagnosing specificity conflicts or cascade issues
Don’t use for:
- Tailwind utility class patterns (use tailwindcss)
- CSS-in-JS patterns (use relevant framework skill)
- Animation performance (use web-performance)
Critical Patterns
✅ REQUIRED [CRITICAL]: Custom Properties for Design Tokens
Define semantic tokens, not raw values. One change point, consistent everywhere.
/* ❌ WRONG — raw value duplicated in 40+ places */
.button { background: #3b82f6; }
.link { color: #3b82f6; }
/* ✅ CORRECT — semantic token defined once */
:root { --color-brand-primary: #3b82f6; }
.button { background: var(--color-brand-primary); }
.link { color: var(--color-brand-primary); }
✅ REQUIRED: Specificity Budget
Single class selectors maximum. Avoid ID selectors and !important. If you need !important, the architecture is broken.
/* ❌ WRONG — specificity escalation */
#sidebar .nav > ul li a.active { color: red; }
/* ✅ CORRECT — single class */
.nav-link--active { color: var(--color-brand-primary); }
✅ REQUIRED: Cascade Layers
Use @layer to establish explicit cascade order: reset → base → components → utilities.
/* ✅ CORRECT — predictable cascade, no order-dependency */
@layer reset, base, components, utilities;
@layer reset { *, *::before, *::after { box-sizing: border-box; } }
@layer components { .card { padding: var(--spacing-md); } }
@layer utilities { .mt-4 { margin-top: 1rem; } }
✅ REQUIRED: Dark Mode via Custom Properties
Override custom properties at the theme level. Never duplicate color values per-component.
/* ❌ WRONG — color duplication per component */
@media (prefers-color-scheme: dark) {
.card { background: #1a1a1a; }
.nav { background: #1a1a1a; }
}
/* ✅ CORRECT — one override point for all components */
@media (prefers-color-scheme: dark) {
:root { --color-surface: #1a1a1a; }
}
[data-theme="dark"] { --color-surface: #1a1a1a; }
✅ REQUIRED: Layout with Modern Primitives
Grid for 2D layout, Flexbox for 1D alignment. No floats, no percentage hacks.
/* ❌ WRONG — fragile percentage grid */
.col { float: left; width: 33.33%; }
/* ✅ CORRECT — intrinsic grid */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
❌ NEVER: Magic Numbers
Unexplained values become unmaintainable. Extract to custom properties with naming that communicates intent.
/* ❌ WRONG — where did 23px come from? */
.modal { margin-top: 23px; }
/* ✅ CORRECT — documented offset */
/* 24px base - 1px border = 23px visual alignment */
.modal { margin-top: calc(var(--spacing-lg) - 1px); }
❌ NEVER: Mixed Naming Conventions
BEM and utility classes in the same component scope cause naming chaos. Pick one per scope.
<!-- ❌ WRONG — BEM + Tailwind + arbitrary classes -->
<div class="card card--featured mt-4 text-blue-600 card__title">
<!-- ✅ CORRECT — consistent convention per component -->
<div class="card card--featured"> <!-- BEM scope -->
<div class="mt-4 text-blue-600 font-bold"> <!-- Utility scope -->
Symptom → Solution
| Symptom | Cause | Fix |
|---|---|---|
Style needs !important to apply | Specificity war | Flatten with single-class selectors + @layer |
| Dark mode colors duplicated across components | Class-based overrides | Custom properties + media/data-theme override |
| ”Why is this 23px?” | Magic number | Extract to custom property with comment |
| Cascade breaks when file order changes | No explicit layer ordering | Add @layer declaration |
| BEM + utility classes mixed in same element | Inconsistent convention | Pick one pattern per component scope |
| Colors wrong in one theme but not another | Raw values, not tokens | Replace with semantic custom properties |
Decision Tree
Defining a color, spacing, or size used in 2+ places?
→ Custom property (design token) — not a raw value
Style not applying?
→ Check specificity — is a higher-specificity rule winning?
→ Use browser DevTools to inspect computed styles
→ Fix by lowering specificity of the winning rule, not raising the losing one
Need dark mode?
→ Override custom properties in @media (prefers-color-scheme: dark)
→ Also add [data-theme="dark"] selector for JS-controlled toggle
2D layout (rows AND columns)?
→ CSS Grid
1D layout (row OR column)?
→ Flexbox
Number with no obvious origin?
→ Magic number — extract to custom property or document with comment
BEM or utility classes?
→ New component in isolation → BEM (scoped, semantic)
→ Rapid composition of existing design tokens → utility
→ Never mix in same element's class list
File order matters for correct rendering?
→ Add @layer to make cascade order explicit and file-order-independent
Example
/* Design tokens */
:root {
--color-surface: #ffffff;
--color-text: #111827;
--color-brand: #3b82f6;
--spacing-md: 1rem;
--radius-card: 0.5rem;
}
/* Dark mode: single override point */
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #1f2937;
--color-text: #f9fafb;
}
}
[data-theme="dark"] {
--color-surface: #1f2937;
--color-text: #f9fafb;
}
/* Component uses tokens — works in both themes automatically */
.card {
background: var(--color-surface);
color: var(--color-text);
padding: var(--spacing-md);
border-radius: var(--radius-card);
}
/* Responsive grid using modern primitive */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
}
Edge Cases
Legacy browser support: Use @supports (--foo: bar) for feature detection rather than static compilation. For environments without custom property support, define fallback values inline before the var() call: color: #3b82f6; color: var(--color-brand-primary);
CSS Modules and custom properties: Custom properties defined in :root are global even in CSS Modules — tokens still work. Component-scoped variables must be defined on the component’s root element.
SCSS variables vs custom properties: SCSS variables are compile-time (no runtime theming); custom properties are runtime (theme switching works). Prefer custom properties for any value that changes with theme, viewport, or user preference.
Specificity in third-party overrides: When overriding a third-party library with high specificity, use @layer to wrap the library styles — then your layer-less styles automatically win without !important.