Choosing from a bounded set — the native select, the custom dropdown, and the combobox, and the point at which each one stops being enough.
A text input asks the user to produce an answer. A select asks the user to recognize one. That single difference — recall versus recognition — spawns an entire family of controls, and the right member of the family is determined almost entirely by two numbers: how many options exist, and how many the user may choose.
This chapter is organized along that spectrum. As the option count grows, the control must grow new organs: first a collapsed list, then a search field, then asynchronous loading, and finally — when the selected set itself becomes large — a dedicated review surface. Each addition solves a real problem and each carries real cost. The most common failure in select design is not choosing a bad variant; it is choosing a variant sized for the wrong list.
The thresholds are heuristics, not laws. A list of 12 familiar options (months) tolerates a plain select comfortably; a list of 12 unfamiliar machine-generated identifiers already wants a filter. The governing question is not the count itself but whether the user can survey the open list in a glance or two. When surveying fails, add search. When even the search space is too large to ship to the client, search on the server.
Below roughly five options, collapsing is a net loss: a radio group shows everything at once, costs zero clicks to survey, and never hides the current value's alternatives. Radio groups, checkboxes, and toggles get their own chapter. This part covers everything from the plain single select through the searchable combobox; Part II takes on multiple selection — token fields, select-all conveniences, and transfer lists.
Every discussion of select design begins with an uncomfortable fact: the platform already ships one, it is more robust than anything you will build, and almost every design system replaces it anyway. Understanding exactly what <select> gives you — and what it refuses to let you touch — is the prerequisite for deciding whether to keep it.
What the native element provides for free is easy to underestimate: an option panel that renders outside the page (no z-index or overflow clipping problems, ever), correct positioning at viewport edges, type-ahead matching on keystrokes, full screen-reader semantics, and — most importantly — platform-appropriate presentation on mobile, where iOS renders a bottom-sheet picker and Android a dialog, both tuned for touch in ways a floating web panel never is.
What it withholds is the panel itself. The closed state accepts ordinary CSS — the styled specimen above is appearance: none plus a background-image chevron, a technique that has been safe for a decade. But the open list is OS chrome on most platforms: option height, typography, hover treatment, grouping presentation, and any hope of icons or descriptions inside options are out of reach.
The platform is finally addressing this. The customizable select — appearance: base-select with the ::picker(select) pseudo-element — opts a native select into full CSS control of the open panel, including arbitrary markup inside options. It began shipping in Chromium in 2025 and is the clear long-term answer, but at the time of writing it is not cross-browser, so production systems still need one of the two paths below.
The honest criterion: go custom only when a requirement on this list exists, because each item is something the native panel cannot do — search inside the list, icons or descriptions or avatars in options, multiple selection with checkboxes, consistent cross-platform brand presentation, or option counts that need async loading. If none apply, the native element with a styled closed state is the better engineering and the better UX, particularly on mobile.
In practice most product design systems — Radix, shadcn/ui, Carbon, Material — ship custom selects, because at least one requirement (usually brand consistency or rich options) always applies somewhere, and shipping two select components is worse than shipping one custom one. That is a defensible trade. What is not defensible is rebuilding the native select without budgeting for what it did silently: keyboard behavior, screen-reader wiring, and edge-of-viewport positioning are now your problems. The rest of this chapter is the anatomy of doing that well.
The custom select is two components wearing one name: a trigger that lives in the form's layout, and a listbox panel that floats above it. They have different box models, different states, and different design budgets — and most of this chapter treats them separately for exactly that reason.
The trigger is a button, not an input. Nothing is typed into it (until the combobox arrives in section X), and treating it as a button clarifies everything downstream: it shows a value or a placeholder, it opens on click and on Enter/Space/ArrowDown, and its focus ring is a button's focus ring. Visually, however, it must impersonate the text inputs beside it — same height, border, radius, and padding, drawn from the same tokens, per the alignment argument in Text Inputs Part I §IX.
The listbox is a floating surface containing options, at most one of which is selected. It carries its own vocabulary — selected indicator, hover highlight, group labels, dividers — covered in sections VI and VII.
The accessibility contract binding the two is the ARIA combobox pattern. The minimal skeleton is worth internalizing, because every attribute in it corresponds to something the native select did invisibly:
Keyboard behavior completes the contract: ArrowDown/ArrowUp move the active option, Enter commits, Escape closes without committing, Home/End jump, and printable characters type-ahead to matching options. Users coming from the native select expect every one of these; the full model belongs to the Keyboard Navigation chapter, but no custom select should ship without them.
The trigger's one non-negotiable job is to say “I open a list” — which it does with a chevron. Remove the indicator and the trigger is indistinguishable from a read-only text field; users discover it is interactive only by accident.
The single downward chevron is the modern consensus — a stroked, rounded glyph matching the line weight of the system's icon set, sized near the cap height of the value text (14–16px for a 14px value) and colored one step muted so it reads as affordance, not content. The filled caret is the same signal in an older dialect; it survives in dense enterprise UIs but its solid triangle rarely matches contemporary stroked icon sets. The double chevron — popularized by macOS and adopted by Radix and shadcn/ui — signals specifically that the control pops over itself rather than dropping below (see section VIII); it is the honest glyph if that is your panel behavior, and a slightly false promise otherwise.
While the panel is open the trigger should acknowledge it: the standard treatment is a darkened border and a 180° chevron rotation, transitioned in about 150ms. The rotation is one of the few pieces of motion in this component and it earns its place — it makes the trigger read as a hinge rather than a lid. Systems that skip it (the flipped state simply appears) lose little; systems that animate the panel but not the chevron feel subtly broken.
Everything else about the trigger's construction — size scale, radius, border weight and strategy, padding ratio — is inherited wholesale from the text input system, and re-deriving it here would contradict the point. The trigger uses the same tokens, not similar values: --field-font, --field-padding-block, --field-border, --field-radius as defined in Text Inputs Part I. A form where selects are 2px taller than inputs, or rounder, reads as assembled from two different kits — the same failure as mismatched button and input radii, in a context where the elements sit even closer together.
One genuine difference: text overflow. An input scrolls its text under the caret when the value outgrows the field; a trigger cannot scroll and must truncate with an ellipsis instead. The chevron must be flex-separated from the value — never absolutely positioned over it — so that the ellipsis appears before the icon rather than beneath it. Size triggers for the longest option where feasible; where the list is user-generated and unbounded, accept the ellipsis and let the full value be visible in the open panel.
Label placement for selects is the same decision as for text inputs — top-aligned by default, inline for dense settings panels, with the same trade-offs documented in Text Inputs Part I §VI. What changes is the stakes of getting the placeholder wrong, because a select's placeholder and its value occupy the same slot and are made of the same material: text the user did not write.
The placeholder is styled exactly like an input's placeholder — the same muted color, the same weight as the value it stands in for — and it makes the same promise: nothing has been chosen yet. Two conventions for its copy. “Select…” and its variants describe the mechanism and work everywhere, at the cost of saying nothing. “Select a country…” names the object and mildly reinforces the label. What placeholder copy must never do is smuggle in a default — a trigger that reads “Netherlands” in value-colored text when the user chose nothing is a form that lies on submit.
The argument against using placeholders as labels is stronger for selects than it was for inputs. When a text input's placeholder-label vanishes, the field at least shows text the user typed and can presumably recognize. When a select's placeholder vanishes, it is replaced by a bare option — “Netherlands” — with no visible record of what question it answers. Country of residence? Shipping destination? Citizenship? In any form with more than one select, unlabeled triggers become interchangeable gray boxes the moment they are all filled.
Selects have an asymmetry text inputs don't: filling them is one click, but emptying them has no obvious gesture. Clicking the selected option again closes the panel without deselecting in nearly every implementation. For a required field this is fine — empty is not a legal state, so no path back to it is needed, and the placeholder (if implemented as a disabled first option in a native select) should be unselectable once a real choice exists. For an optional field, the design must supply an explicit exit, and there are exactly two good ones:
The “None” option makes emptiness a first-class member of the list — visible, keyboard-reachable, styled a step muted, and conventionally separated from the real options by a divider. It is the more discoverable pattern and the only one that works in a native select. The clear button — an × that appears in the trigger once a value exists, before the chevron — is faster and keeps the list clean, but it is a small target, it must be a genuinely separate interactive element (a button inside a button is both an accessibility and an implementation problem), and on hover-less touch devices it is permanently visible clutter. Filter bars tend to prefer the ×, where clearing is frequent; forms tend to prefer “None,” where it is rare.
The listbox is a transient surface — visible for a second or two, then gone — but it is where the actual choosing happens, and it has more distinct design decisions packed into it than the trigger it hangs from. Four of them define its basic body: width, height, density, and the selected indicator.
The default rule is panel width = trigger width: the panel reads as the trigger unfolded, and nothing shifts optically on open. The exception is a deliberately compact trigger — a toolbar sort control, a table-header filter — where the trigger truncates but the panel widens to show options in full. That inversion is fine, with trigger width as the panel's minimum so it never opens narrower than what it hangs from. What the panel should never do is fit itself to an unbounded longest option; cap it (a common ceiling is 1.5–2× the trigger) and let extreme options truncate inside the panel.
Panels need a max-height — the convention is six to eight visible options, roughly 240–320px at default density — after which the list scrolls internally. One deliberate detail does most of the usability work: set the max-height so the final visible option is partially cut. A cleanly-ending list looks complete; a clipped row is an unmistakable, unlabeled signal that more exists below. If your option heights divide evenly into your max-height, subtract half a row.
Option rows follow the density posture of the surrounding product: ~28px rows for dense desktop tools where the panel may hold dozens of options, ~36px as the general-purpose default, ~44px where touch is a first-class citizen — the familiar WCAG touch-target floor. Two refinements that read as polish: give the panel 4px of internal padding and the options a small radius (roughly half the panel's), so hover highlights float inside the panel instead of colliding with its edge; and keep option text one size down from body text (13–14px), matching the trigger value exactly.
The leading check with a reserved gutter is the strongest pattern: the indicator sits in a fixed column that every option reserves whether checked or not, so text stays aligned and the eye can scan the gutter alone to find the current value. Its cost is that permanent left margin. The trailing check (macOS menus historically, many web systems) returns the left edge to the text but puts the indicator in a spot that varies with option length — fine in narrow panels, wandering in wide ones. A fill marks the selected row unambiguously but collides with the hover highlight, forcing the two states to compete for the background channel (the next specimen). Weight alone is the weakest signal — bold-versus-regular is genuinely hard to perceive across a fast-moving panel — and, like the fake-bold problem in button typography, it changes text width and can cause a visible reflow of the row.
An open listbox routinely shows two highlighted rows at once — the one the pointer is over and the one that is selected — and users must never have to guess which is which. The clean solution is to assign each state its own channel: hover (and keyboard-active) owns the background, selected owns the indicator. Systems that mark selection with a fill must then invent a second fill for hover, and the two grays inevitably end up within a shade of each other. This is the single most common defect in homegrown selects, and it matters most at the exact moment of use: the panel opens with the pointer already inside it, instantly producing both states.
Past about eight options, a flat list starts rewarding structure — groups, dividers, and richer option bodies. Each is borrowed from menu design, but with a stricter constraint: everything in a listbox is an option, and anything that is not an option (a header, a divider) must be visibly inert.
Group labels — the custom rendering of <optgroup> — are set in the caption register: very small (9–11px), uppercase or small-caps, faint, with extra space above the group and almost none below the label. The styling does double duty: it names the group and it is so typographically unlike an option that no one tries to click it. Unlabeled dividers are the lighter tool, right when the boundary is self-explanatory (recent items above, everything else below) or when naming the groups would just restate their contents. If you find yourself writing group labels like “Other” or “More,” the divider was the right choice.
Rich options are the single most common legitimate reason to abandon the native select, so when you pay that price, spend it well. Descriptions belong in the panel only when the option names genuinely underdetermine the choice — “Kanban board” vs. “Timeline” benefits; a country list gains nothing but height. Keep descriptions to one line, one step smaller and one step muted, and remember the trigger problem they create: a two-line option must still collapse to a one-line trigger value, so the title alone has to be able to stand for the whole choice.
The native select never asks where its panel should go — the OS decides, flawlessly, every time. A custom select inherits that question, and it is harder than it looks: the answer depends on where the trigger sits in the viewport at the moment of opening.
Attached below is the web default: the panel opens under the trigger with a small gap (4–8px), left edges aligned. The gap matters more than it appears — a panel fused to the trigger reads as the field expanding; a detached panel reads as a surface floating above the page, which is what it is. Flipping is the mandatory companion behavior: when the space below can't fit the panel's max-height, it opens upward instead. Every positioning library (Floating UI being the de facto standard) does this; hand-rolled selects that always open downward will eventually render options into the void below the fold — usually for the select at the bottom of the form, which is usually the submit-adjacent one that matters.
Item-aligned positioning is the third, stranger option: the panel opens over the trigger, vertically offset so the currently-selected option sits exactly on top of it. This is how macOS menus have worked for decades, and it is the default in Radix Select. Its virtue is real — the pointer is already resting on the current value, so re-selecting or nudging one option away is nearly free. Its costs are equally real: the panel obscures the trigger and its label, the open position varies with which option is selected, and users outside the Mac tradition find it startling. It suits option sets where the current value is the anchor of the decision (font sizes, zoom levels); it suits nothing where the user needs to reread the label while choosing.
Two platform notes close the topic. The Popover API renders panels in the browser's top layer, dissolving the z-index and overflow-clipping problems that have plagued custom dropdowns since they were invented; CSS anchor positioning is doing the same for the flip logic. And on mobile, the honest answer is often to stop floating altogether — below roughly 480px, many systems swap the panel for a bottom sheet, which is exactly what the native select would have done for free.
The trigger's state model is a button's state model plus one member: open. Rendered achromatically — this series holds color out of scope until it is the topic — the full set looks like this:
Note what carries each state when hue is unavailable: hover darkens the border one step; focus adds the ring; open flips the chevron and darkens the border to full strength; disabled retreats on every channel at once (fill, border, text, and chevron all fade); error doubles the border weight and adds an icon. In a production system the error state would additionally borrow the semantic red defined in the Input Color Taxonomy §V — but the structure shown here is what makes the state legible to the roughly 1-in-12 male users with deficient color vision, and it is the structure that this chapter is responsible for. Color is reinforcement, not the message.
The error message takes the hint text's slot — same position, same size, heavier voice — and the layout-shift warning from Text Inputs Part I §VII applies verbatim: reserve the line or watch the form jump. Write select errors as instructions, not descriptions: “Select a country to continue,” not “Invalid country.” A select is almost never invalid because the user chose wrongly — the list is the set of legal answers — it is invalid because it was never touched. Which is also why validation timing differs from text inputs: there is nothing to validate on keystroke and little on blur (tabbing past an untouched select and immediately flagging it reads as scolding). Validate required selects on submit; clear the error the instant a change event fires. The rare exception is cross-field consistency — a state that doesn't belong to the chosen country — which should surface on change of either field.
Two smaller states round out the matrix. Disabled options inside the panel (out-of-stock sizes, plan tiers not available on the current billing cycle) render faint, skip keyboard traversal, and — critically — remain in the list; removing them hides the very information that explains what the product offers. If users will wonder why, pair the disabled option with a tooltip or trailing note rather than leaving a mute gray row. Loading matters when options arrive from a request: an open panel showing a spinner row (“Loading options…”) is honest; a trigger that silently refuses to open is broken; and if options routinely take more than a beat to arrive, the async patterns in Part II are the real answer.
Somewhere past fifteen or twenty options, scanning an open list stops working — the panel scrolls, the eye loses its place, and the user is reduced to reading. The fix is to let them type. A combobox is a select whose trigger accepts text that filters the list; the selection model is unchanged (the value must still come from the list), but the path to it becomes a query instead of a scan.
The input-trigger combobox is the classical form: the trigger is a real text input, typing filters immediately, and the committed value lives in the same box the query did. It is the denser pattern and the right one when selecting is the user's primary task on the page. Its cost is ambiguity at rest — the field looks exactly like a free-text input, and the moment of clearing-the-value-to-search-again confuses users who fear they are deleting their answer.
The search-in-panel pattern — trigger stays a button, a search field is pinned to the top of the open panel — keeps the closed control identical to every other select on the form and makes searching an explicitly transient act: close the panel and the query evaporates, while the committed value never left the trigger. This is the pattern of the command-menu generation (shadcn/ui's Combobox, Linear's every-menu) and it has quietly become the default in new systems. Its cost is one extra focus hop and a panel that is taller by one row.
Starts-with matching mirrors the native select's type-ahead and suits lists users navigate by known first letters — months, US states. It fails hard on real-world naming: a user searching a country list for “states” gets nothing because the option starts with “United.” Contains is the correct general default for exactly that reason. Fuzzy (subsequence) matching forgives typos and skipped characters, which earns its keep in developer tools and command palettes where queries like ffprot are genuinely faster than typing “firefox protocol handler” — but its false positives feel random to non-expert users, and highlighting the scattered matched letters becomes visual noise. Whatever the rule: highlight the match. The highlight is the system explaining why each row survived the filter — set it in weight or underline, not a highlighter fill, which at panel scale reads as selection.
A filter with no hits must say so — a silently empty panel reads as breakage, and users' first instinct (“did my click even register?”) is to close and reopen, destroying their query. The empty state echoes the query back, which both proves the system heard it and lets the user spot their own typo. In a strict combobox this is also the moment the component admits its central claim: the answer is not here. If that happens often, the remedy is upstream — the list is incomplete, or the field should never have been a select (see section I's callout, and the free-text discussion below).
Every combobox must answer one question in its contract: may the user submit text that matches no option? A strict combobox says no — it is a select with a faster index, and on blur an unmatched query either reverts to the previous value or sits as an error. An autocomplete says yes — the list is advisory, arbitrary input is legal, and the component is really a text input wearing a select's clothes. The two look identical at rest, which is exactly why the behavior must be deliberate: a strict combobox that silently discards typed text on blur is among the most rage-inducing widgets on the web, because it destroys user input without acknowledgment. If you cannot show a clear rejection (revert visibly, or flag the error), loosen the contract.
HTML ships a native autocomplete — <datalist> bound to an input via list. It is always free-text (the list only suggests), its panel is unstylable OS chrome like the native select's, and its matching behavior varies by browser. Those limits are real, but for the low-stakes middle ground — a “How did you hear about us?” field, a unit picker that tolerates novel units — it delivers suggest-while-typing in three lines of markup:
Everything in this part rested on a quiet assumption: choosing an option ends the interaction. The panel closes on click because the answer is complete. The trigger shows the value because there is exactly one. The check indicator marks a single row. Selection and dismissal are the same gesture.
Allow a second simultaneous choice and every one of those certainties inverts. The panel must stay open — so it needs a new way to close. The trigger must summarize a set — counts, truncated lists, or chips that stretch the field. The indicator becomes a checkbox, selection needs conveniences that single selects never dreamed of — select all, clear all, “3 of 5 selected” — and at sufficient scale the selected set becomes an artifact the user must be able to audit, which is a job for a different component entirely: the transfer list.
Part II takes up multiple selection and scale: the trigger summary problem, the checkbox listbox, select-all and its complications, search under multi-select, selection limits, asynchronous option loading, and the dual-panel transfer list.
Part I of the Select Taxonomy. Continues the input series — Text Inputs (Parts I–II) and the Date Picker Taxonomy.
A select is a promise that the answer is already in the list. Keep the promise or change the component.