Lesson 23 of 25

CSS Best Practices

The Real Cost of CSS Is Changing It Later

Any CSS that produces the right picture today has done half the job. The other half only shows up in a month, when a change has to be made and nobody is certain what will break. Every practice in this lesson exists to make that second moment cheaper, and none of them are about making the page look better right now.

The symptoms of CSS that has gone wrong are recognisable, and you can check for them in your own files. You are afraid to delete a rule because you cannot tell what depends on it. Selectors keep getting longer, because each new rule has to out-rank an older one. !important is appearing, and then appearing again to beat the first one. The same hex code is written in eleven places, and a colour change means finding all eleven. New styles get added at the bottom of the file because that is the only place they reliably win.

Every one of those is a specificity or a duplication problem, and both have straightforward cures. The theme running through this lesson is that CSS gets hard to change when rules can reach each other unexpectedly — so the goal is rules that are shallow, named for what they do, and built from values defined once.

  • Afraid to delete a rule — nothing tells you what it affects
  • Selectors getting longer over time — each one fighting the last
  • !important appearing more than once or twice in a file
  • The same colour or spacing value repeated across the stylesheet
  • New rules only working when added at the very bottom
  • Two rules that must be edited together but sit hundreds of lines apart

Name Classes for What Things Are

A class name should describe the thing's role, not its current appearance. .red-text is an accurate name for about a week, until the design changes and you are left with .red-text { color: blue; } — a name that now actively lies to the next reader. .error-message stays true whatever colour it becomes.

The same logic applies to size and position words. .left-column breaks the day the layout is mirrored for mobile. .sidebar does not. .big-heading tells you nothing about when to use it; .page-title does.

Prefer classes to element selectors and ids for anything that is a component. An element selector such as .card h3 quietly binds your CSS to a particular HTML structure, so changing the heading level for accessibility reasons breaks the styling. A class on the heading survives that. Ids are worse for a different reason: they are unique, so an id-based rule can never be reused, and they carry so much weight in the cascade that overriding them requires escalation.

Consistency matters more than which convention you pick. Lower-case with hyphens is the most common in CSS and reads well; the important thing is that the same idea is spelled the same way everywhere in the project.

