
CSS Best Practices for Clean, Maintainable Code
Writing CSS that works today is easy. Writing CSS that still makes sense in a year, after a redesign, three new team members, and a hundred feature requests, is the real skill. The difference rarely comes down to clever techniques. It comes down to habits: how you name things, how much specificity you use, where values come from, and how components are allowed to affect one another.
Modern CSS makes good habits easier than ever. Custom properties, cascade layers, native nesting, logical properties, container queries, and :where() all exist, in part, to help stylesheets stay maintainable. In this guide, I'll share the practices that make the biggest difference to clean, predictable CSS, with examples of what to do and what to avoid.
What Makes CSS "Maintainable"?
Maintainable CSS has a few recognizable qualities:
- Predictable. You can tell what a rule does and what it affects by reading it.
- Low-risk to change. Editing a component doesn't break unrelated pages.
- Consistent. The same problem is solved the same way everywhere.
- Easy to delete. Unused styles can be removed with confidence.
Every practice below serves at least one of these goals.
1. Keep Specificity Low and Flat
Specificity is the single biggest source of CSS pain. When selectors have wildly different weights, overriding anything becomes a contest, and the codebase slowly fills with longer chains and !important.
Aim for a flat specificity graph, where almost every selector is a single class.
/* Avoid: high, uneven specificity */
#main-content .sidebar ul li a.active {
color: #4f46e5;
}
/* Prefer: one class, easy to override */
.sidebar-link.is-active {
color: #4f46e5;
}
Practical rules:
- Style with classes, not IDs.
- Avoid qualifying classes with elements (
div.card) unless there's a real reason. - Keep selectors to two or three parts at most.
- Use
:where()for defaults you want to be trivially overridable, since it contributes zero specificity.
:where(.prose) :where(h2, h3) {
margin-block: 1.5em 0.5em;
}
2. Control the Cascade with Layers
Specificity is one lever. Cascade layers give you a second, more powerful one: they let you rank whole groups of styles, and a later layer beats an earlier one regardless of how specific its selectors are.
@layer reset, base, components, utilities;
@layer base {
a {
color: var(--color-link);
}
}
@layer components {
.card a {
color: inherit;
}
}
@layer utilities {
.text-danger {
color: var(--color-danger);
}
}
A single-class utility in utilities beats .card a in components, with no !important required. Declare the layer order once at the top of your entry file, and the whole team gets a shared mental model of which styles win. Cascade layers are supported in all current major browsers.
3. Use a Consistent Naming Convention
Names are your documentation. A good convention tells you what an element is, which component it belongs to, and whether it's a variant or a state.
BEM is one popular choice:
<article class="card card--featured">
<img class="card__image" src="/img/cover.jpg" alt="" />
<div class="card__body">
<h3 class="card__title">Launch week recap</h3>
<p class="card__excerpt">Everything we shipped in five days.</p>
</div>
</article>
.card {
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.card__body {
padding: var(--space-6);
}
.card__title {
margin: 0 0 var(--space-2);
}
.card--featured {
border: 2px solid var(--color-primary);
}
It doesn't have to be BEM. What matters is that the team uses one convention everywhere. A few universal naming tips:
- Name things by purpose, not appearance.
.alert-errorsurvives a redesign;.red-boxdoesn't. - Use state classes consistently:
.is-active,.is-open,.has-error. Or use ARIA and data attributes as state hooks, such as[aria-expanded="true"], so styling follows real accessibility state. - Don't abbreviate into riddles.
.btnis universal;.nvbr-itm-lnkis not.
4. Replace Hardcoded Values with Tokens
Every repeated value, whether colors, spacing, font sizes, radii, shadows, or transition timings, should come from a single source.
:root {
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-surface: #ffffff;
--color-primary: #4f46e5;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--radius-md: 8px;
--radius-lg: 14px;
--duration-fast: 150ms;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Avoid */
.alert {
padding: 14px 18px;
color: #5f6b7d;
border-radius: 7px;
}
/* Prefer */
.alert {
padding: var(--space-4) var(--space-6);
color: var(--color-text-muted);
border-radius: var(--radius-md);
}
Tokens stop drift (no more five nearly identical grays), make theming and dark mode a matter of remapping variables, and turn design decisions into something you can search for.
5. Give Components a Custom Property API
Custom properties aren't only for global tokens. They're also a clean way for components to expose controlled customization points, so consumers don't need to override internal selectors.
.button {
--button-bg: var(--color-primary);
--button-fg: #fff;
--button-padding: var(--space-2) var(--space-4);
padding: var(--button-padding);
background: var(--button-bg);
color: var(--button-fg);
border: 0;
border-radius: var(--radius-md);
}
.button--danger {
--button-bg: var(--color-danger);
}
.button--large {
--button-padding: var(--space-4) var(--space-6);
}
Variants now change a variable or two instead of redeclaring properties. And a one-off context can adjust a button safely:
.promo-banner .button {
--button-bg: #111827;
}
6. Keep Components Self-Contained
A component's styles should only affect that component. The moment one component's CSS starts reaching into another's, you create hidden dependencies.
/* Avoid: the sidebar reaches into card internals */
.sidebar .card__title {
font-size: 1rem;
}
/* Prefer: an explicit variant owned by the card */
.card--compact .card__title {
font-size: 1rem;
}
Similarly, a component shouldn't set its own external margins or positioning. Where it sits is the parent layout's job:
/* Avoid: the card decides its own spacing from neighbors */
.card {
margin-bottom: 24px;
}
/* Prefer: layouts handle spacing */
.card-list {
display: grid;
gap: var(--space-6);
}
Components that don't carry external margins can be dropped into any layout without surprises.
7. Nest Shallowly
Native CSS nesting is supported in all current major browsers, and it's a nice way to keep related rules together. But deep nesting silently creates long, high-specificity selectors.
/* Avoid: compiles to .nav ul li a span */
.nav {
ul {
li {
a {
span {
color: #64748b;
}
}
}
}
}
/* Prefer: nest states and variants, one level deep */
.nav-link {
color: var(--color-text);
&:hover {
color: var(--color-primary);
}
&[aria-current="page"] {
font-weight: 700;
}
@media (width >= 48rem) {
padding-inline: var(--space-4);
}
}
A good rule of thumb: nest pseudo-classes, states, and media queries, not DOM structure.
8. Use Modern Layout Instead of Hacks
Floats, clearfixes, negative margins, inline-block grids, and absolute-positioning tricks were necessary once. Today, flexbox and grid solve those layouts directly, with less code and fewer edge cases.
/* A responsive card grid with no media queries */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
gap: var(--space-6);
}
/* A media object */
.media {
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
/* Centering anything */
.center {
display: grid;
place-items: center;
}
When a component should adapt to the space it's given rather than the whole viewport, use container queries, supported in all current major browsers:
.product-card-wrapper {
container-type: inline-size;
}
@container (width >= 30rem) {
.product-card {
display: grid;
grid-template-columns: 10rem 1fr;
}
}
9. Prefer Relative Units and Logical Properties
Relative units respect user preferences and adapt to context:
remfor font sizes and most spacing, so everything scales with the user's font-size setting.emfor things that should scale with the element's own text, like icon sizes or button padding.%,fr, andmin()/max()/clamp()for fluid layout.chfor readable line lengths, as inmax-width: 65ch.
Logical properties make layouts work across writing directions, so a right-to-left language doesn't need a separate stylesheet:
.callout {
margin-block: var(--space-6);
padding-inline: var(--space-4);
border-inline-start: 4px solid var(--color-primary);
}
margin-block, padding-inline, and border-inline-start map to top/bottom, left/right, and the "start" edge automatically based on the document's direction.
10. Build Accessibility In
Accessible CSS is maintainable CSS, because it's written with real users and real states in mind from day one.
/* Visible, consistent focus */
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Hide visually but keep for screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
Also check color contrast for text and UI elements, keep touch targets comfortably large (around 44 by 44 pixels is a good target), and never convey meaning with color alone.
11. Comment the Why, Not the What
Good CSS is mostly self-explanatory. Comments should explain things the code can't: why an unusual value exists, which browser bug a workaround targets, or when a hack can be removed.
/* Avoid: restates the code */
.modal {
z-index: 300; /* set z-index to 300 */
}
/* Prefer: explains intent and exit criteria */
.modal-body {
/* Safari can clip rounded corners on scrolling children
without this. Re-test and remove once fixed upstream. */
isolation: isolate;
}
Section headers in longer files help too, but if a file needs lots of them, it's probably doing too much and should be split.
12. Automate Formatting and Linting
Don't spend code review time on indentation or property order. Let tools handle it.
- Prettier formats CSS consistently.
- Stylelint catches errors and enforces your conventions.
{
"extends": ["stylelint-config-standard"],
"rules": {
"selector-max-id": 0,
"selector-max-specificity": "0,3,0",
"max-nesting-depth": 2,
"declaration-no-important": true,
"color-named": "never"
}
}
That config bans IDs, caps specificity, limits nesting, discourages !important, and blocks named colors like red in favor of tokens. Run both in your editor and in CI so the rules are enforced automatically. If utilities legitimately need !important, relax that rule for the utilities file with an override.
13. Embrace Progressive Enhancement
Write a solid baseline first, then layer on newer features where they're supported, using @supports for groups of related declarations:
.site-header {
background: var(--color-surface);
}
@supports (backdrop-filter: blur(1px)) {
.site-header {
background: rgb(255 255 255 / 0.7);
backdrop-filter: blur(12px);
}
}
The page is fully usable everywhere, and capable browsers get the polish. This keeps you from writing fragile browser-specific code, and it makes newer features safe to adopt.
14. Delete Code Fearlessly
Unused CSS is the silent killer of maintainability. It adds weight, confuses readers, and makes everyone afraid to touch anything.
- Co-locate component styles with component code, so deleting a component deletes its CSS.
- Use the Coverage panel in Chrome DevTools to find rules unused on a page (remembering it only reflects states you've visited).
- Prefer one clear source for each style over several overlapping ones.
- When refactoring, remove the old code in the same pull request. "We'll clean it up later" rarely happens.
15. Keep Performance in Mind
Clean CSS is usually fast CSS, but a few habits help:
- Avoid giant, overly generic selectors like
.page *that match thousands of elements. - Animate
transformandopacityrather than layout properties likewidth,top, ormargin. - Use
content-visibility: autoon long, off-screen sections where it fits. - Keep your total CSS small, and consider inlining critical styles for the first view.
A Quick Checklist
Before merging CSS, ask:
- Is every selector a class, and at most two or three parts long?
- Are all values coming from tokens?
- Does this component only style itself?
- Would a new team member understand the class names?
- Does it work at small and large widths, with keyboard focus, and with reduced motion?
- Did I remove the code this replaces?
Conclusion
Clean, maintainable CSS comes from a handful of consistent habits: flat, low specificity; cascade layers for ordering; a single naming convention; tokens instead of hardcoded values; self-contained components with custom property APIs; shallow nesting; modern layout; relative units and logical properties; accessibility from the start; and tooling that enforces all of it.
None of these practices are complicated on their own. Their power comes from applying them every time, across the whole codebase. Do that, and your stylesheets stay predictable, easy to change, and pleasant to work in long after they were first written.


