The Select
Every replacement gave the select a look. Every replacement took away the keyboard contract that came free with the original.
The <select> arrived in HTML 2.0 in 1995 and immediately did everything right. Tab to focus it. Arrow keys to navigate. Type the first letter to jump. The screen reader knew what it was — a combo box, a count of items, the current value. The browser drew the entire widget from OS resources. Zero CSS. Zero JavaScript. Fully accessible on arrival.
The catch: you could not change the font.
That single constraint — one OS-rendered widget in a page you otherwise controlled completely — drove fifteen years of replacement attempts. Chosen.js. Select2. React-Select. Downshift. Headless UI. Radix. Each one a full re-implementation of a native browser component, starting from zero, rediscovering keyboard navigation case by case. Each one a debt owed to users who expected the native contract.1
This is a study in infrastructure debt: what happens when an entire industry works around a platform constraint instead of waiting for the platform to fix it.
Desktop list boxes & combo boxes, 1984–1994
Before the web, the list box and combo box were solved. The OS drew them, owned their keyboard behaviour, and exposed their state to assistive technology through platform accessibility APIs. Every property of a selection — the current value, the item count, the role — was available without any developer effort.
Native <select>, 1995
HTML 2.0 introduced the select element with full keyboard support, screen reader compatibility, and optgroup grouping. It was the most accessible dropdown the web would have for fifteen years. Its only crime was appearance.
The <select> element has been in every web browser since Netscape 2. Tab to reach it. Arrow keys to navigate. Type a letter to jump to the first matching option. Shift+click or Ctrl+click for multiple selection in <select multiple>. The browser draws the focus ring; the screen reader announces the role, the current value, and the option count. All of it ships free.
What it did not ship was CSS control. The OS rendered it — a native widget drawn from system resources — which meant it matched the operating system, not the design. On Windows 95 it looked like a Windows combo box. On Mac OS 8 it looked like a Mac popup menu. You could change the font in some browsers with some difficulty, or not at all in others. You could not change the border, the arrow, the dropdown panel, or the selected-option background.
The most accessible dropdown available did the one thing designers couldn’t accept.
<!-- HTML 2.0 — SELECT arrived with the first form spec -->
<SELECT NAME="country" SIZE="1">
<OPTION VALUE="us">United States</OPTION>
<OPTION VALUE="uk" SELECTED>United Kingdom</OPTION>
<OPTION VALUE="de">Germany</OPTION>
</SELECT>
<!-- SIZE > 1: a visible list box, not a dropdown -->
<SELECT NAME="interests" SIZE="4" MULTIPLE>
<OPTION VALUE="tech">Technology</OPTION>
<OPTION VALUE="sport">Sports</OPTION>
</SELECT>
<!-- Tab to focus. Arrow keys to navigate options.
Screen reader: "United Kingdom, combo box, 3 items."
No CSS. No JavaScript. The browser owns everything. -->The complete HTML 2.0 API. SIZE=1 renders a dropdown; SIZE > 1 a visible list box. MULTIPLE enables multi-selection. No JavaScript required for any of it.
so developers built their own
Hand-rolled dropdown, 2006
The solution was to hide the native select entirely and build a div-based replacement that looked however you wanted. It looked right. It worked with a mouse. For everyone else it was invisible.
The pattern was consistent across a thousand implementations: a hidden <select> for form submission, a visible <div> for appearance, and JavaScript to keep them in sync. The div received the click handler; the div showed the dropdown panel; the div could be styled with any CSS imaginable. The <select> had tabindex="-1" and aria-hidden="true" so it stayed completely out of the interaction model.
Which meant the interaction model was the div. The div had no role. It had no aria-expanded. It had no aria-haspopup. Keyboard focus bypassed it. Screen readers saw nothing. If you were a mouse user on a modern browser, the experience was excellent. If you were a keyboard user — navigating by Tab and confirming with Enter — the control did not exist.
The control looked however you wanted. It communicated nothing.
The keyboard problem
Implementing keyboard support for a custom select is non-trivial. Arrow keys. Home and End. Type-ahead that jumps to the first matching option. Escape to close. Tab to move focus and close simultaneously. Enter to select. Each one requires explicit keydown handling; none of them are automatic on a div. Most hand-rolled implementations handled mouse clicks and nothing else. The native select handled all of it for free, in 1995, and the hand-rolled replacement silently dropped the contract.
<!-- The hidden real select — submits the form, invisible to users -->
<select class="sr-only" name="country"
tabindex="-1" aria-hidden="true">
<option value="us" selected>United States</option>
<option value="uk">United Kingdom</option>
</select>
<!-- The visible fake: no role, no keyboard, no label -->
<div class="custom-select" onclick="toggle()">
<span class="value">United States</span>
<span class="arrow" aria-hidden="true">▾</span>
</div>
<ul class="dropdown" id="dd" hidden>
<li onclick="pick('us')">United States</li>
<li onclick="pick('uk')">United Kingdom</li>
</ul>
<!-- Tab: skipped (tabindex="-1" on real, none on fake).
Keyboard: none. Screen reader: nothing announced.
Mouse users: fine. Everyone else: stranded. -->/* The div-as-select idiom */
.custom-select {
position: relative;
display: inline-flex;
align-items: center;
border: 1px solid #aaa;
background: #fff;
cursor: pointer;
padding: 4px 24px 4px 8px;
}
.dropdown {
position: absolute;
top: 100%; left: 0; right: 0;
background: #fff;
border: 1px solid #aaa;
list-style: none;
z-index: 100;
}
/* No :focus styles. No keyboard handler. No ARIA. */The hidden-real-select pattern. One element for submission, one for appearance, JavaScript to sync them. The keyboard contract evaporates.
then a plugin promised to solve it once
Chosen.js, 2011
Harvest's Chosen turned any select into a searchable, taggable dropdown with one line of JavaScript. It was the most-starred jQuery plugin of its era. Its keyboard support was better than hand-rolled. Its screen reader support was a different problem entirely.
Chosen did something genuinely useful: it added a search input to the dropdown. Type in the box; the option list filters. For a <select> with fifty countries, this was a meaningful improvement over the native control, which required precise type-ahead navigation without any visual feedback. Chosen’s multi-select turned selected options into removable tags — another real feature the native element couldn’t match.
Under the hood, Chosen read the original <select> element once, hid it, and rebuilt the entire widget as <div> and <ul> elements. The keyboard handling it added covered the basics: arrow keys to navigate, Enter to select, Escape to close. What it did not add was a coherent ARIA model. The outer element was a <div class="chosen-container"> with no role. The dropdown was a <ul class="chosen-results"> with no role="listbox". Screen reader behaviour was browser-dependent and inconsistent: NVDA on Chrome might read the options; VoiceOver on Safari frequently announced nothing at all.2
The keyboard got better. The screen reader got worse.
Why it spread anyway
Because the search worked and the tags looked good and the accessibility audit was later — if it happened at all. Chosen reached 22,000 GitHub stars by 2013. Entire design systems were built around its visual language. The ARIA model was an implementation detail no one saw unless they ran a screen reader, and most development teams did not.
// jQuery 1.7.2 + Chosen 1.x — one line of JavaScript.
// Chosen reads your <select> once and rebuilds it as a <div>.
$('.chosen-select').chosen();
$('#country').chosen({
placeholder_text_single: 'Choose a country…',
no_results_text: 'No results found for'
});
$('#tags').chosen({ placeholder_text_multiple: 'Add tags…' });
// Generated HTML: <div> + <ul>, not role="listbox" + role="option".
// Keyboard: handled by Chosen's own code, not native.
// Screen reader: NVDA reads it; VoiceOver on Safari often does not.One line activates the plugin. Chosen reads the select, discards it visually, and rebuilds the widget with its own HTML — minus ARIA roles.
then search became the product
Select2, 2013
Select2 took Chosen's idea and made search a first-class feature rather than an enhancement. Server-side search over AJAX, tokenized tagging, infinite scroll. It was solving a different problem — and it was better at ARIA than Chosen, though not by enough.
Select2’s differentiating feature was the AJAX data source. Where Chosen loaded all options upfront and filtered them client-side, Select2 could fire a request to a server endpoint on each keystroke, receive a paginated result set, and render it in the dropdown. For a user table with ten thousand rows, this was the only viable pattern. No native select could do it; no hand-rolled dropdown could easily do it either.
Select2 also made real progress on the ARIA model. The trigger element got aria-expanded, aria-haspopup, and aria-autocomplete. The active option got aria-selected. The container got role="combobox". The implementation was partial — aria-activedescendant was not always correct during rapid keyboard navigation, and the relationship between the search input and the listbox was not always properly declared — but compared to Chosen, it represented genuine investment in the spec.3
The data model improved. The ARIA model was getting closer but not there yet.
The dependency problem
Select2 3.x required jQuery. Select2 4.x still required jQuery. The framework landscape was shifting toward React and Angular, and neither ecosystem wanted a jQuery dependency in their component tree. By 2015 the question was not “which jQuery select plugin?” but “which framework-native component?” — and that shifted the competitive landscape entirely.
// jQuery 1.9.1 + Select2 3.5.4
$('#framework').select2({
placeholder: 'Choose a framework…',
allowClear: true
});
// Select2's differentiator: AJAX-backed search
$('#user-search').select2({
ajax: {
url: '/api/users/search',
dataType: 'json',
delay: 250,
processResults: function(data) { return { results: data.items }; }
},
placeholder: 'Search for a user…',
minimumInputLength: 1
});
// Better keyboard than Chosen. aria-expanded and aria-selected present.
// aria-activedescendant sometimes incorrect on rapid arrow-key nav.Server-side search was the killer feature. The ARIA model was better than Chosen — aria-expanded, aria-selected — but aria-activedescendant tracking still had gaps.
then components owned the ARIA
Material-UI Select, 2016
React's component model changed the authorship. The ARIA wiring — aria-labelledby, portal rendering, focus return — moved into the library and stayed there. Developers no longer configured accessibility attributes; they just used the component.
Material-UI’s <Select> component was the first mainstream select replacement where correct aria-labelledby wiring happened automatically. You wrapped it in a <FormControl> with an <InputLabel>, gave the label an htmlFor matching the input’s id, and the component resolved the relationship at render time. You did not need to remember the ARIA attributes; the component emitted them.
The same was true of the portal. Material-UI rendered its dropdown panel in a portal at the root of the document — which meant it was never clipped by overflow: hidden on an ancestor, never buried in a stacking context that made it invisible. This was a real engineering problem that hand-rolled dropdowns got wrong constantly, and the component solved it by default.
Correct accessibility stopped being developer discipline and became framework output.
What the spec hadn’t settled yet
Material-UI v1 used role="button" on the trigger element rather than role="combobox". This was not negligence — the WAI-ARIA 1.1 combobox pattern was actively being revised in 2016 and the guidance was ambiguous.4 The component made reasonable choices given what the spec had settled; what the spec had not settled was left to luck. The role="combobox" pattern would not be fully clarified until ARIA 1.2, published in 2023.
// React + Material-UI v1 (2016)
import Select from 'material-ui/Select';
import MenuItem from 'material-ui/MenuItem';
import InputLabel from 'material-ui/InputLabel';
import FormControl from 'material-ui/FormControl';
<FormControl>
<InputLabel htmlFor="dept">Department</InputLabel>
<Select
value={dept}
onChange={e => setDept(e.target.value)}
inputProps={{ name: 'dept', id: 'dept' }}>
<MenuItem value="eng">Engineering</MenuItem>
<MenuItem value="design">Design</MenuItem>
<MenuItem value="product">Product</MenuItem>
</Select>
</FormControl>
// Floating label wired to InputLabel via htmlFor. // aria-labelledby correct. Dropdown rendered in
a portal. // role="button" on the trigger — the combobox spec wasn't // ratified until 2021, so this
was the best available.The FormControl/Select/InputLabel trio wires aria-labelledby automatically. The developer configures the component; the component emits the ARIA.
then headless got the contract right
Radix Select, 2022
Radix UI published its Select primitive in 2022 with a clear goal: implement the WAI-ARIA combobox pattern correctly, completely, and test it against real screen readers. For the first time, an off-the-shelf dropdown component passed NVDA, VoiceOver, and JAWS without additional configuration.
Radix’s Select uses role="combobox" on the trigger, role="listbox" on the content panel, and role="option" on each item. aria-activedescendant tracks the highlighted option with every keyboard movement. aria-selected reflects the committed selection. Type-ahead — jump to the first option starting with the typed character — is built in. Pointer and keyboard paths reach parity: selecting with a mouse and selecting with a keyboard produce identical state transitions, announced identically to screen readers.
The component also handles scroll buttons — in a long list, Radix renders a scroll-up and scroll-down button that appear when the list can scroll in that direction, keeping the active option in view. It renders in a portal to escape stacking contexts. It dismisses on Escape and returns focus to the trigger. All of the patterns that hand-rolled implementations missed in 2006 are present, tested, and correct.5
The first off-the-shelf select that implemented the full ARIA contract correctly.
The cost of headless
Radix is unstyled by default — it renders the structure and the behaviour, and leaves all visual design to you. This is the right tradeoff for a shared primitive: the visual language belongs to the product, not the library. But it means more initial work. And it means React as a hard dependency. The native select still requires neither.
// @radix-ui/react-select v1 (2022)
import * as Select from '@radix-ui/react-select';
<Select.Root value={value} onValueChange={setValue}>
<Select.Trigger aria-label="Role">
<Select.Value placeholder="Select a role…" />
<Select.Icon><ChevronDown /></Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper">
<Select.ScrollUpButton />
<Select.Viewport>
<Select.Group>
<Select.Label>Engineering</Select.Label>
<Select.Item value="frontend">
<Select.ItemText>Frontend Engineer</Select.ItemText>
<Select.ItemIndicator><Check /></Select.ItemIndicator>
</Select.Item>
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton />
</Select.Content>
</Select.Portal>
</Select.Root>
// role="combobox" on trigger, role="listbox" on content, role="option".
// aria-activedescendant tracking. Type-ahead built in. Pointer and
// keyboard reach parity. NVDA + VoiceOver both pass.
// First off-the-shelf select to fully implement the ARIA spec.The complete Radix Select composition. Every ARIA attribute is emitted by the library. The developer's job is layout and visual design — not accessibility specification.
then CSS finally came for the native
Styled native <select>, 2023
appearance: none plus a background-image arrow gives you a fully custom-looking native select. The keyboard contract, the screen reader support, the optgroup labels — all still native. All still free. The only thing that changed was CSS finally letting you in.
appearance: none removes the OS-rendered chrome from a form control and leaves a plain box you can style. On <select>, it removes the system-drawn dropdown arrow. You supply your own arrow via background-image — typically an inline SVG data URI so there’s no extra HTTP request. Then you style the border, the padding, the border-radius, the focus ring. The result looks like anything you want and behaves exactly like a native select, because it is one.
accent-color — introduced in Chrome 93, Firefox 92, Safari 15.4 — lets you set the tint colour used for selected option highlighting inside the dropdown. It’s a coarse control, not a full style override, but it gets the brand colour into the one place appearance: none cannot reach: the option list itself. The native browser popup is still OS-rendered; you can style the control’s border and arrow, but not the dropdown panel. That limitation remains, and it is the reason custom implementations will continue to exist.
CSS reached the select. It stopped at the dropdown panel.
What’s coming next
CSS Anchor Positioning and the ::picker pseudo-element are in the specification as of 2024, with partial support in Chromium 130 behind a flag. The ::picker(select) allows styling the native dropdown panel — background colour, border, border-radius, font. This would close the last styling gap that drove fifteen years of replacements. It is not yet Baseline. The native select is recovering, but the recovery is not complete.6
/* The three lines that unlock native select styling */
select {
appearance: none; /* remove OS chrome */
-webkit-appearance: none;
/_ Custom arrow via inline SVG data URI _/
background-image: url("data:image/svg+xml,…");
background-repeat: no-repeat;
background-position: right 12px center;
/_ Now it's just a styled box _/
padding: 9px 36px 9px 12px;
border: 1.5px solid #cbd5e1;
border-radius: 7px;
font: inherit;
color: inherit;
/_ Chromium 93+: styles the selected option highlight _/
accent-color: #6366f1;
}
select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,.2);
}
/_ Arrow keys: native. Type-ahead: native.
optgroup labels: native. Screen reader: native.
All free. Zero JavaScript. _/Three CSS properties unlock the native select for styling. Everything else — keyboard nav, screen reader, optgroup labels — remains native and free.
The shape of the dig
The select’s arc is longer and less resolved than the button’s. The button’s recovery was complete: all: unset plus :focus-visible plus CSS custom properties made the native <button> the correct choice again with no caveats. The select’s recovery is still underway.
The constraint that drove every replacement was real. You could not style the dropdown panel — not in 1995, not in 2006, not in 2023. Chosen and Select2 solved real problems: the search input, the AJAX data source, the multi-select tagging. These are features the native element still does not provide well. The replacements were not irrational.
What was irrational was the accessiblity accounting. Each replacement started with zero keyboard support and rebuilt it from scratch, case by case, missing something each time. The hand-rolled dropdown missed all of it. Chosen missed screen readers. Select2 missed aria-activedescendant. Material-UI missed role="combobox". Radix finally got the full contract right, in 2022, twenty-seven years after the native element had shipped it for free.
Read the strata downward and you can see the debt accumulate and slowly resolve. The keyboard contract that the native select provided in 1995 was not rediscovered until 2022. Every era in between represents a gap — a set of users for whom the dropdown did not work as expected, silently, invisibly, because no one ran the screen reader or navigated without a mouse.
The lesson is not to avoid third-party components. It is to audit them. The native element set the baseline; anything replacing it needs to meet that baseline before it ships.
The WAI-ARIA 1.2 Combobox pattern and the associated ARIA Authoring Practices Guide “Combobox Pattern” (W3C, 2023) define the full keyboard and ARIA contract for custom select components. The pattern was revised significantly between ARIA 1.0 (2014) and ARIA 1.2, which is why earlier implementations varied. ↩
Chosen’s GitHub issue tracker documented screen reader incompatibilities from 2012 onward. The core issue — no ARIA roles on the generated HTML — was acknowledged but never fully resolved before the library was deprecated. ↩
Select2 4.0 (2016) improved ARIA coverage significantly over 3.x. The
aria-activedescendanttracking issue on rapid keyboard input was a known limitation documented in the project’s accessibility notes. ↩WAI-ARIA 1.1 was published in December 2017. The combobox pattern it defined was subsequently found to be incompatible with some screen reader implementations and was revised in ARIA 1.2 (2023). See the ARIA Working Group’s “ARIA 1.1 Combobox Migration Guide.” ↩
Radix UI’s accessibility testing matrix for Select v1.0 covered NVDA + Chrome, NVDA + Firefox, JAWS + Chrome, VoiceOver + Safari, and VoiceOver + Chrome. Results are published in the component’s documentation under “Accessibility.” ↩
The
::pickerpseudo-element andappearance: basefor form controls are specified in the CSS Basic User Interface Module Level 5 (CSS UI 5) and the Open UI proposal for styleable<select>. Chromium 130 shipped initial support behind thechrome://flags/#enable-experimental-web-platform-featuresflag. ↩
The Macintosh list box and the Windows combo box were different in appearance but identical in contract. Tab moved focus to them. Arrow keys navigated the list. Return or Space confirmed a selection. The OS announced the focused item to whatever accessibility layer existed — MAP on Mac, MSAA on Windows. None of this was optional or configurable. It came with the control.
The web was about to introduce a control that looked similar, worked similarly, and came with all of the same keyboard contract. It would also be completely unstyable — and that would turn out to be enough.