Skip to main content

Harshal V. LADHE

Styling a <select> Dropdown with CSS appearance: base-select

Style a real select's dropdown, options and arrow.
Published at:
Last updated:
Estimated reading time:8 min read

Introduction

For as long as the web has had forms, <select> has been the control nobody could style. You could change the closed box a little — its border, background and font — but the moment it opened, the list of options was drawn by the operating system, in the operating system's font, with no room for an icon, a colour swatch or a second line of text.

So teams rebuilt it. A <div> with a click handler, a hidden list, a pile of ARIA attributes and keyboard handling that almost never matched the real thing: type-ahead, Home and End, closing on Esc, form submission, screen reader announcements and mobile behaviour all had to be recreated by hand.

appearance: base-select removes the reason to rebuild it. It opts a real <select> into a new, fully styleable mode: the closed button, the dropdown, the arrow, the options and the checkmark all become ordinary boxes you can style — and the options can hold real HTML. Everything the browser already got right stays exactly as it was.

The Opt-in

select,
::picker(select) {
  appearance: base-select;
}

Two selectors, because there are two parts to opt in. select is the closed box you click; ::picker(select) is the dropdown that opens from it. Each can be opted in on its own, but you'll almost always want both.

With that one rule, the select drops its operating-system look for a plain, unopinionated base style — a bordered box, a list of options and a small arrow — that's ready to be styled like any other element.

Rich Content in Options

In a customisable select, an <option> can contain more than text. Images, icons and extra <span>s are kept and rendered:

<select>
  <option value="indigo">
    <span class="swatch" style="--swatch: #818cf8"></span>
    Indigo <small>#818cf8</small>
  </option>
  <option value="teal">
    <span class="swatch" style="--swatch: #2dd4bf"></span>
    Teal <small>#2dd4bf</small>
  </option>
</select>

This needed a change to the HTML parser as well as to CSS. Until now, the parser threw away any tags inside an <option> and kept only the text. Browsers that support customisable select now keep those elements in the DOM.

Two rules keep it sensible. Content inside an option is for display: no interactive elements such as links, buttons or inputs. And the option's text still matters most, because it's what screen readers announce, what type-ahead matches against, and what older browsers show on their own.

The Anatomy

Every part of a customisable select can be targeted:

SelectorWhat it styles
selectThe closed box you click — the select's button
select > buttonAn optional custom button, replacing the default contents of the box
selectedcontentA live copy of the selected option, shown inside that button
::picker-iconThe arrow in the closed box
::picker(select)The dropdown that holds the options
optionEach option row
option::checkmarkThe tick shown beside the selected option
select:openThe select while its picker is open
option:checkedThe currently selected option

A typical set of styles touches most of them:

select::picker-icon {
  transition: rotate 0.2s ease;
}

select:open::picker-icon {
  rotate: 180deg;
}

::picker(select) {
  padding: 0.25rem;
  border: 1px solid #2a2f45;
  box-shadow: 0 1rem 2rem -1rem rgb(0 0 0 / 0.6);
}

option {
  display: flex;
  align-items: center;
  gap: 0.625rem;
}

option:checked {
  background: rgb(129 140 248 / 0.3);
}

option::checkmark {
  order: 1; /* move the tick to the end of the row */
  margin-inline-start: auto;
}

Options are ordinary boxes, so display: flex lays out the swatch, label and checkmark in a row, and order moves the checkmark to the end without touching the HTML.

Showing the Selected Option with <selectedcontent>

By default, the closed select shows the selected option's text. To show the swatch or icon as well, give the select a <button> as its first child, with a <selectedcontent> element inside:

<select>
  <button>
    <selectedcontent></selectedcontent>
  </button>
  <option value="indigo">…</option>
  <option value="teal">…</option>
</select>

Whenever the selection changes, the browser copies the chosen option's content into <selectedcontent>. Because it's a copy, it can be styled differently from the option it came from — showing the swatch and name but hiding the hex code, for example:

selectedcontent {
  display: flex;
  align-items: center;
  gap: 0.625rem;
}

