unscripted/ui

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 with transition-discrete (transition-behavior: allow-discrete) + starting:, rendered in the top layer.
  • ::picker-icon is the chevron (rotates when open); ::checkmark marks 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-height equal to the trigger’s own height. The panel’s default position sits just under the trigger, so nudging it up by --checked-index × --option-height pulls 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, on nth-of-type to skip the <button>. Keep it DRY by having each rule set a bare --checked-index and 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 only opacity. Because the panel’s top is recalculated from whichever option is checked, keeping the picker in the top layer through a discrete display exit 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.

FeatureChromeEdgeFirefoxSafariWhen missing
Customizable select (appearance: base-select)Limited availability13513527Falls back to the classic native select — fully functional and accessible, just not custom-styled.
Entry/exit animations (allow-discrete + @starting-style)Baseline 2024 · newly available11711712917.5Elements 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.