Select
A hand-drawn single-select dropdown with a fully custom, hand-implemented listbox.
Overview
A Select lets someone choose one option from a list, shown in a floating panel anchored to a
button trigger. Unlike every other GHDS form control, it does not wrap a real native
<select> — a native dropdown’s browser-drawn UI can’t be restyled to match the hand-drawn
aesthetic, so the trigger is a real <button> but the panel and its keyboard model are
hand-implemented, following the WAI-ARIA “select-only combobox” pattern.
Anatomy
A sketchy trigger button (selected value or placeholder, plus a chevron that flips when open):
clicking or pressing Enter/Space/Arrow keys opens a floating listbox panel positioned below (or
above, if there isn’t room) the trigger. Real DOM focus never leaves the trigger — the currently
highlighted option is tracked via aria-activedescendant, not by moving focus into the panel.
Variants & States
| State | Status | Notes |
|---|---|---|
| Default | Implemented | — |
| Open | Implemented | Floating panel, positioned via @floating-ui/dom (React/Web Components) or a measured Modal overlay (React Native). |
| Selected | Implemented | — |
| Disabled | Implemented | Trigger and individual options can each be disabled independently. |
| Loading | Not implemented | — |
Usage
Do
- Use Select when there are more options than comfortably fit as visible Radio buttons (roughly 5+).
- Keep option labels short and scannable — the panel does not wrap long text.
Don't
- Don't expect keyboard Arrow navigation to wrap from the last option back to the first — v1 clamps at the ends, matching a native select's own behavior in most browsers.
- Don't rely on the panel repositioning correctly if a transformed or clipped ancestor sits between the trigger and the viewport — position: fixed escapes normal overflow, but not a transform/filter/contain ancestor. This is a known v1 limitation, not a bug.
Accessibility
- React / Web Components:
role="combobox"+aria-expanded+aria-haspopup="listbox"+aria-controlson the trigger;role="listbox"on the panel;aria-activedescendanton the trigger points at the highlighted option’s id. Full keyboard support: Arrow Up/Down move the highlight (opening the panel first if closed), Home/End jump to the first/last enabled option, Enter/Space selects, Escape closes and keeps focus on the trigger, typeahead jumps to the first option whose label starts with what was just typed. - React Native:
accessibilityRole="combobox"+ a directaria-expandedprop (React Native Web does not derivearia-expandedfromaccessibilityStatethe way it doesaria-disabled— confirmed directly againstreact-native-web’s source, the same gap already documented forCheckbox/Radio/Switch’saria-checked).aria-haspopup,aria-controls, andaria-activedescendanthave no equivalent on React Native at all — not even a manual workaround exists, since none of these are supported RN props in any form. The options panel usesaccessibilityRole="menu"/"menuitem", the closest available roles (RN has nolistbox/optionrole). - See the Accessibility Guide for general conventions.
Content
The placeholder names what’s being chosen (“Choose a fruit”), not an instruction (“Please select”). Option labels name the option itself, not an action.
Props API
React@ghds/react
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label, associated via Radix Label. |
value | string | — | Controlled selected value. |
defaultValue | string | — | Initial value when uncontrolled. |
onValueChange | (value: string) => void | — | Fires when an option is selected. |
placeholder | string | — | Shown when nothing is selected. |
disabled | boolean | false | Disables the trigger. |
children | ReactNode | — | SelectOption elements — the only platform using children composition. |
Web Components@ghds/web-components
| Prop | Type | Default | Description |
|---|---|---|---|
options | { value, label, disabled? }[] | — | The selectable options — a data array, not slotted children (light DOM has no context mechanism). |
value | string | '' | Current field value, mirrored to the form value. |
name | string | — | Submitted control name. |
placeholder | string | — | Shown when nothing is selected. |
label | string | — | Optional visible label, also the accessible name. |
disabled | boolean | false | Disables the trigger and form participation. |
React Native@ghds/react-native
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible, accessible label — required (there is no separate label element on RN). |
options | { value, label, disabled? }[] | — | The selectable options — mirrors the Web Components data-array API. |
value | string | — | Controlled selected value. |
onValueChange | (value: string) => void | — | Fires when an option is selected. |
placeholder | string | — | Shown when nothing is selected. |
disabled | boolean | false | Disables the trigger. |
testID | string | — | Test handle for queries. |
Code examples
// React — @ghds/react (children composition with SelectOption)
import { Select, SelectOption } from '@ghds/react/select';
import { useState } from 'react';
const [fruit, setFruit] = useState<string | undefined>(undefined);
<Select label="Fruit" placeholder="Choose a fruit" value={fruit} onValueChange={setFruit}>
<SelectOption value="apple">Apple</SelectOption>
<SelectOption value="banana">Banana</SelectOption>
<SelectOption value="cherry" disabled>
Cherry (out of stock)
</SelectOption>
</Select>;
<!-- Web Components — @ghds/web-components -->
<!-- `options` is a data array (JSON attribute), not slotted children -->
<script type="module">
import '@ghds/web-components/select';
</script>
<gh-select
label="Fruit"
placeholder="Choose a fruit"
options='[{"value":"apple","label":"Apple"},{"value":"banana","label":"Banana"}]'
></gh-select>
// React Native — @ghds/react-native (data-array options, like Web Components)
import { Select } from '@ghds/react-native/select';
const FRUIT_OPTIONS = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];
<Select label="Fruit" options={FRUIT_OPTIONS} value={fruit} onValueChange={setFruit} />;
Live Demo
React and Web Components render live below. React Native can’t run in a browser without
additional react-native-web wiring (not set up in this site), so its usage is shown as a code
sample instead.