selectedcontent small {
  display: none;
}

Positioning and Animating the Picker

The picker is drawn in the top layer — the same layer as popovers and modal dialogs — so it's never clipped by an overflow: hidden parent and never needs a z-index. It's placed with anchor positioning, tied to the select: by default it opens below, at least as wide as the select, and flips above when there isn't room underneath.

Since it's anchor-positioned, position-area can move it. To open the picker to the right of the select, for example:

::picker(select) {
  position-area: span-bottom right;
}

Opening and closing can be animated with the same technique used for popovers and dialogs. Transition display and overlay with allow-discrete, so the picker stays visible until its fade-out ends, and use @starting-style to give the fade-in a starting point (see why display needs allow-discrete):

::picker(select) {
  transition:
    opacity 0.2s ease,
    translate 0.2s ease,
    display 0.2s allow-discrete,
    overlay 0.2s allow-discrete;
}

select:not(:open)::picker(select) {
  opacity: 0;
  translate: 0 -0.5rem;
}

@starting-style {
  select:open::picker(select) {
    opacity: 0;
    translate: 0 -0.5rem;
  }
}

For visitors who've asked their system for reduced motion, keep the fade but drop the slide and the spinning arrow. The picker still clearly opens and closes, just without movement:

@media (prefers-reduced-motion: reduce) {
  select::picker-icon {
    transition: none;
  }

  select:not(:open)::picker(select) {
    translate: none;
  }

  @starting-style {
    select:open::picker(select) {
      translate: none;
    }
  }
}

Try It Live

The sandbox has one <select> whose options hold a colour swatch, a name and a hex code. Open it with the mouse, then try it from the keyboard: Tab to it, press Space, use the arrow keys, type a letter to jump to an option, and press Esc to close. Untick appearance: base-select to see the same markup as a classic select, and untick Animate to compare the picker with and without its transition. The accent preview beside the select takes on whichever colour you choose, and the readout shows the selected value and the CSS behind the current state.

Customisable select Playground

A real <select> whose options hold colour swatches, styled with appearance: base-select, ::picker(select), ::picker-icon, ::checkmark and <selectedcontent>, with toggles to compare it against the classic select and to animate the picker open and closed.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Customisable select Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>Styling a Real select with appearance: base-select</h1>
        <p>One native <code>&lt;select&gt;</code> with colour swatches in its options. Open it with the mouse or the keyboard, then switch <code>base-select</code> off to compare it with the select browsers have drawn until now. The badge beside it shows the chosen colour as an accent.</p>
      </header>

      <p class="support-note" id="support-note" hidden>This browser doesn't support <code>appearance: base-select</code> yet, so you'll see a regular select with plain-text options — the fallback every visitor without support gets.</p>

      <fieldset class="control-group">
        <legend>Options</legend>
        <label class="checkbox-field">
          <input type="checkbox" id="custom-toggle" checked>
          <code>appearance: base-select</code> on the select and its picker
        </label>
        <label class="checkbox-field">
          <input type="checkbox" id="animate-toggle" checked>
          Animate the picker open and closed
        </label>
      </fieldset>

      <section class="demo-area" aria-label="Customisable select">
        <div class="stage">
          <label class="select-label" for="colour-select">Accent colour</label>
          <select class="colour-select is-custom is-animated" id="colour-select">
            <button type="button"><selectedcontent></selectedcontent></button>
            <option value="indigo" selected><span class="swatch" style="--swatch: #818cf8"></span><span class="option-text">Indigo <small>#818cf8</small></span></option>
            <option value="teal"><span class="swatch" style="--swatch: #2dd4bf"></span><span class="option-text">Teal <small>#2dd4bf</small></span></option>
            <option value="amber"><span class="swatch" style="--swatch: #fbbf24"></span><span class="option-text">Amber <small>#fbbf24</small></span></option>
            <option value="rose"><span class="swatch" style="--swatch: #fb7185"></span><span class="option-text">Rose <small>#fb7185</small></span></option>
            <option value="lime"><span class="swatch" style="--swatch: #a3e635"></span><span class="option-text">Lime <small>#a3e635</small></span></option>
          </select>

          <span class="accent-preview" id="accent-preview">Accent preview</span>
        </div>
      </section>

      <output class="readout" id="readout" aria-live="polite"></output>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln –, Col –HTML2.7 KBUTF-8

