Design wants a country picker. Nothing fancy — a dropdown, a small flag icon next to each name. You reach for <select>, type the first <option>, and hit the wall every frontend dev hits eventually: an <option> can only ever show text. No <img>, no icon span, nothing. The browser renders whatever markup you put inside it as a plain string.
So you do what everyone does. You build a fake one.
The dropdown you build instead
A <div role="listbox">, a button that toggles it, <div role="option"> children with real icons inside, arrow-key handlers, a click-outside listener to close it. It's maybe 150 lines. It works in the demo. Ship it.
<div class="select" id="country-select">
<button aria-haspopup="listbox" aria-expanded="false">🇨🇦 Canada</button>
<div role="listbox" hidden>
<div role="option" data-value="ca">🇨🇦 Canada</div>
<div role="option" data-value="jp">🇯🇵 Japan</div>
<div role="option" data-value="br">🇧🇷 Brazil</div>
</div>
</div>
Then someone drops this select inside a card component — rounded corners, overflow: hidden so a stray image never bleeds past the border radius. Totally normal CSS. And now the popup, which is just a child <div> positioned absolute, gets sliced off at the card's edge the moment it's taller than the space below the trigger.
Try it — watch the fix disappear back into the bug
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
You didn't do anything wrong. overflow: hidden clips any descendant, and a hand-built popup is, structurally, just a descendant. The standard escape hatch is position: fixed plus getBoundingClientRect() to glue the popup under the trigger by hand — and now you're also re-running that math on every scroll and resize event, and remembering to tear the listeners down when the select unmounts. None of this is exotic. It's just work the native <select> used to do for you, that you signed back up for the moment you needed one icon.
And the part that doesn't show up in a demo video: keyboard behavior. Type-ahead (press "j" and jump to "Japan"), Home/End, wraparound at the list's edges, closing on Escape and returning focus to the trigger, announcing the right state to a screen reader as options are highlighted — a real <select> gets every one of those for free, in every browser, on every OS. The WAI-ARIA Authoring Practices listbox pattern exists as a reference implementation precisely because getting all of that right by hand, consistently, is genuinely hard. Most homegrown dropdowns implement a fraction of it and call it done.
What actually changed
<select> now has an opt-in that unlocks styling without giving up any of that native behavior:
select {
appearance: base-select;
}
/* the popup is now a real, styleable target */
select::picker(select) {
border-radius: 12px;
border: 1px solid #2a2f3a;
}
Two things happen the moment you set appearance: base-select. First, <option> stops being text-only — it can hold real markup, so the flag icon goes right back where you originally wanted it:
<select>
<button>
<selectedcontent></selectedcontent>
</button>
<option value="ca"><img src="/flags/ca.svg" alt="" /> Canada</option>
<option value="jp"><img src="/flags/jp.svg" alt="" /> Japan</option>
<option value="br"><img src="/flags/br.svg" alt="" /> Brazil</option>
</select>
That <selectedcontent> element is new too — it mirrors whatever content the chosen <option> holds into the closed button, so the flag shows up on the trigger, not just inside the open popup.
Second, and this is the part that actually kills the clipping bug from the wrong-way section: ::picker(select) doesn't render as a descendant <div> at all. It's promoted to the top layer — the same rendering layer <dialog> and the Popover API use — so it paints above the entire page, unclipped by any ancestor's overflow: hidden, with no z-index or position: fixed math required. The exact bug in the playground above simply can't happen to it, because it was never inside the clipping container to begin with.
The honest caveat
This is a real, working CSS feature — not a proposal — but support isn't universal yet. It shipped first in Chromium browsers; Firefox and Safari have it in progress at various stages. Check caniuse before you rely on it, and reach for it as progressive enhancement: wrap the opt-in behind @supports (appearance: base-select) and let browsers without support fall back to a plain <select>, which still works, still submits, still has icon-free but fully accessible options. You lose the flags, not the form.
@supports (appearance: base-select) {
select { appearance: base-select; }
}
That's the whole migration. No polyfill, no JS fallback path to maintain — the feature detection is the fallback.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The payoff
The custom <div role="listbox"> component your team maintains — the one with the scroll listener and the getBoundingClientRect() call and the open GitHub issue about it not closing right on iOS — was never really a design requirement. It was a workaround for <option> being text-only and the popup being an ordinary, clippable <div>. Both of those are gone now, behind one CSS property and one new element.
Are you still shipping a hand-rolled dropdown for something this small, or are you already behind @supports on the real thing? I'd genuinely like to know which.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)