Example
/* Names that will become lies */
.red-text     { color: #c0392b; }
.left-column  { }
.mt-20        { margin-top: 20px; }   /* only honest if it never changes */

/* Names that describe the role */
.error-message { color: var(--danger); }
.sidebar       { }
.page-title    { }
.card__footer  { }

/* Bound to the HTML structure — fragile */
.card h3 { font-size: 1.1rem; }

/* Bound to a name you control — robust */
.card__title { font-size: 1.1rem; }

/* Ids are unique and heavy; keep them for anchors and scripts */
#checkout { }        /* avoid for styling */
.checkout { }        /* reusable, easier to override */
Notes
  • One useful test for a class name: could you say it out loud to a teammate and have them find the right element on the page? card title passes. big blue does not.

Keep Specificity Flat and Values in One Place

Almost every stylesheet that becomes unpleasant to work in got there through specificity creep. It always starts reasonably: a rule needs to beat an earlier one, so a second class is added to the front of it. Then something needs to beat that, so a third goes on. Eventually the only way to win is !important, and from that point the file only gets worse.

The discipline that prevents it is simple to state: aim for one class per rule, and when something needs to be different, add a class rather than write a longer selector. A card that needs a highlighted variant gets .card--featured alongside .card. Both rules are one class deep, both can be overridden by anything that comes later, and neither has to know about the other.

Two tools help. :where() contributes nothing to specificity, so base styles wrapped in it are trivially easy to override — ideal for typographic defaults. And custom properties let a variant change values rather than restate rules, which keeps the number of selectors down.

The other half is duplication. Every colour, spacing value, radius and transition duration should be defined once as a custom property and referenced everywhere else. This is not tidiness for its own sake: it means a design change is one edit rather than a search across the file, and it makes inconsistencies impossible rather than merely unlikely.

Example
/* Specificity creep — each rule fighting the last */
.card .content .title { }
.page .card .content .title { }
.page .card .content .title { color: red !important; }

/* The flat version */
.card__title            { }
.card--featured .card__title { }   /* two classes at most */

/* Base styles that are meant to be overridden */
:where(.prose) p  { margin-bottom: 1em; }   /* scores 0-0-1 */
.prose .lead      { margin-bottom: 2em; }   /* wins easily */

/* Values defined once */
:root {
  --brand:   #d1039e;
  --radius:  10px;
  --space-3: 16px;
}

.btn  { background: var(--brand); border-radius: var(--radius); padding: var(--space-3); }
.chip { background: var(--brand); border-radius: var(--radius); }
  • One class per rule wherever possible; two is the practical ceiling
  • Add a modifier class instead of lengthening a selector
  • Reserve !important for third-party CSS you cannot edit and for utilities meant to always win
  • Wrap easily-overridden defaults in :where()
  • Define every colour, space and radius once as a custom property
  • Keep related rules together in the file, not scattered by when you wrote them

Performance: What Actually Matters

CSS is render-blocking. A browser will not paint anything until it has downloaded and parsed the stylesheets in the <head>, because painting first and restyling afterwards would make the page flash. That single fact decides which optimisations are worth doing.

What matters is how much CSS you send. Unused rules from a framework you use three components of, or from a redesign whose old styles were never deleted, still have to be downloaded and parsed on every first visit. Deleting dead CSS is the highest-value performance work in this whole lesson, and minifying and compressing what remains is nearly free.

What matters far less is selector speed. Modern browsers match selectors extremely efficiently, and the difference between a one-class selector and a four-level descendant selector is not something a visitor will ever perceive. Write shallow selectors because they are easier to maintain, not because you are chasing milliseconds.

Two other habits are worth adopting. Avoid chains of @import inside stylesheets — each one is discovered only after its parent has been parsed, so the files download one after another instead of in parallel; use separate <link> tags or a build step that combines them. And prefer animating transform and opacity, which the browser can handle without redoing layout, over animating sizes and positions.

Example
/* Parallel downloads */
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/components.css">

/* Serial downloads — slower for no benefit */
/* base.css */
@import url('components.css');

/* Cheap to animate */
.card:hover { transform: translateY(-4px); opacity: 1; }

/* Expensive — layout recalculated every frame */
.card:hover { width: 320px; margin-top: -4px; }
Notes
  • Use the Coverage panel in your browser's developer tools to see how much of each stylesheet a page actually uses. On a site built with an unfamiliar framework, the number is often startling, and it points directly at what can be removed.

A Checklist Before You Call It Done

Most of the ways a page fails for real users are invisible on the machine it was built on. The list below takes a few minutes to work through and catches the great majority of them.

Two of these deserve emphasis because they are so easy to get wrong. Sizing text in px overrides the reader's own browser setting, so anyone who has enlarged their default text finds your site ignoring them; rem fixes that. And building interactions around :hover alone leaves both touch users and keyboard users stranded, because neither has a hover state — every hover behaviour needs a focus or tap equivalent.

Finally, get into the habit of testing on a real phone rather than only in the browser's device emulator. The emulator cannot show you a tap target that is too small for a thumb, a fixed header that eats a third of a short screen, or how the page behaves while a keyboard is open. Five minutes on an actual device finds problems that an hour of resizing a desktop window will not.

  • Text sized in rem, so the reader's browser setting still works
  • Body text at 4.5:1 contrast or better, checked rather than guessed
  • A visible :focus-visible style on every interactive element
  • Nothing important reachable only by hover
  • prefers-reduced-motion honoured for transitions and animations
  • The page usable at 200% browser zoom without content being cut off
  • No horizontal scrolling at 360 pixels wide
  • Colour never the only carrier of meaning
  • Tested in both light and dark system themes
  • Checked on a real phone, not only in the device emulator
Notes
  • Add a comment at the top of each stylesheet saying what it is for and what it should not contain. It takes ten seconds and is the cheapest way to stop a file quietly turning into the place where everything ends up.
Ask AI