CSS usually doesn’t fail right away.
It fails a year later, when the system is under real use and changing anything feels risky.
Styles start to feel heavy. You open DevTools, jump through rules, and try to understand why something works the way it does. Refactoring feels mentally taxing and time consuming so you add another override just to make the ticket go away.
Bad CSS isn't just ugly, it's expensive. It’s the reason simple UI updates take three days instead of three minutes.
The code is doing too much.
Too much nesting. Too much specificity. Too many decisions made early that are too hard and too expensive to undo.
This article is about what causes this and how to avoid that. Not through tricks or tools, but through a few habits that keep CSS simple, readable, and cheap to change as a project grows.
These are the things I wish I learned earlier.
1. Starting With CSS Is the First Mistake
I know the article is about CSS, but one of the most common mistakes I see juniors make is jumping into a task and building everything at once. Styles, JavaScript, Markup, early abstractions.
Usually they start with the most complicated part. Or the most fun one.
I get it. CSS is tempting because it is tangible. You see results immediately.
This is wrong. The better approach is to put CSS aside, pause, slow down, and start with the markup.
If you are building a whole app, start with the page shell. Landmarks, heading hierarchy, main sections. Work on that first. Read the page start to finish. Does it make sense? Only move to styles when it does.
If you are working on a smaller feature, the same idea applies. Lay out the markup first and see how it fits into the rest of the app. Do the tags match the intent? Are you respecting the surrounding heading order?
Starting with markup forces you to think about intent, not appearance. You are deciding what things are, not what they look like. With an HTML-first approach you define structure, meaning, and constraints that keep the system simple and predictable.
CSS should adapt to that, not the other way around.
When you rush into styling, you tend to shape the markup around visual cues. Extra wrappers. Wrong tags. Usually you end up with things that look fine but are semantically wrong, or impossible to scale.
By committing the markup first, you lock in a solid foundation. Accessibility, document outline, keyboard flow, and content hierarchy are mostly solved upfront. Styling then becomes additive instead of corrective.
It also slows you down in a good way. You catch edge cases early. You see where things do not belong. You can push back on some design decisions.
In short: HTML-first makes everything else make sense.
2. When CSS Does More Than You Think
Another common mistake I see in code reviews is juniors doing too much, often without realizing it. Let me show you a few simple examples.
Say you want to center content column horizontally:
.entry-content {
max-inline-size: 48rem;
margin: 0 auto; /* ❌ Avoid this! */
}
This works. You set a max width and center the element by using auto margins.
However, what you might not realize is you are also setting top and bottom margins to zero. If that is intentional, fine. Most of the time it is not.
Most of the time the shorthand is used just because it is shorter. What you probably want instead is this:
.entry-content {
max-inline-size: 48rem;
margin-inline: auto; /* ✅ Do this */
}
Same result. No side effects. More intent.
Another common example:
.button--secondary {
background: var(--color--dark-gray);
}
This sets the background color using the --color--dark-gray custom property. But it also resets a bunch of other background-related properties.
Quite a lot, actually:
background-image→nonebackground-position→0% 0%background-size→autobackground-repeat→repeatbackground-origin→padding-boxbackground-clip→border-boxbackground-attachment→scroll
Most of the time, that is not what you want. This can easily lead to annoying side effects that you’ll have to deal with later.
What you probably want instead is this.
.button--secondary {
background-color: var(--color--dark-gray);
}
Same idea as before. But surgical. Only change what you actually mean to change. Again, more intent.
The same principle applies to other shorthand properties, such as border, transform, transition, font, and others. Be careful when using them as shorthands. Or avoid using shorthands altogether.
One more common example of doing too much is being too specific too early. Take this rule:
a {
text-decoration: none;
background-image:linear-gradient(to right, currentColor, currentColor);
background-position:0%100%;
background-repeat: no-repeat;
background-size:100%2px;
transition: background-size 0.3s;
&:hover,
&:focus-visible {
background-position:100%100%;
}
}
This snippet adds an animated underline to links.
The problem is that not every <a> tag is a text link. Images can be links. Profile avatars can be links. Buttons are often marked up as anchors.
Suddenly, this "clever" global style is adding weird 2px lines under your logo or breaking the background gradients on your primary buttons. You’ve created visual debt. Now, you’ll spend the rest of the project fighting the cascade, writing "undo" styles for every non-text link you work on. Unless you fix it, of course.
You can solve this in a few ways. One option is to target text links inside content.
.entry-content:is(h1, h2, h3, h4, h5, h6, p, li) > a {
/* styles here */
}
But even this can still be too narrow or too broad, depending on the markup. It relies on a specific HTML structure that might change.
My favorite approach is a utility class.
.has-animated-underline {
/* styles here */
}
By moving this to a class, you make the behavior opt-in rather than opt-out. It’s explicit, intentional, and—most importantly—it doesn't require you to fix things that weren't broken in the first place.
3. When Mobile Is an Afterthought
Mobile web traffic passed desktop globally in 2016 — almost ten years ago. Yet I still see designers spend most of their time working on desktop comps and presenting work desktop-first, with mobile treated as a simple "stacked" version of the desktop.
This still feel backwards.
Love it or not, we experience the internet through a phone first. That may change again, but it is the reality today.
The way we build for the web should reflect that.
In practice, this is how I approach styling now:
- Lay out the markup (talked about it 😉).
- Resize the browser to around 400px and style the app or feature as if desktop does not exist. At all.
- Once it works on mobile, resize the browser to desktop and do a second pass on the styles.
Working this way naturally gets you 80% (random number that feels right) there in terms of making your project accessible and ready for small screens.
This organically leads us to the mobile-first implementation.
When I started doing web development, media queries were barely a thing. We did not browse the web on phones. It was a desktop-only universe.
Then the iPhone came out, and media queries became the main tool for responsive layouts. The number of web-capable devices was limited, so you could get away with a rigid set of breakpoints:
/* phones */
@media (max-width: 480px) {}
/* large phones and small tablets */
@media (max-width: 767px) {}
/* tablets */
@media (min-width: 768px) and (max-width: 1024px) {}
/* desktop */
@media (min-width: 1025px) {}
Notice this is not even mobile-first. No min-width approach.
Today, the reality is different. There are too many devices and screen sizes to design for using breakpoints alone. It is often smarter to rely on tools that naturally adapt across the entire range.
For example:
- intrinsic layouts with Grid or Flexbox
- fluid values using
clamp()for font sizes, spacing, and dimensions - container queries
Media queries are still useful, but they should not be the first tool you reach for.
If a layout only works because of breakpoints, it is probably too rigid. When it works without them, media queries become small, intentional adjustments to the responsive structure, not the foundation.
4. Fighting the Cascade Instead of Using It
This is another area where I often see junior engineers struggle.
A lot of CSS frameworks and solutions exist to solve one main feature: the cascade. And I get why. If you jump into CSS without much experience, it can feel chaotic.
You write a rule and it does nothing. You tweak it and suddenly something else breaks. On top of that, the user agent stylesheet is doing its own thing in the background.
That is frustrating because CSS is nothing like most other languages. In JavaScript, you can create a module and nothing leaks out. Variables stay contained to that module.
You cannot approach CSS the same way. If you try, it will bite you really hard in the face.
Every CSS change lives in two scopes at once: the thing you are styling and the rest of the system it flows into.
Going back to the earlier example, you cannot just write this:
a {
text-decoration: none;
background-image:linear-gradient(to right, currentColor, currentColor);
background-position:0%100%;
background-repeat: no-repeat;
background-size:100%2px;
transition: background-size 0.3s;
&:hover,
&:focus-visible {
background-position:100%100%;
}
}
If you do, this will apply background-related properties to every anchor on the page, even if that anchor is not a text link.
Most of the time, that is not what you want.
Some engineers learn this the hard way and respond by avoiding the cascade altogether, like touching a hot stove as a kid and learning not to do it ever again.
I think that is a mistake because it leads to a lot of repetition.
Here is an example from a recent project I worked on. There was a mixin:
@define-mixin focus-state {
&:focus-visible {
outline: var(--outline--color) var(--outline--style) var(--outline--width);
outline-offset: var(--outline--offset);
}
}
It was then included across multiple components:
.button {
@mixin focus-state;
}
a {
@mixin focus-state;
}
.pagination-link {
@mixin focus-state;
}
…and so on.
I cannot think of a good reason for this. Focus outlines should look consistent across a project, with very few exceptions.
In this setup, every mixin call generates the same CSS over and over again.
A more elegant approach is to lean on the cascade and define this globally:
:focus-visible {
outline-color:var(--outline--color);
outline-offset:var(--outline--offset);
outline-style:var(--outline--style);
outline-width:var(--outline--width);
}
Now every element that receives a visible focus state gets the same styling by default.
And in the rare cases where something needs to be different, you can override it locally:
.pagination-link {
--outline--offset: -2px;
}
That is it. Simple, compact, and predictable.
The same idea applies to things like box-sizing, ::selection, margin resets, text-wrap, and other styles that should behave consistently across the app.
A quick gut check helps here. If a style looks exactly the same on every component, with very few exceptions, it probably belongs in your global styles.
Do not ignore the cascade. Use it to your advantage.
5. Specificity Is Where Things Continue to Fall Apart
This is usually the reason behind "I wrote my CSS but it doesn't work."
I'm not going to go deep into what specificity is. MDN already does that really well. What matters here is that high specificity is one of the main sources of pain when working with third-party libraries or somebody else's code.
Take this example:
.layout > :not(.alignleft):not(.alignright):not(.alignfull):not(.alignwide) {
max-width: var(--content-width);
margin-left: auto;
margin-right: auto;
}
Here we center the immediate children of .layout horizontally and apply a max width — all children except alignleft, alignright, alignfull, and alignwide.
There are two main reasons this code smells.
First, this is a blacklist pattern. We are styling everything except a growing list of things. The problem is that "everything" always changes over time. As the project grows, you will almost certainly add more exceptions. One day you will forget to add it, and something will break.
Second, every selector you add increases specificity. The snippet above is already 0.5.0. That means overriding it requires an even stronger selector, !important, @layer, or wrapping everything in :where(). In other words, you fix complexity with more complexity.
WordPress is a good example of starting without CSS architecture and ending up with chaos. Here is a very similar snippet from core:
body .is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
max-width: var(--wp--style--global--content-size);
margin-left: auto !important;
margin-right: auto !important;
}
I'm getting mixed signals here. We cornered ourselves with the :not() pattern, realized it was a bad idea, recovered with :where(), and then… applied !important to make margins impossible to override? I'm sure there was a reason for this, but man… isn't that frustrating.
You can certainly do all of that…
…or you can be more intentional and buy yourself some peace:
/* 0.1.0 */
.layout > * {
max-width: var(--content-width);
margin-left: auto;
margin-right: auto;
}
/* 0.2.0 */
.layout > .alignleft,
.layout > .alignright {
--content-width: var(--content-width--narrow);
}
/* 0.2.0 */
.layout > .alignwide {
--content-width: var(--content-width--wide);
}
/* 0.2.0 */
.layout > .alignfull {
--content-width: var(--content-width--full);
}
This is easier to read and easier to reason about. The base rule is 0.1.0. The exceptions are 0.2.0.
Predictable and intentional.
Here's another common way specificity grows for no good reason:
/*
❌ combine element with a class
❌ element nested inside the block
❌ modifier nested inside the block
*/
a.button {
&.button__label {...}
&.button--secondary {...}
}
In this example:
- If there is no reason to combine
aand.button, don't do it. - If there is no reason to nest
.button__labelunder.button, don't do it. - Same goes for modifier classes.
If you are shipping a new component, this is usually what you want instead:
/*
✅ no element + class combination
✅ elements and modifiers stay flat
*/
.button {...}
.button__icon {...}
.button--primary {...}
Here's the rule of thumb I follow:
- Prefer a single class selector by default.
- Use utility variations instead of stacking selectors. Source order will help you here.
- Keep specificity at
0.1.0or0.1.1. - Do not go above
0.2.0in features you ship. - Avoid
#idselectors and inline styles in app code. They spike specificity and are painful to override. - Use higher specificity only when overriding third-party or legacy code you cannot refactor yet. Isolate it and move on.
Note on :where(), @layer, and @scope
I am fully aware of these tools. You can reduce specificity to zero with :where(), isolate styles with @layer, and scope selectors with @scope.
They are powerful, and they have their place. But they do not fix bad architecture.
The same specificity rules still apply inside each layer and inside each scope.
Keep specificity low. Your future self will thank you.
