The bug that should have been impossible
Client homepage. Friday afternoon. Of course.
The design looked simple enough. Two-column hero, CTA on the left, image on the right. On mobile it had to stack vertically, text first, image second, with some tidy spacing and a consistent button style.
I had already built a reusable layout system for this project. Utility-style classes. A token-based spacing scale. Nothing fancy, but pretty clean. This should have been trivial.
Instead, a single button on the homepage quietly ignored almost every style I threw at it. Margins, font-size, padding. Everything worked on staging, and then half of it died when we merged to the main branch.
I thought I had broken the grid. I had not. I had started a specificity war.
The setup: a pretty normal component system
This was a mid-sized marketing site for a SaaS client. Not full design system level, but structured enough.
- We used CSS modules in a React app.
- Global utility classes for spacing, typography, and layout.
- Component-level CSS modules for more specific stuff.
Buttons were simple. I had a global class:
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-bg);
font-weight: 600;
text-decoration: none;
}
The home hero was a React component with a CSS module:
// HomeHero.module.css
.hero {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
gap: var(--space-5);
}
.cta {
margin-top: var(--space-4);
}
.ctaPrimary {
/* some layout-specific overrides */
}
Then the JSX:
<section className={styles.hero}>
<div>
<h1>Ship better experiments</h1>
<p>Blah blah growth lorem.</p>
<div className={styles.cta}>
<a
href="#pricing"
className={`btn-primary ${styles.ctaPrimary}`}
>
Start free trial
</a>
</div>
</div>
<div>...image...</div>
</section>
So far so normal. It looked fine. Story ends here, right? I wish.
The symptom: one button that refused to behave
After the merge, QA pointed out something small but annoying.
On the homepage only, the primary CTA had:
- Too much horizontal padding.
- Slightly different font size than the other buttons.
- No margin-top on mobile, so it stuck to the previous paragraph.
All other buttons across the site looked correct.
In dev tools, the weird part: I saw the correct rules, but they were crossed out and some other styles were applied instead. The padding, font-size, and margin-top were coming from somewhere else. Somewhere I did not expect.
I checked for inline styles. None. No style props from React. No obvious conflicting class.
Of course this happened on the most visible button on the site.
Hour 1: chasing the wrong layers
First instinct: global reset or some legacy CSS leaking in.
This project used a legacy stylesheet from their previous site. It lived under legacy.css and we gradually migrated stuff out of it. I immediately blamed that file, because I dislike big unstructured CSS files almost as much as I dislike silent bugs.
I searched for .btn, a.button, all the usual suspects. Nothing that matched the styles I saw in dev tools.
Then I looked at the compiled CSS in the browser. That is where I found this gem:
.hero a.btn-primary {
padding: 1rem 2.5rem;
font-size: 1.1rem;
margin-top: 0;
}
I had not written that. At least, I did not remember writing that.
It lived in a file called homepage.css, which I absolutely did write. Six months earlier. For version one of the hero before we modularised it.
So the browser was not wrong. I was. Or past-me was.
Specificity quietly doing its job
This is where specificity stops being theory and becomes pain.
I had:
.btn-primary { ... } /* global button base */
.hero a.btn-primary { ... } /* older homepage-specific rules */
And then in the CSS module I had:
/* HomeHero.module.css */
.ctaPrimary {
margin-top: var(--space-3);
padding-inline: 1.75rem;
}
I assumed the module class would win. It did not.
Because .hero a.btn-primary is more specific than just .ctaPrimary on the same element, some of my overrides inside the module were losing. The browser was doing exactly what I asked for months ago, not what I currently wanted.
Important detail. The compiled DOM looked like this:
<a
class="btn-primary HomeHero_ctaPrimary__1fj2a"
>
Start free trial
</a>
So the selector that matched was effectively:
.hero a.btn-primary
And the one I hoped would override it was just:
.HomeHero_ctaPrimary__1fj2a
Specificity math:
.hero a.btn-primaryhas 0-0-2-1 (two classes, one element)..HomeHero_ctaPrimary__1fj2ahas 0-0-1-0 (one class).
So the old homepage selector wins. Completely fair. Completely annoying.
Hour 2: trying to be clever instead of honest
I knew the root cause. Old homepage CSS with a higher specificity selector. Time to delete it, right?
Except that homepage.css was not used only for the hero anymore. Past-me had let other components lean on that file. A quick rip-out broke three other sections in spectacular ways.
So now I had options:
- Refactor
homepage.cssproperly, which would take an afternoon. - Hack around the specificity for this one button.
Because this was a live campaign launch with a fixed date, I went for the ugly route. I tried to outsmart specificity instead of respecting it.
Attempt one: stack the module class with the global one and hope the build order would save me.
.btn-primary.HomeHero_ctaPrimary__1fj2a {
margin-top: var(--space-4);
}
That meant editing the generated class name or reaching into the compiled CSS output. Both felt terrible. Also brittle. If the module name changed, the fix would silently die.
Attempt two: add another wrapper in the JSX and target that.
<div className={styles.ctaWrapper}>
<a href="#pricing" className="btn-primary">Start free trial</a>
</div>
Then:
.ctaWrapper .btn-primary {
margin-top: var(--space-4);
}
Still lost. Because .hero a.btn-primary matched the same element and had equal specificity on classes plus an element specifier. It did not give me the padding control I wanted.
Attempt three: !important. Yes, I tried it. Yes, I regretted even hovering my cursor near those nine letters.
.ctaPrimary {
margin-top: var(--space-4) !important;
}
This worked. Of course it did. And I hated it.
Because now I had raised the ceiling on how messy this could get. Future-me (or someone else) would have to fight against !important in a layout that should be simple.
So I removed it. Back to a broken button. Two hours gone.
Hour 3: reading the cascade properly
The turning point came when I stopped poking the code and spent 5 minutes just looking at DevTools like a human debugger instead of an impatient developer.
I filtered the Styles panel for btn-primary. I read every rule in order. Very slowly.
- Base
.btn-primaryinglobals.css. - The old
.hero a.btn-primaryinhomepage.css. - The module class
.HomeHero_ctaPrimary__1fj2a.
And then I noticed something stupidly simple.
The old rule was not just about padding. It duplicated things that belonged to the base button.
.hero a.btn-primary {
padding: 1rem 2.5rem;
font-size: 1.1rem;
margin-top: 0;
border-radius: 999px;
display: inline-flex;
}
That selector tried to redefine the entire button inside a hero. It was not a small override. It was a second “version” of the same component, glued to one layout.
This is the part where I had to admit that the bug was not really about specificity. The bug was about me breaking my own mental model of where base styles live and where layout lives.
Buttons should be global. The hero should not know about padding or font-size. It should care about alignment and spacing relative to other elements. That is it.
So instead of trying to beat the old selector, I asked a different question.
What is the minimal change that removes this misplaced responsibility without nuking the entire file?
The one-line fix
The answer:
.hero a.btn-primary {
- padding: 1rem 2.5rem;
- font-size: 1.1rem;
- margin-top: 0;
- border-radius: 999px;
- display: inline-flex;
+ margin-top: 0;
}
One line stayed. The line that actually was layout specific in context: margin-top: 0; for that older hero design.
Everything else was deleted. The base button styles came solely from .btn-primary again. The hero component could now override margin or padding with its own class, and the specificity made sense.
My actual commit was literally this, buried in a tiny PR:
- .hero a.btn-primary {
- padding: 1rem 2.5rem;
- font-size: 1.1rem;
- margin-top: 0;
- border-radius: 999px;
- display: inline-flex;
- }
+ .hero a.btn-primary {
+ margin-top: 0;
+ }
As soon as those redundant properties went away, the module class started doing exactly what I expected. The homepage CTA matched the rest of the site. No specificity hacks. No extra elements. No !important.
Three hours into a "bug" that ended with one deletion block and a single remaining line.
What this really taught me about specificity
I used to think of specificity as a force you fight with tricks. I do not think that anymore. Now I see it more as a signal that something in the structure is lying.
In this case, the lie was simple. The hero component was pretending to own the button design. So it used a more specific selector and tried to repaint an existing component inside a specific layout.
Some patterns I wrote down after this:
- If you ever write
.some-layout a.btn-primary, you are probably mixing component and layout responsibilities. - Global components should not be restyled through layout selectors. Extend or wrap them instead.
- Specificity bugs usually show up where your mental boundaries between layers are fuzzy.
That last one keeps proving true. When I see a selector like .header nav ul li a.active, my future-bug radar starts beeping. Not because long selectors are always bad, but because that chain usually means no one is sure who owns the style.
A few practical rules I now follow
This project pushed me to tighten how I structure CSS on client work.
1. Base components never get layout-specific wrappers
If I need a special version of a button for hero sections, I create a modifier or an explicit variant. For example:
.btn-primary--hero {
padding-inline: 2.25rem;
}
Then I can still compose it with layout classes inside the hero component, but no layout selector reaches into the button internals.
2. Layout files do not redefine padding or font-size for base components
They can tweak margins between components. They can set justify-content, gap, align-items. They should not compete with the component on essentials.
If a layout wants to change intrinsic component spacing, I treat that as a new variant or I adjust the base if every usage benefits from the change.
3. When a style looks “impossible”, I check for old CSS first
This is the boring practice that saves hours. Any time a component looks right in isolation but wrong on a specific page, I now:
- Open DevTools.
- Filter by the class name.
- Scroll through every matched rule, top to bottom.
I am usually one outdated selector away from the answer.
Why this bug was useful
This was not a fancy bug. No container queries. No exotic browser edge case. Just the core CSS rules doing their job and me forgetting that past decisions compound.
Three hours felt like overkill for a homepage button. But that time forced me to:
- Confront the lazy boundary between my layout and component layers.
- Stop leaning on selectors that mention both layout and component in the same breath.
- Write down a few rules I now follow before I touch any existing CSS.
And honestly, the fix being “delete four lines and keep one” is a useful reminder too.
If you are fighting a specificity issue right now, there is a decent chance you do not need a trick. You probably need to remove the wrong layer of styling from the wrong place, then add one honest line back where it belongs.
This time, that one line sat in .hero a.btn-primary. Next time it might be somewhere else. But I will be checking the old CSS first.
Subscribe to my newsletter to get the latest updates and news
Member discussion