DEV Community

Cover image for Styling dropdown menus in CSS got a whole lot better!
Artclick
Artclick

Posted on

Styling dropdown menus in CSS got a whole lot better!

For as long as most of us have been writing CSS, the <select> element has been the one form control everyone quietly gave up on. You could change its border, maybe its font, and then you hit a wall. The dropdown panel itself, the arrow icon, the checkmark next to the selected item: all of that lived deep in the browser's operating system layer, completely out of CSS's reach. If you wanted a dropdown that actually matched your design system, the "solution" was to reach for a JavaScript library like Select2 or Choices.js, throw away the real <select>, and rebuild the whole thing out of divs, complete with your own keyboard navigation, focus trapping, and screen reader support.

That's finally changing. A new CSS property called appearance: base-select lets you keep the real, native <select> element (with all its built-in accessibility and keyboard behavior) while styling every single part of it: the button, the dropdown popup, the individual options, the arrow icon, and the selected checkmark. No JavaScript. No rebuilt component. Just CSS doing what it should have been able to do years ago.

In this article we'll build a fully custom dropdown from scratch, starting with a plain <select> and ending with something that looks like it came out of a design system, using nothing but HTML and CSS.

Browser support first, because it matters here

This feature is genuinely new and not yet Baseline. As of writing, it works in Chromium-based browsers (Chrome 135+, Edge, and other Chromium forks), and support in Firefox and Safari is still catching up. Check the current numbers on caniuse.com before shipping this to production.

The good news is that this feature was designed with progressive enhancement built in. If a browser doesn't understand appearance: base-select, it just ignores the declaration and renders a normal, fully functional native <select>. Nothing breaks. Your users on unsupported browsers get the boring-but-reliable dropdown they've always had, and users on supporting browsers get the fully styled version. That's a rare and pleasant kind of "new CSS feature" to work with.

Starting point: a completely ordinary select

Before touching any CSS, here's the markup we're working with. We're building a "favorite language" picker, with a small icon next to each option:

<label for="lang-select">Pick your favorite language:</label>

<select id="lang-select">
  <button>
    <selectedcontent></selectedcontent>
  </button>

  <option value="js">
    <span class="icon">🟨</span>
    <span class="label">JavaScript</span>
  </option>
  <option value="py">
    <span class="icon">🐍</span>
    <span class="label">Python</span>
  </option>
  <option value="rs">
    <span class="icon">🦀</span>
    <span class="label">Rust</span>
  </option>
  <option value="go">
    <span class="icon">🐹</span>
    <span class="label">Go</span>
  </option>
  <option value="ts">
    <span class="icon">🔷</span>
    <span class="label">TypeScript</span>
  </option>
</select>
Enter fullscreen mode Exit fullscreen mode

Two things here look unfamiliar if you haven't seen this feature before:

A <button> as the first child of <select>. This used to be invalid markup. Now, when you include it, it replaces the default closed-state button of the select with your own button element. If you skip it, the browser falls back to its default rendering for the closed state.

A <selectedcontent> element inside that button. This is a new HTML element whose whole job is to mirror whatever option is currently selected. Under the hood the browser clones the selected <option>'s content into it. This is what lets you show an icon and text in the closed select, not just plain text.

Also worth noting: options can now contain real markup. Historically, anything except plain text inside an <option> got silently stripped. Now you can put <span>, images, and other non-interactive inline content in there, and it'll actually render.

If a browser doesn't support any of this, it degrades gracefully: the button/selectedcontent structure is ignored, the option markup collapses down to its text content, and you get a normal select with "JavaScript", "Python", etc. as plain text options.

Step 1: opting in

None of the styling below works until you explicitly opt in, on both the select itself and its dropdown panel:

select,
select::picker(select) {
  appearance: base-select;
}
Enter fullscreen mode Exit fullscreen mode

::picker(select) is a new pseudo-element that represents the popup panel, the part that shows when you click the select. You can opt the <select> in on its own without opting in the picker, but you can't do the reverse: the picker can only go into base-select mode if its parent select has too.

Once both are opted in, the browser strips its OS-level chrome from both pieces and renders the plainest possible version of a select. From here, it behaves like any other element you can put a border, background, or padding on.

Step 2: styling the select button

The select button is the always-visible part, the piece someone clicks to open the dropdown. This is your highest-visibility surface, so it's worth spending the most design effort here.