Starting sandbox…

No console output yet.

What Stays the Same

base-select changes how the select looks, not how it behaves. It's still a real <select>, so:

  • Its value is submitted with the form, and select.value and the change event work as before
  • Keyboard support is built in: arrow keys, type-ahead, Home, End, Enter and Esc
  • Screen readers announce it as a combobox with a list of options, using each option's text
  • <label for>, required, disabled and :invalid all behave as they always have
  • Light dismiss works: clicking outside the picker closes it

That's the real gain over a hand-built dropdown. You only write CSS; the behaviour you'd otherwise have to rebuild is already there and already correct.

Browser Support and Fallbacks

Customisable select shipped in Chromium browsers (Chrome and Edge 135) in 2025. Firefox and Safari are working on it but haven't shipped it at the time of writing.

The fallback is built in. A browser that doesn't recognise base-select ignores the declaration and draws an ordinary select. Its parser drops the swatch <span>s and the extra <button>, and each option shows its text. The control still works perfectly; it just looks like a regular select.

That's why the option's text should be able to stand on its own. "Indigo" is a complete label without its swatch; an option containing nothing but an icon would be blank in every browser without support.

Common Pitfalls

1. Opting In Only the Select

appearance: base-select on select alone customises the closed box, but the picker keeps its native look. Put it on ::picker(select) as well for a fully custom dropdown.

2. Options with No Text

An option that only contains an image or icon has no accessible name, nothing for type-ahead to match and nothing to show in a fallback. Always include text, and hide it visually only if the design truly needs it.

3. Putting Interactive Content in Options

Links, buttons and inputs inside an <option> aren't allowed — the option itself is the interactive element. Keep option content to images, icons and text.

4. Reaching for a Custom Dropdown Anyway

If a design calls for a searchable list, multiple tags or free-text entry, that's a different control — a combobox — and <select> isn't the right base for it. But for "pick one from a list", which is most dropdowns, a customisable <select> now covers the designs that used to justify a rebuild.

Key Takeaway

appearance: base-select on select and ::picker(select) turns the least styleable form control into one of the most: options with real HTML, a <selectedcontent> copy in the closed box, and pseudo-elements for the picker, arrow and checkmark. It keeps the native select's keyboard support, accessibility and form behaviour, and falls back to a working plain select everywhere else. For brand-coloured checkboxes, radios and sliders to sit beside it, see Theming Native Controls with accent-color.

Changelog

  • — Added cross-references to posts 'Mastering CSS z-index: Stacking Contexts, isolation, and Layering Bugs' and 'An Interactive Guide to CSS Transitions'
  • — Initial publication.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • Theming Native Controls with accent-color

    Theme native checkboxes, radios and sliders with one property.
    The accent-color property themes the checked and filled parts of native checkboxes, radio buttons, switches, range sliders and progress bars — including their indeterminate states — while keeping real browser controls with full native behaviour and accessibility, plus how it pairs with color-scheme and what it leaves alone.
    Published at:
  • HTML Attributes as Real CSS Values with Typed attr()

    Read HTML attributes as CSS colours, lengths and numbers.
    Typed attr() reads an HTML attribute as a real CSS value — type(<color>), type(<integer>), a unit like % or px, or raw-string — for any property on any element, not just text in content. Covers fallbacks for missing or invalid values, patterns for layout, transitions and view transitions, safe fallbacks in browsers without support, and when a custom property is the better choice.
    Published at:
  • Squircles, Bevels and Scoops with corner-shape

    Shape corners into squircles, bevels, scoops and notches.
    The corner-shape property gives border-radius corners a shape — round, squircle, square, bevel, scoop, notch or any superellipse() — with borders, shadows and outlines that follow it, per-corner values, smooth animation, and an automatic rounded fallback in browsers without support.
    Published at: