Skip to content

Component

select

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and select.css. No scripts.

Source

Copy both files, or run npx nojsui add select.

Source for select
<!-- .sk-select — a customizable <select>.

     The markup is a real <select> with real <option>s, so the fallback is the
     native control with native keyboard behaviour, not a rebuild.

     Each rich option carries the same text three times, on purpose:
       - inside      the visual presentation, used where base-select renders
       - label=""    what the *native* picker shows in engines without it
       - aria-label  the accessible name; without it Chromium computes none at
                     all for an option that has element children (measured)
     The README explains why all three are needed. -->
<div class="sk-select">
  <label class="sk-select__label" for="sk-select-plan">Plan</label>

  <select class="sk-select__control" id="sk-select-plan" name="plan">
    <!-- Presentational. Where base-select applies this button *is* the closed
         control's rendering; it is not separately focusable (verified) and the
         accessible value comes from the <select>. aria-hidden keeps assistive
         tech and axe from treating it as a second, competing target. -->
    <button class="sk-select__button" aria-hidden="true" tabindex="-1">
      <selectedcontent class="sk-select__selected"></selectedcontent>
    </button>

    <option
      value="free"
      label="Free — one project"
      aria-label="Free — one project"
      selected
    >
      <span class="sk-select__name">Free</span>
      <span class="sk-select__desc">One project</span>
    </option>

    <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>

    <option
      value="enterprise"
      label="Enterprise — SSO and audit logs"
      aria-label="Enterprise — SSO and audit logs"
    >
      <span class="sk-select__name">Enterprise</span>
      <span class="sk-select__desc">SSO and audit logs</span>
    </option>
  </select>
</div>
/* .sk-select — a <select> that keeps native semantics and gains a styleable
   picker where the engine supports one.

   The element is an ordinary <select> with ordinary <option>s. Arrow keys,
   type-ahead, Enter, Esc, form participation and mobile's native picker all
   come from that and are never re-implemented. Everything below is presentation.

   Specs:
   - customizable select .... https://drafts.csswg.org/css-forms-1/#appearance-base-select
   - ::picker(select) ....... https://drafts.csswg.org/css-forms-1/#picker-pseudo
   - ::checkmark ............ https://drafts.csswg.org/css-forms-1/#checkmark-pseudo
   - <selectedcontent> ...... https://html.spec.whatwg.org/multipage/form-elements.html#the-selectedcontent-element
   - accent-color ........... https://drafts.csswg.org/css-ui-4/#widget-accent */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. ::picker(select) is a pseudo-element of
   the control, so it inherits these like any descendant would. */
.sk-select {
  --_accent: var(--sk-select-accent, var(--sk-color-accent));
  --_bg: var(--sk-select-bg, var(--sk-color-surface));
  --_bg-hover: var(--sk-select-bg-hover, var(--sk-color-surface-raised));
  --_fg: var(--sk-select-fg, var(--sk-color-text));
  --_border: var(--sk-select-border-color, var(--sk-color-border-strong));
  --_radius: var(--sk-select-radius, var(--sk-radius-md));
  --_label-fg: var(--sk-select-label-fg, var(--sk-color-text));
  --_picker-bg: var(--sk-select-picker-bg, var(--sk-color-surface-raised));
  --_picker-border: var(--sk-select-picker-border-color, var(--sk-color-border));
  --_picker-radius: var(--sk-select-picker-radius, var(--sk-radius-md));
  --_picker-shadow: var(--sk-select-picker-shadow, var(--sk-shadow-lg));
  --_option-radius: var(--sk-select-option-radius, var(--sk-radius-sm));
  --_option-bg-hover: var(--sk-select-option-bg-hover, var(--sk-color-accent-subtle));
  --_desc-fg: var(--sk-select-desc-fg, var(--sk-color-text-muted));

  display: flex;
  flex-direction: column;
  gap: var(--sk-space-2xs);
}

.sk-select__label {
  color: var(--_label-fg);
  font-size: var(--sk-text-sm);
  font-weight: var(--sk-weight-medium);
}

/* ---------------------------------------------------------------------------
   Fallback: the native control, themed as far as engines allow.
   This is what Firefox gets, and it is a complete experience — the same
   options, the same keyboard behaviour, the platform's own picker.
--------------------------------------------------------------------------- */

.sk-select__control {
  accent-color: var(--_accent);
  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  font: inherit;
  min-block-size: var(--sk-size-tap-target);
}

