The hidden attribute loses to display: flex, and a test that reads el.hidden will not notice
The hidden attribute hides an element through a single rule in the
browser’s own stylesheet, display: none. Any display you set in your own
CSS overrides it, so a .banner { display: flex } stays on screen while
el.hidden is true — and a test that checks the property passes. Test what is
rendered instead. The usual one-line fix works, and has a catch of its own.
27 September 2026 · measured in headless Chrome 153.0.8010.36 on our extension’s real options page, with the stylesheet from before and after the fix
1. What the user saw
Our extension’s options page shows a banner asking for the one permission its shortcuts need.
When the user granted it, the code set the banner’s hidden to true, and the
banner stayed where it was: someone who had just granted the permission was still being asked for
it. The “collapse” button on the templates section set hidden on the cards, and
nothing moved. During the first-run tour, the old banner showed up alongside the tour’s
own.
2. Why it happens
The browser’s stylesheet contains [hidden] { display: none }. In the cascade your
stylesheet beats the browser’s before selector specificity is even compared, so
.banner { display: flex } or .cards { display: grid } wins, and the element is
laid out as if the attribute were not there.
Copying the browser’s rule into your own stylesheet is not enough by itself.
[hidden] and .banner have the same specificity, so whichever rule comes later in
the file wins, and a reset at the top of the file comes first. !important is what makes it
hold everywhere.
3. What we measured
We loaded the options page with its scripts removed, set hidden = true on the banner
and on the template cards, and read each one back three ways:
| Element and stylesheet | el.hidden | computed display | checkVisibility() |
|---|---|---|---|
| Permission banner, before the fix | true | flex | true |
| Template cards, before the fix | true | grid | true |
| Permission banner, after the fix | true | none | false |
| Template cards, after the fix | true | none | false |
Every row says the element is hidden, including the two where it is on screen.
4. Test what is rendered, not what you set
el.hidden // true in all four rows: it reports what your code set
getComputedStyle(el).display // 'flex' or 'grid' before the fix, 'none' after
el.checkVisibility() // true before the fix, false after
Every one of our tests for this page read the property, so every one passed while the banner was
on screen. What caught it was comparing a screenshot with the DOM. The test now reads the computed
display, and has a new check that the cards are really gone after “collapse”.
5. The one-line fix, and what it breaks
[hidden] { display: none !important; }
It covers every element on the page, including ones added later, and it is what we ship. It also
overrides hidden="until-found", the form of the attribute meant to keep content hidden but
findable with find-in-page. The browser hides that form with content-visibility: hidden,
not with display: none, and the rule above turns it back into display: none. We
put two boxes with display: flex on a test page, one with plain hidden and one
with hidden="until-found", and tried three stylesheets:
| Your rule | plain hidden | until-found | window.find() finds its text |
|---|---|---|---|
| none | flex (on screen) | flex, content hidden | yes |
[hidden]{display:none !important} | none | none | no |
[hidden]:not([hidden="until-found"]){display:none !important} | none | flex, content hidden | yes |
If nothing on your page uses until-found — nothing in our extension does — the
short rule is fine. If something does, use the longer one.
How we know, and what we did not test
Both tables come from one script that drives headless Chrome and records the browser version and
date with each reading. The first loads the extension’s real options.html, once with
the stylesheet from the commit before the fix and once with today’s. The second uses the two-box
test page.
What we did not test: the real find bar. window.find() is the closest a script can get,
and it is not the same thing — it found the until-found text without opening it, and
the beforematch event that the find bar fires never fired. We also measured Chrome
only.
Where this came from
From the options page of Custom Keyboard Shortcuts, a Chrome extension that binds key combinations to browser actions. The bug was there from version 1.0.0 and was fixed in 1.2.0, after a screenshot showed a banner that every test said was gone. The extension is free on the Chrome Web Store.
The API and the code are the easy half. The half that costs days is the store: the slot cap on a new account, the listing text going read-only mid-review, the uninstall URL you cannot change after you ship. The ten that caught us — three of them in full, no signup.
This note was drafted with an AI model from our own bug log and a reproduction we ran in Chrome, and automated checks ran before it was published.
More from the same build
Every one of these is something we hit while shipping the two extensions on this site, written up from our own code and measurements.
- Chrome Web Store API v1 to v2 — what actually changes
- Chrome Extension Ship Kit — everything between “it works” and “it is live”
- A new Chrome Web Store account can publish two extensions
- setUninstallURL ships inside the extension, so the page has to exist first
- chrome.runtime.id is a different string on Edge, and it breaks your store links silently
- suggested_key is a suggestion: commands.getAll() can hand you an empty shortcut