select {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-width: 220px;
  padding: 0.6rem 1rem;
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  background: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

select:hover {
  border-color: #98a2b3;
}

select:focus-visible {
  outline: none;
  border-color: #6366f1;
  box-shadow: 0 0 0 3px rgb(99 102 241 / 0.15);
}
Enter fullscreen mode Exit fullscreen mode

Nothing exotic here, it's the same declarations you'd write for any button. That's really the headline of this whole feature: the select button stops being a special case and becomes just another styleable box.

Cleaning up what shows inside the button

Remember the icon spans inside each <option>? Because <selectedcontent> clones the entire selected option's content, the icon comes along for the ride into the closed button too. That's usually not what you want, since it can throw off the button's height and spacing. Target it directly and hide it:

selectedcontent .icon {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

This only affects how the content looks inside the closed button. The icon still renders normally inside the open dropdown, because that's a separate rendering context.

Step 3: styling the picker icon (the little arrow)

That small down-facing arrow that used to be untouchable OS chrome now has its own pseudo-element: ::picker-icon.

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

select:open::picker-icon {
  rotate: 180deg;
}
Enter fullscreen mode Exit fullscreen mode

The :open pseudo-class targets the select button specifically while its picker is showing, which is exactly what you need to flip the arrow when the dropdown opens. This combination, targeting the icon and reacting to open state, used to require JavaScript toggling a class. Now it's two CSS rules.

Step 4: styling the dropdown picker itself

This is the part that used to be completely off-limits: the popup panel holding all the options. It's addressed with ::picker(select).

select::picker(select) {
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  margin-top: 0.4rem;
  padding: 0.4rem;
  background: #ffffff;
  box-shadow: 0 8px 24px rgb(16 24 40 / 0.12);
}
Enter fullscreen mode Exit fullscreen mode

A detail worth calling out: the picker is a genuine popover under the hood. When it opens, its contents get promoted to the browser's top layer, the same mechanism used by <dialog> and native popovers. That's why it correctly renders above everything else on the page and why it automatically closes other open popovers when it appears, without you writing any z-index hacks or click-outside handlers.

Because it's a popover, you also get transition support for free. If you want the dropdown to fade and scale in instead of just snapping open:

select::picker(select) {
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  transition: opacity 0.15s ease, transform 0.15s ease, display 0.15s allow-discrete, overlay 0.15s allow-discrete;
}

select:open::picker(select) {
  opacity: 1;
  transform: translateY(0) scale(1);
}

@starting-style {
  select:open::picker(select) {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
}
Enter fullscreen mode Exit fullscreen mode

The @starting-style block defines what the picker looks like the instant before it transitions in, which is necessary because it's animating from display: none, a state CSS can't normally transition from. allow-discrete is what makes animating display and overlay possible at all. This pairing looks unusual the first time you see it, but it's becoming a standard pattern anywhere popovers, dialogs, or view transitions are involved.

Step 5: styling the options

Each <option> is now a fully flexible container. It comes with display: flex applied by the browser's default base-select styles, which is convenient since our markup already has an icon span and a label span sitting side by side.

option {
  align-items: center;
  gap: 0.6rem;
  padding: 0.55rem 0.7rem;
  border-radius: 6px;
  font-size: 0.95rem;
  cursor: pointer;
}

option:hover,
option:focus {
  background: #f2f4f7;
}

option .icon {
  font-size: 1.1rem;
}
Enter fullscreen mode Exit fullscreen mode

You can reach for the exact same pseudo-classes you already know: :hover, :focus, :first-of-type, :nth-of-type(odd) for zebra striping, and so on. There's no special "option styling API" to learn, it's just CSS applied to an element that finally accepts it.

Step 6: styling the selected option and its checkmark

Two more new selectors round this out. :checked targets whichever option currently matches the select's value, and ::checkmark targets the little indicator next to it inside the open dropdown.

option:checked {
  background: #eef2ff;
  font-weight: 600;
}

option::checkmark {
  order: 1;
  margin-inline-start: auto;
  color: #6366f1;
  content: "✓";
}
Enter fullscreen mode Exit fullscreen mode

Setting order: 1 and pushing the checkmark to the end with an auto margin moves it from its default position at the start of the row to the end, a small flexbox trick that suddenly works here because options are flex containers. You can also replace the checkmark glyph entirely through content, or hide it altogether with display: none if your selected-state background color is enough of a signal on its own.

One accessibility note worth remembering: ::checkmark and ::picker-icon are purely visual. They're excluded from the accessibility tree, so whatever you put in their content won't be read out by screen readers. That's fine, since the underlying <select> still reports the correct selected value through normal accessibility APIs, but don't rely on a checkmark glyph alone to communicate meaning that assistive tech users would otherwise miss.

Putting it all together

Here's the complete, working example:

<label for="lang-select">Pick your favorite language:</label>

<select id="lang-select">
  <button>
    <selectedcontent></selectedcontent>
  </button>

  <option value="js">
    <span class="icon">🟨</span>
    <span class="label">JavaScript</span>
  </option>
  <option value="py">
    <span class="icon">🐍</span>
    <span class="label">Python</span>
  </option>
  <option value="rs">
    <span class="icon">🦀</span>
    <span class="label">Rust</span>
  </option>
  <option value="go">
    <span class="icon">🐹</span>
    <span class="label">Go</span>
  </option>
  <option value="ts">
    <span class="icon">🔷</span>
    <span class="label">TypeScript</span>
  </option>
</select>
Enter fullscreen mode Exit fullscreen mode
select,
select::picker(select) {
  appearance: base-select;
}

select {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-width: 220px;
  padding: 0.6rem 1rem;
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  background: #ffffff;
  font-size: 1rem;
  cursor: pointer;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

select:hover {
  border-color: #98a2b3;
}

select:focus-visible {
  outline: none;
  border-color: #6366f1;
  box-shadow: 0 0 0 3px rgb(99 102 241 / 0.15);
}

selectedcontent .icon {
  display: none;
}

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

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

select::picker(select) {
  border: 1px solid #d0d5dd;
  border-radius: 10px;
  margin-top: 0.4rem;
  padding: 0.4rem;
  background: #ffffff;
  box-shadow: 0 8px 24px rgb(16 24 40 / 0.12);
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  transition: opacity 0.15s ease, transform 0.15s ease, display 0.15s allow-discrete, overlay 0.15s allow-discrete;
}

select:open::picker(select) {
  opacity: 1;
  transform: translateY(0) scale(1);
}

@starting-style {
  select:open::picker(select) {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
}

option {
  align-items: center;
  gap: 0.6rem;
  padding: 0.55rem 0.7rem;
  border-radius: 6px;
  font-size: 0.95rem;
  cursor: pointer;
}

option:hover,
option:focus {
  background: #f2f4f7;
}

option .icon {
  font-size: 1.1rem;
}

option:checked {
  background: #eef2ff;
  font-weight: 600;
}

option::checkmark {
  order: 1;
  margin-inline-start: auto;
  color: #6366f1;
  content: "✓";
}
Enter fullscreen mode Exit fullscreen mode

Drop that into any Chromium-based browser and you'll get a fully custom dropdown, complete with icons, a rotating arrow, a fading-in panel, and a repositioned checkmark, all without a single line of JavaScript.

Why this is a bigger deal than it sounds

It's easy to read "you can now style a select" and shrug, but the actual shift underneath is significant. Every JS-powered "custom select" library exists because the real <select> couldn't be styled, so teams rebuilt it from scratch using divs and ARIA attributes, and in doing so quietly re-implemented keyboard navigation, focus management, typeahead search, and screen reader semantics. Most of these reimplementations get some part of that wrong, because native form controls are deceptively hard to fully replicate.

appearance: base-select sidesteps the problem entirely by keeping the real element. You still get native keyboard support, native form submission, and native accessibility behavior, for free, because it's still an actual <select>. You're just no longer locked out of its visual layer.

Should you use this today?

Given the current browser support, treat this as progressive enhancement rather than a drop-in replacement for your existing custom dropdown component:

  • If you're building something new and can tolerate a plainer fallback in non-supporting browsers, this is a great candidate, less JavaScript, smaller bundle, and better baseline accessibility than most hand-rolled dropdowns.
  • If you already ship a JS-based select component for a production product with broad browser support requirements, keep it for now, but keep an eye on this feature's Baseline status. It's likely to make that component unnecessary within the next couple of years.
  • Either way, it's worth trying out in a side project now. The mental model (button, picker, picker-icon, checkmark) is small enough to learn in an afternoon, and it'll only become more relevant as support widens.

The <select> element spent decades as the one thing in CSS everyone had to work around. That's no longer true, and it's worth getting familiar with while it's still new.


We're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at artclickdev.

Top comments (0)