Select
The real <select>, opted into full styling — rich HTML in options, an animated picker panel, and a mirrored selection. Falls back to the classic native select everywhere else.
base-selectexit animations
How it works
[appearance:base-select]on both the<select>and its::picker(select)pseudo-element opts into the new customizable select rendering.- Options can contain real HTML (the colored status dots).
<selectedcontent>mirrors the chosen option’s content into the closed button. ::picker(select)is the dropdown panel: styled, animated withtransition-discrete(transition-behavior: allow-discrete) +starting:, rendered in the top layer.::picker-iconis the chevron (rotates when open);::checkmarkmarks the selected option.
Align the selection with the trigger
The classic OS popup menu move: instead of dropping below, the picker opens over the trigger with your current choice already sitting under the cursor. It’s a tiny bit of Fitts’s-law generosity. The option you’re most likely to want is exactly where your pointer already is.
- Undo the UA placement:
position-area: auto,align-self: auto,position-try-fallbacks: none, so the picker is a plain top-layer box we position by hand instead of an anchored dropdown. - Give every
<option>a fixed--option-heightequal to the trigger’s own height. The panel’s default position sits just under the trigger, so nudging it up by--checked-index × --option-heightpulls the selected row right onto the trigger. - The catch: the picker can’t read which option is checked (
sibling-index()won’t help: it only reports an element’s own slot, never a descendant’s). So it stays a:has()chain, one rule per option, onnth-of-typeto skip the<button>. Keep it DRY by having each rule set a bare--checked-indexand doing the math once:top: calc(var(--option-height) * -1 * var(--checked-index, 0)). - Drop the
transition-discrete(allow-discrete) that the basic select uses, and transition onlyopacity. Because the panel’stopis recalculated from whichever option is checked, keeping the picker in the top layer through a discretedisplayexit lets it jump to the new selection’s position mid-fade. Letting it close immediately gives a cleaner animate-out here.
The fallback is the feature
In browsers without base-select, this renders as the classic native select. Ugly options,
maybe, but fully functional, accessible, and familiar. That’s a much better floor than a
JS-powered listbox that breaks without hydration.
Browser support
Minimum stable version per engine, resolved at build time from MDN's browser-compat-data; Baseline status from the official web-features dataset. Everything degrades gracefully — the “when missing” column is the actual behavior, not a broken page.
| Feature | Chrome | Edge | Firefox | Safari | When missing |
|---|---|---|---|---|---|
| Customizable select (appearance: base-select)Limited availability | 135 | 135 | ✕ | 27 | Falls back to the classic native select — fully functional and accessible, just not custom-styled. |
| Entry/exit animations (allow-discrete + @starting-style)†Baseline 2024 · newly available | 117 | 117 | 129 | 17.5 | Elements appear and disappear instantly. Nothing breaks — you just lose the animation. |
† @supports cannot test at-rules, so the badge checks transition-behavior: allow-discrete, which co-shipped with @starting-style in every engine.