Theme
On this page (8)
Component Skill — components/select/component-skill.md

Native basis

<select> element with custom styling via appearance: none.

Web Platform APIs

<select><optgroup>

Classes

.select

Sizes (data-size)

xsHeight: 1.75remsmHeight: 2remmdHeight: 2.25remlgHeight: 2.75remxlHeight: 3.25rem

Accessibility

• Native <select> provides full keyboard navigation (arrow keys, type-ahead).

• Use <label> with for for description.

• The placeholder is a <option value="" selected>withoutdisabled - a disabled placeholder cannot be re-selected, so a made selection could never be cleared (issue #32). Keep it selectable and mark the field required if empty must not submit; the control reads as a placeholder while the empty option is selected (muted text via :has(option:checked)).

Notes

• Uses appearance: none with a custom chevron via background-image SVG.

• The dropdown list is rendered by the browser - it cannot be styled.

• For a fully custom dropdown, use the Combobox component instead.

• While the empty option is selected the closed control renders muted (:has(> option[value=""]:checked)); choosing a value restores the foreground color.

§Default

Native select with a clearable placeholder - re-choose 'Select a fruit' to empty it again.

§With groups

§Multiple

The native multi-select: add multiple (and size='N' for the visible rows) and the browser shows a list box - Ctrl/⌘-click adds or removes an option, Shift-click or Shift+arrows pick a run, and the form submits every chosen value under the same name. Best for a short, fixed list on desktop; for tags, people or a long searchable list use the Combobox multi-select, for a handful of options checkboxes.

Several choices? Pick the control by the list

  • A handful of options, all worth seeing (2-7): a group of checkboxes - every choice visible, one click each.
  • A short fixed list, mostly desktop: <select multiple> (above) - zero JavaScript, native keyboard and form behaviour; the Ctrl/⌘-click gesture needs a hint.
  • Tags, people, countries - long or searchable lists: the Combobox multi-select - type to filter, checkboxes in the list, removable tags, one hidden input per value.

§Unavailable options

disabled on an <option> greys out ONE choice in an otherwise usable list - a size that's out of stock, a plan you're not eligible for. It can't be chosen by click or keyboard, and the browser skips it. Say why in the option text ('out of stock'), so the state isn't carried by grey alone. <optgroup disabled> switches off a whole group. (The empty 'Select …' placeholder is different: it stays enabled so it can clear the choice.)

§Disabled

disabled on the <select> itself switches off the whole control - nothing can be chosen and it leaves the tab order. For one unavailable choice in a usable list, disable the <option> instead (above).

§Sizes

The full five-step scale via data-size - the unlabeled default IS md (2.25rem).

§States

Machine contract - verified against select.schema.json by bun run verify:

StateTypeValuesDefaultDescription
valuestring—"apple"Selected option (select.value), changed by panel or by picking.
disabledbooleantrue, falsefalseInteraction off (native disabled attribute).
requiredbooleantrue, falsefalsePost-validation marks the field invalid (:user-invalid).

§CSS view file

/* -- Select component ------------------------------------------- */
@layer components {
  .select {
    appearance: none;
    /* the field standard: unsized = md step (see .input) */
    height: 2.25rem;
    width: 100%;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    padding: 0 2rem 0 0.75rem;
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;
    box-shadow: var(--shadow-xs);
    cursor: pointer;
    transition: border-color 150ms, box-shadow 150ms;
    /* Custom chevron */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.5rem center;
    background-size: 1rem;
    /* Placeholder = the empty-value option. It stays selectable (no
       `disabled`) so it doubles as the clear/reset entry - choosing it
       again empties the box (issue #32). While it is the selection the
       closed control reads as a placeholder; a real value restores
       --foreground because the base rule's color wins over this one.
       option:checked matches the *selected* option in every engine. */
    &:has(> option[value=""]:checked) {
      color: var(--muted-foreground);
    }
    &:focus {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    /* Disabled = legible but inert: a muted surface and muted text inside
       the full-strength border - never an opacity fade that lets the box
       dissolve into the page. */
    &:disabled {
      background-color: var(--muted);
      color: var(--muted-foreground);
      box-shadow: none;
      cursor: not-allowed;
      opacity: 1; /* Chrome's UA sheet fades select:disabled to 0.7 on its own */
    }
    &:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) {
      border-color: var(--destructive);
      &:focus {
        border-color: var(--destructive);
        box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2);
      }
    }
    /* -- Multiple (<select multiple>) ---------------------------
       The browser renders a list box instead of a dropdown: every option is
       visible (size="N" rows), Ctrl/⌘-click or Shift-click / Shift+arrows
       pick several, and the form submits every chosen value under the same
       name. No fixed height, no chevron; chosen options get a tinted row. */
    &[multiple] {
      height: auto;
      padding: 0.25rem;
      background-image: none;
      overflow-y: auto;
      & option {
        padding: 0.375rem 0.5rem;
        border-radius: calc(var(--radius-md) - 2px);
        color: var(--foreground);
      }
      & option:checked {
        /* a gradient beats the UA's opaque selection paint in every engine */
        background: linear-gradient(color-mix(in oklch, var(--primary) 14%, transparent), color-mix(in oklch, var(--primary) 14%, transparent));
        font-weight: 500;
      }
      /* one unavailable choice in a usable list */
      & option:disabled {
        color: var(--muted-foreground);
        cursor: not-allowed;
      }
      & optgroup {
        font-size: 0.75rem;
        font-weight: 600;
        color: var(--muted-foreground);
      }
    }
    /* -- Sizes ----------------------------------------------- */
    &[data-size="xs"] { height: 1.75rem; padding-left: 0.5rem;   font-size: 0.75rem; }
    &[data-size="sm"] { height: 2rem;    padding-left: 0.625rem; font-size: 0.8125rem; }
    &[data-size="md"] { height: 2.25rem; padding-left: 0.75rem;  font-size: 0.875rem; }
    &[data-size="lg"] { height: 2.75rem; padding-left: 1rem;     font-size: 1rem; }
    &[data-size="xl"] { height: 3.25rem; padding-left: 1.25rem;  font-size: 1.125rem; }
    &[multiple][data-size] { height: auto; }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .select,
    .select *,
    .select::before,
    .select::after,
    .select *::before,
    .select *::after,
    .select::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub