The hidden attribute is a suggestion — here's how to make it a rule
Your stylesheet can silently overrule the HTML hidden attribute — and the reason is cascade origin, not specificity. The classic one-line hardening fix, the until-found carve-out it needs now, and when the !important hammer is the wrong tool.
A while back I wrote up a tag filter that silently did nothing: JavaScript set the hidden attribute, the cards' own display: flex quietly overruled it, and the feature shipped fully wired and completely inert. The note gives you the bug and the one-line fix. This is the deep dive — why hidden loses by design, why the popular explanation gets the mechanism wrong, and why the classic fix now needs a carve-out that didn't exist when the advice was written.
A rule that loses by design
hidden reads like a command: put it on an element and the element should be gone. But the attribute removes nothing by itself. It works because every browser ships a user-agent stylesheet, and that stylesheet contains a rule for it. The WHATWG spec's expected rendering rules are more nuanced today than the folklore version:
[hidden]:not([hidden="until-found" i]):not(embed) { display: none; }
[hidden="until-found" i]:not(embed) { content-visibility: hidden; }
embed[hidden] { display: inline; height: 0; width: 0; }
Three things are worth noticing. The plain case is still display: none. There's a second state, until-found, hidden by a completely different property — we'll get there. And all of it lives in the user-agent stylesheet, which is the entire problem.
The spec is not naive about this. The hidden attribute's own definition carries a plain warning: "Because this attribute is typically implemented using CSS, it's also possible to override it using CSS." MDN documents the same behavior — style a hidden element display: block and it displays, attribute or no attribute. Neither document prescribes a fix. They're telling you the gun is loaded; where it points is your problem.
It's the cascade, not specificity
The popular explanation gets the fix right and the reason subtly wrong. Monica Dinculescu's classic "The hidden attribute is a lie" described the UA rule as "less specific than a moderate sneeze" — memorable, and the prescription that follows from it is correct, but specificity never enters into it.
The cascade sorts declarations by origin and importance before specificity (CSS Cascade Level 5). For normal declarations, the origin order is:
- author — your stylesheets, inline styles, JS-set styles: wins
- user — reader preferences
- user agent — the browser's built-ins: loses
Specificity only breaks ties within an origin. So .card { display: flex } doesn't defeat [hidden] { display: none } because .card is the mightier selector — it defeats it because you wrote it. Any author declaration for display — a utility class, an inline style, one line of JavaScript — outranks any normal UA declaration, regardless of how the selectors compare. That's also why the failure is perfectly silent: nothing is wrong, nothing throws; the cascade is doing exactly what it's specified to do.
Origin beats specificity. Your stylesheet doesn't outrank the browser's because it's more specific — it outranks it because it's yours.
And !important is not "extra specificity" either. It reverses the origin order: important user-agent declarations rank above important user declarations, which rank above important author declarations — but every important declaration ranks above every normal one. That reversal is precisely why the classic hardening works.
The one-line hardening
[hidden] { display: none !important; }
This is the long-standing community fix — Dinculescu's post ends with it, CSS-Tricks endorsed it ("the hidden attribute is visibly weak"), and it still makes the rounds in HTMHell's advent calendar. The mechanism is exactly the origin reversal above: your important author declaration now sits in a band that no normal declaration from any origin can reach. display: flex on the card class loses. An inline style loses. element.style.display = "block" loses.
Worth being clear-eyed about its status: this is community practice, not a spec or MDN recommendation. The platform's position is that overridability is a feature. The hardening rule is you deciding, deliberately, that in your codebase the attribute is the single authority on visibility.
For years, that was the whole story. It isn't anymore.
The carve-out: hidden="until-found"
The hidden attribute grew a second state: hidden="until-found". Instead of display: none, the browser applies content-visibility: hidden — the element keeps its box, so its contents stay reachable by find-in-page, fragment navigation, and scroll-to-text. When a match is found, the browser fires a beforematch event and removes the attribute, revealing the content. Collapsed FAQ sections and accordion panels that users can still Ctrl-F into — that's the use case.
Support: Chromium since 2022, Firefox since mid-2025, Safari not yet in stable at the time of writing — check current support before you rely on it, but in Chromium and Firefox it's already live in your users' hands.
Here's the collision: the [hidden] attribute selector matches hidden="until-found" too. display and content-visibility are different properties, so your hardening rule's display: none !important applies on top of the UA's content-visibility: hidden — and an element with display: none generates no boxes, so there's nothing for find-in-page to reveal. The spec warns about exactly this: authors "are encouraged to make sure that their style sheets don't change the display or content-visibility properties of hidden until found elements."
So the current form of the rule carves that state out:
[hidden]:not([hidden="until-found" i]) { display: none !important; }
The i makes the value match case-insensitive, same as the UA's own rule. Tailwind CSS v4 ships this exact shape in its preflight — as [hidden]:where(:not([hidden='until-found'])) { display: none !important; }, wrapping the exclusion in :where() so the selector's specificity stays flat and easy to override deliberately.
When the hammer is wrong
Tailwind adopting the rule is also the best documented case against it. Their v4 preflight change drew real-world pushback, and the complaints are instructive: !important doesn't just defeat accidents — it defeats intent. A class="block" meant to reveal a hidden element: dead. An inline style="display:block": dead. A third-party library that toggles visibility by setting element.style.display while leaving the attribute in place: dead, and now its failure is the silent one.
Which surfaces the real design question, the one the one-liner quietly answers for you: is hidden the single source of truth for visibility in your codebase? If yes — you toggle the attribute, and nothing ever reveals an element by out-styling it — the hardening rule is pure upside, and the bug that started this post becomes impossible. If your code or your dependencies intentionally override hidden with styles, the rule doesn't fix your problem; it relocates it.
If you'd rather not fight the cascade
The hardening rule is the right tool for attribute-authoritative codebases. If that's not yours, there are quieter options:
- Own the class instead of the attribute. A
.is-hidden { display: none }utility you control end-to-end has no UA-origin weakness to paper over. - Guard your own display rules:
.card:not([hidden]) { display: flex }. Your flex rule stops applying the instant the attribute lands — no!important, no fight. - Use cascade layers to manage priority explicitly inside the author origin, rather than reaching for importance.
- Toggle cleanly from JS: set
el.hidden = true, and if something left an inline display behind, remove it —el.style.removeProperty("display")— instead of trying to out-shout it.
The short version
hiddenworks through the browser's stylesheet, so anydisplayyou set overrides it — by cascade origin, not specificity.- The classic fix —
[hidden] { display: none !important; }— still works, but in a world withhidden="until-found"it needs the carve-out:[hidden]:not([hidden="until-found" i]). !importantdefeats intentional overrides as thoroughly as accidental ones. Decide whether the attribute is your codebase's single visibility switch before you harden it.- And the debugging lesson that started all this: when a DOM feature does nothing at all — no error, no effect — suspect the cascade before you suspect your logic.