Styling the Native <select> with appearance: base-select

Want more of these in your Google results?
Picture getting a dropdown design where each option has a taller row, a brand-colored checkmark for the selected item, and a logo next to every label.
You try using a standard <select>, but see it can’t show that icon anywhere.
So you rebuild the control using a <div role="combobox"> and a <ul role="listbox">, with role labels that tell screen readers these simple divs work as a dropdown and its list.
You also add the arrow key navigation yourself.
The result matches the design perfectly, and when you click through each option, everything works as expected.
Then you try typing “dr” to find Dribbble, but nothing shows up.
With a real <select>, the browser would pick Dribbble before you finish typing.
It remembers what you type and matches it to the list as you go.
A plain <div> doesn’t support typing ahead, so your new version does nothing when you type those keys.
That’s just one of the features you lost.
You probably didn’t notice because they worked on their own, without extra code.
You lose even more functionality when using a phone.
A real <select> lets the device show its own picker, the same control people already know from every other app on the phone.
A custom version opens a floating panel designed with your CSS, usually made for use with a mouse.
There’s also the way it works with forms.
The select element sends its value with the form, resets when the form resets, stops the form from sending if it’s required but empty, and shows up in FormData under its name.
A <div> does none of this, which is why custom versions often add a hidden <input> next to it to keep the value.
The figure below shows a custom dropdown placed next to a standard <select> in the same form.
The first one looks cool, but click on each control, type a few letters, and watch what happens.
When you type, only one of the two controls reacts, and the form only includes the value from that control.
At the time, the rebuild made sense.
The <option> element could only hold text, so if you wanted to add an icon to the list, there was no way to do it.
Now, CSS can style that part.
What a native select already provides
Before adding new code, let’s list what the element already provides.
| Behavior | The browser does this for a <select> | What you build yourself | The review question |
|---|---|---|---|
| Type-ahead | Briefly stores the letters you type in a text buffer and selects the matching option | A text buffer, a timeout, prefix matching, and skipping disabled options | Can I find an option by typing its name? |
| Edge and step keys | Arrow keys, Home, End, and Page keys move through the list | The full set of keys, depending on the platform | Which keys does my control respond to, and on which operating system? |
| The picker itself | A system control on a phone, a popup the browser shows above the page on a desktop | A placed panel, handling overlaps, and closing | Whose widget is the user seeing? |
| Form participation | Submit, reset, FormData, required, and the error message popup | A hidden input, or a custom element connected to the form manually | Is this field included when the form is sent? |
| Autofill | The browser’s own input help targets the element | A connection no one has set up for you | Can the browser fill in this field? |
| Semantics | combobox role, open or closed state, selected option, announcements | All ARIA attributes, kept updated with the state | What does a screen reader say? |
Each row in the last column is a question you can ask during a code review, and together they lead to one main question that applies to everything.
Which browser feature am I replacing, and for which input does my version work less well?
Styling the closed control
Using appearance: none removes the standard style that the operating system applies to the closed control.
After that, you can change the font, color, border, rounded corners, padding, focus outline, and even the dropdown arrow, which you draw yourself.
css
/* The safe layer. This runs in every engine, opt-in or not, and it only ever touches the closed control. */
select {
appearance: none;
font: inherit;
/* The right padding is the only reason the longest label never runs under the caret below. */
padding: 10px 32px 10px 12px;
/* Two 4px tiles are placed side by side 12px to 20px in from the right edge, and the halves they each fill meet as a chevron. */
background-image:
linear-gradient( 45deg, transparent 50%, currentColor 50% ),
linear-gradient( 135deg, currentColor 50%, transparent 50% );
background-position: right 16px center, right 12px center;
background-size: 4px 4px;
background-repeat: no-repeat;
border: 1px solid #e7e6e2;
border-radius: 9px;
}
select:focus-visible {
outline: 2px solid #2257e6;
outline-offset: 2px;
}The two gradients create the arrow without needing any image, and because they use currentColor, the arrow automatically matches the text color in dark mode without extra work.
A common mistake is using a background image but not leaving enough space around the text.
This can cause the longest option’s text to go under the arrow.
Use the switches in the figure below to add each layer, then open the list. Your operating system draws that panel.
You can control it a little, though.
On a dark screen, one declaration helps prevent a quick flash of white.
color-scheme: light dark tells the browser your interface supports both light and dark modes.
The operating system then draws the list, its scrollbar, and the control parts using the mode chosen on the user’s system.
If nothing on the page sets color-scheme, a dark-themed page can still show a bright white option list.
Your page’s own light and dark switch won’t change it unless the switch also sets color-scheme.
The closed control is fully yours at this point, but the browser still owns the list that opens.
How to opt in
When you use appearance: base-select, you choose which parts you control and which parts the browser handles.
Setting this puts the element in a new mode, so you can target and style the picker like any other element on your page.
Two details of that rule affect what older browsers do with it:
You’ll usually apply the opt-in to both the element and its picker part.
MDN clearly says you can opt in the <select> by itself and keep the picker native, but you can’t do the opposite.
css
/* The opt-in, and nothing else. */
/* An engine that can't parse ::picker() throws the whole selector list away, so any other declaration in this rule would be thrown away with it. */
select,
select::picker( select ) {
appearance: base-select;
}The second detail keeps that rule down to a single declaration.
If a browser doesn’t recognize ::picker(), it treats the whole selector list as invalid and ignores the entire rule.
If you keep the opt-in alone, you won’t lose a border-radius because of a pseudo-element that older browsers don’t support.
There’s a trade-off with this opt-in.
Using appearance: base-select replaces the operating system’s picker with a popup inside the page in all browsers that support it, including on touch devices.
MDN explains on the appearance page that “the base-select value causes the <select> not to render outside the browser pane or to trigger built-in mobile operating system components.”
Chromium’s intent to ship also describes this, saying the picker “does not extend outside of the web contents like an appearance:auto select does.”
The operating system’s picker is made for touch, with big buttons, smooth scrolling, familiar gestures, and a position that works well with the on-screen keyboard.
Your in-page popup doesn’t have all of these features automatically.
On a small screen, even a small design mistake in the popup can cause much more trouble than on a desktop.
How many people see the swapped picker depends on which browsers have shipped the opt-in. Just saying “on mobile” doesn’t show the real differences between browsers.
Chromium has released this feature on both desktop and Android. This means that if you use Chrome on your phone, you already get the in-page popup. Safari has also released this feature in the update that comes with macOS 27, iOS 27, iPadOS 27, and visionOS 27. The compatibility data in the table below shows the same version for Safari on iOS, which fills in the iPhone row along with the desktop one. Firefox hasn’t turned on this feature by default yet, so if you want to see the fallback, you should use Firefox.
Apple’s WWDC session on the element shows the feature on a desktop, but it doesn’t mention how the picker works on an iPhone after the opt-in. The Safari release notes don’t mention it either. MDN describes the value in general terms, not tied to a specific engine, so by that definition, it includes WebKit. Even so, I’d still test the page on a real iPhone before launching a touch experience based only on what the specification says.
Browser data updated September 24, 2026
You don’t need to write any code for the fallback.
If a browser doesn’t support these features, it will skip the appearance value and the ::picker() rule.
You’ll still have a functioning <select> element.
That’s why this version has no JavaScript to check for the feature and no extra code to fix missing features.
You won’t need to remove anything later as the feature becomes available in more browsers.
Use a media query for the opt-in
Because appearance is a CSS property, you can put the opt-in behind a media query.
css
@media ( hover: hover ) and ( pointer: fine ) {
select,
select::picker( select ) {
appearance: base-select;
}
}hover and pointer both have a twin, any-hover and any-pointer, and the two pairs may sound similar, but they report different things.
It’s easy to pick the wrong one by mistake.
I explained these in more detail in The Four User-Preference Media Queries Your CSS Should Honor.
hover and pointer describe the pointing device the browser treats as primary, while any-hover and any-pointer cover all available pointing devices.
The query above matches when that primary pointer is precise and can hover.
It usually matches a laptop with a trackpad and doesn’t match a phone used by touch, but the primary pointer can vary with the browser and connected devices.
any-pointer: coarse matches when any available pointer has limited accuracy, including a touchscreen.
For example, it matches a touchscreen laptop even if a mouse is connected.
Using it would remove the branded picker from a device where the touchscreen hasn’t been used for months.
The primary-pointer query still has a problem on that same touchscreen laptop. The main pointer works well and hover is available, so the branded picker is used. If you touch the screen instead of using the trackpad, you get a popup inside the page that’s sized for a cursor. No media feature shows what the user plans to do, only what the device can do and its main way of input. You want to know which input the person will use for this control, but the system doesn’t give that information.
So a media query can fail in two directions. The safer choice declines the opt-in anywhere a coarse pointer exists at all.
css
/* The conservative variant, for when a touchscreen laptop is a real segment for you. */
@media ( hover: hover ) and ( pointer: fine ) and ( not ( any-pointer: coarse ) ) {
select,
select::picker( select ) {
appearance: base-select;
}
}For most products, I’d start with the primary-pointer query and keep the option rows large enough to tap. That lets a touchscreen laptop show the branded picker when its primary pointer is a mouse or trackpad. If you have real data about your users, use that to decide instead of just following my advice.
Phones used to ignore the opt-in, but that’s no longer the case. Chrome on Android now supports it, and compatibility data shows that Safari on iOS does too. A stylesheet made before either browser supported this has already changed what phone users see. A media query still works because it never relied on which browser engines had released what features.
What you can do with the picker
When the opt-in is limited in scope, the picker becomes part of the page, and everything else is normal CSS.
css
/* The branded picker. It is held behind the same pointer media query and behind @supports, so it can never half-apply to an operating system widget. */
@supports ( appearance: base-select ) {
@media ( hover: hover ) and ( pointer: fine ) {
select {
display: flex;
align-items: center;
gap: 10px;
background-image: none;
}
/* The closed button holds a clone of the selected option, so it needs its own layout rather than inheriting the option's. */
selectedcontent {
display: flex;
align-items: center;
gap: 10px;
}
/* The closed control shows the label alone, since the second line is picker detail that would crowd the button. */
selectedcontent .hint {
display: none;
}
/* The arrow is the browser's own element, so it gets a box and a mask here instead of a second caret drawn beside it. */
select::picker-icon {
content: "";
width: 11px;
height: 11px;
margin-left: auto;
background: currentColor;
mask: url( chevron.svg ) center / contain no-repeat;
}
select:open::picker-icon {
rotate: 180deg;
}
select::picker( select ) {
background: #ffffff;
border: 1px solid #90909a;
/* A long list would otherwise grow the panel past the viewport, and the scroll container has to be the panel rather than the page behind it. */
max-height: 260px;
overflow-y: auto;
}
/* The 44px minimum gives a finger on a touchscreen laptop room to select an option.
An option in base appearance takes its color from the user agent, so it has to be named here or the text follows the reader's system scheme while the panel follows the page. */
option {
color: #17171a;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
}
/* The label and the hint stack, so the option needs a block of its own beside the icon. */
option .body {
display: grid;
}
option:hover {
background-color: #eef2fe;
}
/* Arrowing the open picker moves real focus onto the option, so this ring is the only cue for where Enter will commit. */
/* It raises the browser's own indicator instead of removing it, and it's tied to :focus-visible the same way the browser ties its own. */
option:focus-visible {
outline: 2px solid #2257e6;
outline-offset: -2px;
}
/* The browser reserves this box on every option and reveals it only on the selected one, so the rows never shift when the choice moves. */
option::checkmark {
content: "";
order: 1;
margin-left: auto;
width: 18px;
height: 18px;
border-radius: 999px;
background: #2257e6 url( check.svg ) center / 12px no-repeat;
}
option:not( :checked )::checkmark {
visibility: hidden;
}
/* The picker animates open, so the motion drops out for anyone who asked for less of it. */
@media ( prefers-reduced-motion: no-preference ) {
select::picker( select ) {
opacity: 0;
transition: opacity 160ms ease, display 160ms allow-discrete, overlay 160ms allow-discrete;
}
select:open::picker( select ) {
opacity: 1;
}
@starting-style {
select:open::picker( select ) {
opacity: 0;
}
}
}
}
}If you skip the @supports wrapper, a browser that matches the pointer query but doesn’t support the opt-in yet will still apply the option rules to whatever it displays.
This can leave you with a half-styled system widget, which looks worse than either of the fully styled options.
By default, the browser adds its own arrow using ::picker-icon.
If you keep a custom caret, it will show up next to the browser’s arrow.
To prevent this, use background-image: none in the same block to remove the custom caret.
::picker-icon selects the arrow the browser adds to the button, and ::checkmark selects the mark on the chosen option.
Both pseudo-elements can have their own content property.
In this component, the arrow is a masked chevron and the mark is a filled brand circle, instead of the browser’s default symbols.
:open applies to the select element when its picker is visible.
The :open selector turns the arrow without any JavaScript.
The picker has a built-in anchor point and popover behavior. The browser puts it on the top layer above all other page content, places it next to the button, and uses its own default styles to handle screen edge collisions.
These tasks were previously done by a positioning library like Floating UI.
A common way to style the rows is one rule for option:hover and option:focus that sets a background color and removes the outline.
But with arrow keys, focus moves to <option>, and outline: none hides the focus highlight.
Only the background color indicates focus, which is subtle in light theme and invisible in dark.
To fix this, the block above separates the states.
Hover keeps the background, and :focus-visible adds a 2px colored ring like the native focus.
The picker animation at the bottom of the block works the same way as any element that animates into the top layer.
@starting-style sets the starting value for the animation on the first frame.
The allow-discrete keyword in the transition shorthand lets display and overlay animate.
In browsers that support overlay, the picker stays on top long enough to fade out smoothly.
Options can now include markup, which was the main reason for the original rebuild.
html
<option value="github">
<span aria-hidden="true"><svg viewBox="0 0 16 16"><!-- the GitHub mark --></svg></span>
<span class="body"><span>GitHub</span> <span class="hint">Code and gists</span></span>
</option>If an <option> doesn’t have a value, the browser derives its submitted value from the option’s text.
Following the HTML spec, the browser trims leading and trailing ASCII whitespace and collapses repeated ASCII whitespace to a single space.
Without value="github", the example above would submit GitHub Code and gists, including the hint.
You can avoid this problem by adding a value to every option.
The SVG in this example contributes no text, but text inside an SVG <title> or <text> element can become part of the value too.
The literal space between the label and hint keeps their words separate in the text used by browsers without the opt-in and by screen readers.
Keep a text label in every option so the choice still makes sense in the plain fallback.
In this example, the icon is decorative and can appear before the label.
aria-hidden="true" excludes it from the accessible name, leaving the label and hint for screen readers to announce.
The form sends the value, whatever name the screen reader announces.
The closed button displays the same rich content using <selectedcontent>.
html
<label for="network">Share to</label>
<select id="network" name="network" required>
<button>
<selectedcontent></selectedcontent>
</button>
<option value="">Choose a network</option>
<!-- one rich <option> per choice -->
</select>You can now put a <button> as the first item inside a <select>, which makes this feature work.
<selectedcontent> keeps a copy of the selected option and updates whenever you choose a different one, while the button itself does nothing by default and always acts as a single target.
If you change an option after the page has loaded, you have to update <selectedcontent> yourself.
You can style the copy differently from the original option.
In the example below, the closed button hides the second line, but it still appears in the dropdown list.
Don’t place template expressions in <button> or <selectedcontent>.
If a browser doesn’t support the new parser, it deletes these elements while loading the page, so your template content disappears before your code can use it.
Frameworks that follow elements by their order in the document might place the remaining expressions in the wrong spot.
Even if the browser supports this feature, it recreates the copy every time you change the selection, so any changes are lost.
To avoid problems in both cases, keep your markup fixed and put any changing content inside the options.
Does the keyboard still work
The docs, including those from vendors, say that the browser still handles the keyboard after opting in. The next figure uses your browser to show which events the control fires.
Amber rows mean the key reached the control but the selection didn’t move. The HTML spec deliberately doesn’t pin the key map down. Usually, arrow keys move through options and Home/End jump to the start or end, but keys can be different depending on the platform. On macOS, type-ahead works, but edge keys do nothing when the control is closed. With the opt-in, arrow keys in Chromium open the picker without changing the value, and typing inside the open picker only moves focus to the matching option.
Keyboard behavior depends on the platform, and only the browser ships with the local key setup. A custom dropdown often follows the author’s operating system habits, not the user’s, which causes differences across platforms.
To check your version, focus the closed control, type “dr” for Dribbble, press Tab, submit, and look for network=dribbble in the form.
Try again with the picker open on a machine where it’s enabled; press Enter before Tab to get the same result.
Wrapping the select in a component
Everything so far is CSS on plain markup. If that’s all you need, stop here.
Turning this into a reusable component with a typed API adds hidden complications.
A <select> inside a shadow root has no association with the outer form, so that form can’t gather, reset, or check it.
Wrapping the element removes its connection to the form without any visible sign.
Bring back form participation by making the wrapper a form-associated custom element using a static flag and the element’s own internals.
Without the flag, attachInternals() gives an object, but setFormValue causes an error and the element doesn’t submit anything.
js
class BrandSelect extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#select = document.createElement( 'select' );
constructor() {
super();
this.attachShadow( { mode: 'open' } ).append( this.#select );
}
get value() {
return this.#select.value;
}
set value( next ) {
this.#select.value = next;
this.#syncFormState();
}
}From there the contract is explicit, one method at a time.
setFormValue updates the form value.
setValidity sets the validity state, using the inner select as the point for showing error messages and focusing on blocked submissions.
js
#syncFormState() {
const selected = this.#select.selectedOptions[ 0 ];
this.#internals.setFormValue( selected && ! selected.matches( ':disabled' ) ? this.value : null );
if ( this.required && this.value === '' ) {
this.#internals.setValidity( { valueMissing: true }, this.requiredMessage, this.#select );
return;
}
this.#internals.setValidity( {} );
}A custom element doesn’t have a built-in validation message, and you can’t use an empty message.
If you use an empty message, setValidity throws a TypeError with the message “The second argument should not be empty if one or more flags in the first argument are true.”
This error happens inside syncFormState, causing more problems than just an empty message bubble.
The required check never gets set, and checkValidity() still returns true.
A plain <select> shows a message from the browser (like Chrome’s “Please select an item in the list.”).
Create a similar message for your wrapper and consider it part of the contract.
Reset needs its own function with a default value.
A native select uses the selected attribute for its default choice; the wrapper captures its initial value once so later connections don’t replace the reset value.
js
#defaultValue = '';
#hasDefaultValue = false;
connectedCallback() {
if ( ! this.#hasDefaultValue ) {
this.#defaultValue = this.value;
this.#hasDefaultValue = true;
}
this.#syncFormState();
}
formResetCallback() {
this.value = this.#defaultValue;
}
formStateRestoreCallback( state, mode ) {
if ( mode === 'restore' || mode === 'autocomplete' ) {
this.value = state;
}
}In the third callback, formDisabledCallback, the obvious implementation goes wrong.
The browser calls this when a parent <fieldset> disables your control; usually, you’d write this.disabled = disabled.
But with a property that reflects changes, this only works in one direction, since the disabled attribute now on the main element keeps the control disabled.
Turning the fieldset back on doesn’t trigger a callback to reverse the change, so the field stays disabled and is left out of submissions.
The solution is to keep track of the browser’s disabled state separately from any input that affects the disabled status.
js
#formDisabled = false;
formDisabledCallback( disabled ) {
this.#formDisabled = disabled;
}Combine the two states when updating the inner select’s disabled property.
js
get #isDisabled() {
return this.disabled || this.#formDisabled;
}Both cases need to look disabled, but :host([disabled]) only matches when the host itself has the attribute.
You can use ElementInternals.states to set a custom state for either case and style it with :state().
js
// wherever either disabled path changes
this.#select.disabled = this.#isDisabled;
if ( this.#isDisabled ) {
this.#internals.states.add( 'disabled' );
} else {
this.#internals.states.delete( 'disabled' );
}css
/* Driven from a custom state rather than the attribute, so an ancestor fieldset dims the control without the host having to claim it disabled itself. */
:host( :state( disabled ) ) {
opacity: 0.55;
pointer-events: none;
}The shadow boundary causes another problem that only shows up outside, making it harder to notice before production.
The built-in change event has composed: false, so it doesn’t reach listeners outside the shadow root.
I confirmed this in the browser, and to fix it I sent the event again with composed: true.
js
this.#select.addEventListener( 'change', () => {
/* The form value goes out before the event, or a listener calling new FormData( form ) still gets the previous choice. */
this.#syncFormState();
this.dispatchEvent( new CustomEvent( 'brand-select-change', {
detail: { value: this.value },
bubbles: true,
composed: true,
} ) );
} );Setting select.value to a string that doesn’t match any option clears the selection.
With no selected option, or with a disabled option selected, a native select contributes no entry to FormData, as the form submission algorithm specifies.
Passing null in both cases makes the wrapper do the same.
If you assign the value before adding options, the browser won’t remember that requested selection. Adding options to a single-choice dropdown can select the first enabled option instead. Assign the intended value after the options exist, then check that it still matches a choice whenever you replace the list.
The finished component
The figure below shows the finished component in a form, alongside live readings for support, pointer types, and the conservative coarse-pointer check.
Submit, reset, or clear and resubmit to see each form result reported by ElementInternals instead of the shadowed <select>.
When your primary pointer is precise and can hover, the media query enables the branded picker if your browser supports it. The switch lets you decline that opt-in and compare it with your browser’s native control. On a device whose primary pointer doesn’t match, the picker stays native with either switch setting. To inspect a phone’s native picker, open the example on the phone itself.
Does autofill still work
None of the sources say whether autofill works the same after the opt-in.
WebKit’s Safari release notes mention keyboard navigation, screen readers, form submission, validation, change events, and more.
Autofill isn’t mentioned, so it’s unclear if it works.
Joey Arhar’s customizable-select polyfill README says the polyfill “does not support autofill” unlike a real <select>, and the opt-in uses a real select.
I’d expect autofill to keep working on the plain select because the element, name, and autocomplete haven’t changed.
Test it in a browser before you rely on it.
- Build a form with an address and set
autocomplete="country"on a country<select>. - Save an address in your browser’s autofill settings, if you don’t already have one.
- Open the page without an
appearancestyle, click on the street field, accept autofill, and check if the country dropdown changes and if achangeevent happens. - Apply
appearance: base-selectto both the select and::picker(select), reload the page, and repeat the test. - Compare the two results.
If the dropdown is inside a shadow root, autofill works only if the browser supports autofill there.
Behavior differs, so test on the browsers you support.
formStateRestoreCallback is the one hook the platform gives you for autofill.
The browser calls this callback with mode: 'autocomplete' when autofill fills a value, so leaving it out removes autofill’s only way to connect.
When a select is the wrong control
All of this only matters if a dropdown is the right choice, which often isn’t true.
Drop-downs work best for 5 to 10 options. If there are fewer than 5 options, use radio buttons or segmented controls. If there are more than 10 options, use a text box where users can type. A long drop-down is useful when users need to look through options they don’t know instead of remembering them.
A few ways this goes wrong
- Putting other declarations in the opt-in rule. Browsers that can’t understand
::picker()ignore the whole selector list, so styles likeborder-radiusare lost in browsers that otherwise work fine. Keep the rule limited toappearance. - Options with extra content but no explicit
value. The submitted string includes the label and hint, with whitespace trimmed and collapsed. Give each option the value your form expects. - Assigning
formDisabledCallback’s state to a reflecteddisabledproperty. The host’s own attribute keeps the control disabled after you re-enable the<fieldset>. Keep the computed state separate from the public property. - Interpreting
any-pointer: coarseas “this is a phone”. The query also matches a touchscreen laptop with a mouse. Usepointerandhoverwhen you want the primary pointer to determine the opt-in, or deliberately exclude every coarse pointer with the conservative query. - Wrapping the select element and doing nothing more. A select inside a shadow root has no form owner, so the form loses a field even though the component looks complete.
Want more of these in your Google results?