Building a Select Component — When to Use Native and When to Go Custom

The Select is the component where most UI libraries make a hard choice and then pretend it was easy. Native select elements are accessible and mobile-friendly but nearly impossible to style. Custom selects look great but carry the full weight of keyboard navigation, screen reader support, and focus management. We're building both — and being honest about when each one is the right call.
Building a Select Component — When to Use Native and When to Go Custom
Before writing a line of code, we need to make a decision that will shape the entire component: native or custom?
This isn't a stylistic preference. It's an architectural choice with real tradeoffs. Most libraries pick one and paper over the limitations. We're going to build a styled native select for the majority of use cases, and show exactly where it breaks so you know when a custom implementation is justified.
The honest tradeoff
Native <select>:
- Accessible by default — keyboard navigation, screen reader announcements, and mobile pickers all work without any extra code
- Cannot render custom option content — no icons, no descriptions, no multi-line options
- Extremely difficult to style consistently across browsers — particularly the dropdown panel itself
- On mobile, triggers the native OS picker — which is actually a better experience than a custom dropdown on a small screen
Custom select (listbox):
- Fully styleable — options can contain any content
- You own every behaviour — keyboard navigation, focus management, scroll into view, filtering
- Requires significant ARIA implementation to be accessible —
role="combobox",aria-expanded,aria-activedescendant,aria-controls - Mobile experience is typically worse than the native picker unless significant extra work is done
Our approach: a well-styled native select covers 80% of real-world use cases. We build that first and build it properly. The custom listbox pattern — used by our Dropdown Menu and Command Palette components later in the series — covers the remaining 20%.
The four files
Select.variants.ts
The pr-* on each size variant creates space for the chevron icon on the right — without it the selected text would overlap the icon. The icon itself is positioned absolutely inside the wrapper and has pointer-events-none so clicks pass through to the native select element underneath.
Select.types.ts
We support two option formats: a flat options array for simple lists, and a groups array for grouped options rendered with <optgroup>. Consumers can also skip both and pass <option> elements as children directly — the native select handles it naturally.
Select.tsx
What each decision is doing
appearance-none with an absolute chevron — appearance-none removes the browser's default dropdown arrow. We replace it with our own SVG chevron, absolutely positioned inside the wrapper div. pointer-events-none on the icon means clicks and focus events pass through to the native select underneath — the icon is purely decorative.
Placeholder as a disabled option — the native way to implement a placeholder in a select is a <option value="" disabled> as the first option. When the select has no value or value="", this option is displayed. When the user picks a real option, they can't return to the placeholder — disabled prevents it. This is the correct pattern; an enabled empty option would submit an empty string on form submission.
options, groups, and children all work together — a consumer can pass options for a flat list, groups for grouped options, children for custom option markup, or any combination. The native select renders them all in order. This gives maximum flexibility without a complex option rendering API.
Error and hint follow the same pattern as Input and Textarea — aria-describedby, aria-invalid, role="alert" on the error paragraph. Consistency across form fields means consumers don't need to learn a new pattern for each component.
forwardRef for form library integration — react-hook-form and other form libraries need to register the underlying select element. Without forwardRef, they can't reach it.
Where the native select breaks
Be honest with your consumers about the limitations. The native select cannot:
- Render icons or custom content inside options
- Style the dropdown panel — background color, border, shadow, font are all browser-controlled
- Show a search/filter input inside the dropdown
- Support multi-select with a custom UI (the native
multipleattribute has terrible UX) - Render option descriptions or secondary text
When any of these are requirements, the right component is not a styled native select — it's a DropdownMenu or CommandPalette, both of which we'll build later in this series. Those components use the listbox ARIA pattern and give full control over option rendering.
The component index
src/components/Select/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use this Select component when you have a list of simple text options and don't need to style the dropdown panel. Use DropdownMenu when options need custom content — icons, descriptions, or actions. Use CommandPalette when the list is long enough to need filtering. The native select is the right default; reach for the custom components only when it genuinely can't do the job.
Up next: Building a Toggle / Switch component from scratch — a binary input like Checkbox but with different semantics. Toggle communicates an immediate on/off effect; Checkbox communicates selection in a form context. The distinction matters for both UX and accessibility.
Related: Building a Dropdown Menu component with keyboard navigation — when your Select options need icons, descriptions, or custom rendering, Dropdown Menu is the right component. Built later in the series with the full listbox ARIA pattern.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Ilya Pavlov on Unsplash