/* The enhanced markup is inert in engines without base-select: the author
   <button> is not rendered, and rich option content collapses to the option's
   label="" text. Hiding it explicitly keeps that predictable rather than
   relying on each UA's default. */
.sk-select__button,
.sk-select__desc {
  display: none;
}

/* ---------------------------------------------------------------------------
   Enhanced: appearance: base-select
   Chromium and WebKit render the author's own button and picker here.
--------------------------------------------------------------------------- */

@supports (appearance: base-select) {
  .sk-select__control {
    appearance: base-select;
    border: 0;
    background: none;
    padding: 0;
  }

  .sk-select__button {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--sk-space-sm);
    inline-size: 100%;
    border: var(--sk-border-width) solid var(--_border);
    border-radius: var(--_radius);
    background: var(--_bg);
    color: var(--_fg);
    padding-block: var(--sk-space-xs);
    padding-inline: var(--sk-space-sm);
    min-block-size: var(--sk-size-tap-target);
    text-align: start;
  }

  .sk-select__button:hover {
    background: var(--_bg-hover);
  }

  /* The picker is the popup itself. It is painted in the top layer, so it is
     never clipped by an ancestor's overflow. */
  .sk-select__control::picker(select) {
    border: var(--sk-border-width) solid var(--_picker-border);
    border-radius: var(--_picker-radius);
    background: var(--_picker-bg);
    padding-block: var(--sk-space-2xs);
    padding-inline: var(--sk-space-2xs);
    box-shadow: var(--_picker-shadow);
  }

  .sk-select__control option {
    display: flex;
    align-items: baseline;
    gap: var(--sk-space-xs);
    border-radius: var(--_option-radius);
    padding-block: var(--sk-space-2xs);
    padding-inline: var(--sk-space-xs);
    color: var(--_fg);
  }

  .sk-select__control option:hover,
  .sk-select__control option:focus {
    background: var(--_option-bg-hover);
  }

  .sk-select__control option:checked {
    font-weight: var(--sk-weight-medium);
  }

  /* The tick the UA draws for the selected option. */
  .sk-select__control option::checkmark {
    color: var(--_accent);
    order: 1;
    margin-inline-start: auto;
  }

  .sk-select__name {
    color: inherit;
  }

  .sk-select__desc {
    display: inline;
    color: var(--_desc-fg);
    font-size: var(--sk-text-xs);
  }

  /* The closed button shows only the option's name, not its description —
     otherwise the trigger grows to fit prose it does not need to repeat. */
  .sk-select__selected .sk-select__desc {
    display: none;
  }

  @media (prefers-reduced-motion: no-preference) {
    .sk-select__control::picker(select) {
      opacity: 0;
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }

    .sk-select__control:open::picker(select) {
      opacity: 1;
    }

    @starting-style {
      .sk-select__control:open::picker(select) {
        opacity: 0;
      }
    }
  }
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
<select>widely112111841
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Customizable <select>limited135135135
accent-colorlimited93939226.29226.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:

WhereWho 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).

PropertyDefaultControls
--sk-select-accent--sk-color-accentaccent-color on the native control, and the picker’s checkmark
--sk-select-bg--sk-color-surfaceControl background
--sk-select-bg-hover--sk-color-surface-raisedControl background on hover
--sk-select-fg--sk-color-textControl and option text
--sk-select-border-color--sk-color-border-strongControl border
--sk-select-radius--sk-radius-mdControl corner radius
--sk-select-label-fg--sk-color-textLabel text
--sk-select-picker-bg--sk-color-surface-raisedPicker background
--sk-select-picker-border-color--sk-color-borderPicker border
--sk-select-picker-radius--sk-radius-mdPicker corner radius
--sk-select-picker-shadow--sk-shadow-lgPicker elevation
--sk-select-option-radius--sk-radius-smOption corner radius
--sk-select-option-bg-hover--sk-color-accent-subtleOption background on hover or focus
--sk-select-desc-fg--sk-color-text-mutedSecondary 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.

KeyBehaviour
TabMoves to the control
Space / Enter / Alt+DownOpens the picker
Up / DownMoves through options
TypingType-ahead jumps to a matching option
EnterCommits the highlighted option
EscCloses 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__desc that 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 usedBaseline statusBehavior without it
Customizable selectlimitedThe 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-colorlimitedThe 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.