Component
select
Demo
tokens.css,
base.css and select.css. No scripts.
Source
Copy both files, or run npx nojsui add select.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| <select> | widely | 1 | 12 | 1 | 1 | 18 | 4 | 1 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Customizable <select> | limited | 135 | 135 | — | — | 135 | — | — |
| accent-color | limited | 93 | 93 | 92 | 26.2 | — | 92 | 26.2 |
Usage
An ordinary <select> with ordinary <option>s. Engines that support
appearance: base-select render your button and picker; the rest render the
platform’s own control with the same options.
<div class="sk-select">
<label class="sk-select__label" for="plan">Plan</label>
<select class="sk-select__control" id="plan" name="plan">
<button class="sk-select__button">
<selectedcontent class="sk-select__selected"></selectedcontent>
</button>
<option value="pro" label="Pro — for teams" aria-label="Pro — for teams">
<span class="sk-select__name">Pro</span>
<span class="sk-select__desc">For teams</span>
</option>
</select>
</div>
Rich options need the text three times
This looks redundant. It is not, and each copy earns its place — measured in Chromium, WebKit and Gecko:
| Where | Who uses it |
|---|---|
Inside the <option> | The visual rendering, wherever base-select applies |
label="…" | What the native picker displays in engines without base-select. Without it, Gecko shows the child elements’ text run together — "ProFor teams" |
aria-label="…" | The accessible name. Chromium computes no name at all for an <option> that has element children; a plain <option> is named fine, one with a <span> inside is not |
With all three, every engine names the option identically. Drop aria-label and
Chromium users hear an unnamed option; drop label and Gecko users see mashed
text.
If you do not need rich options, use plain text options and none of this
applies — you can delete the <button>, the <selectedcontent> and the extra
attributes.
html-validate normally rejects aria-label on <option>, which is good
general advice and wrong here; the repo’s config allows it on namable elements
for this reason.
How it works
Native semantics are never re-implemented. Arrow keys, type-ahead, Enter,
Esc, form participation, required, and the platform picker on touch devices
all come from <select> itself. This component contributes presentation only —
which is what keeps the fallback complete instead of degraded.
The enhanced markup is inert where unsupported. A <button> inside a
<select> is not in the legacy content model, so the question is whether it
breaks anything. Measured in all three engines: options still parse, the control
still renders and is still operable by script and by user. Engines without
base-select simply do not render the author button.
The picker is in the top layer, so unlike the tooltip it is never clipped by
an ancestor’s overflow: hidden.
The author button is aria-hidden and tabindex="-1". Where base-select
applies, that button is the closed control’s rendering — it is not separately
focusable (verified), and the value it displays is already exposed by the
<select>. Leaving it exposed makes it a second, competing target: axe reports
the select as “partially obscured”, because Chromium renders the UA picker
indicator beside the author button and only an 18.5px sliver of the select is
left uncovered. Hiding the button resolves that and is the accurate description
of what it is.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. select.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-select-accent | --sk-color-accent | accent-color on the native control, and the picker’s checkmark |
--sk-select-bg | --sk-color-surface | Control background |
--sk-select-bg-hover | --sk-color-surface-raised | Control background on hover |
--sk-select-fg | --sk-color-text | Control and option text |
--sk-select-border-color | --sk-color-border-strong | Control border |
--sk-select-radius | --sk-radius-md | Control corner radius |
--sk-select-label-fg | --sk-color-text | Label text |
--sk-select-picker-bg | --sk-color-surface-raised | Picker background |
--sk-select-picker-border-color | --sk-color-border | Picker border |
--sk-select-picker-radius | --sk-radius-md | Picker corner radius |
--sk-select-picker-shadow | --sk-shadow-lg | Picker elevation |
--sk-select-option-radius | --sk-radius-sm | Option corner radius |
--sk-select-option-bg-hover | --sk-color-accent-subtle | Option background on hover or focus |
--sk-select-desc-fg | --sk-color-text-muted | Secondary line inside a rich option |
:root { --sk-select-accent: teal; }
The --sk-select-picker-* and --sk-select-option-* knobs only have an effect
where the engine renders the author’s picker (appearance: base-select).
Elsewhere the platform draws the popup and --sk-select-accent is the one knob
that reaches it, through accent-color — which is the whole point of setting
it there as well.
Keyboard contract
Entirely native. Nothing here changes it.
| Key | Behaviour |
|---|---|
Tab | Moves to the control |
Space / Enter / Alt+Down | Opens the picker |
Up / Down | Moves through options |
| Typing | Type-ahead jumps to a matching option |
Enter | Commits the highlighted option |
Esc | Closes without changing the value |
Exact key bindings vary by platform — that is the platform’s business, and inheriting it is the point.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
- The label is a real
<label for>. Nothing here should be labelled by placeholder text or an adjacent<div>. - The description is decoration, not information. It repeats into the
accessible name via
aria-label; do not put anything in.sk-select__descthat is not also in that name. - The closed button shows only the option name, not its description, so the trigger does not grow to fit prose. The full text is still the accessible name.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
| Customizable select | limited | The platform’s own <select> and picker, themed with accent-color, borders and radius. Same options, same keyboard behaviour, same form data — only the picker’s appearance is the UA’s. |
accent-color | limited | The UA’s default highlight colour in the native picker. |
The support data is behind the engines here. web-features 3.35.0 — the
latest release at the time of writing — records customizable-select as
Chromium-only (Chrome/Edge 135). Measured directly, WebKit supports it too:
appearance: base-select computes, ::picker(select) and ::checkmark both
parse as selectors, and HTMLSelectedContentElement is defined. Gecko does not,
and takes the fallback exactly as intended.
The badge above reflects support.json, because ADR 0004 makes the data the
single source of truth and hand-editing it would be worse than being briefly
out of date. This note is here so the discrepancy is visible rather than
silently wrong, and it is worth reporting upstream.