Select
A trigger-owned popup for choosing one value from a bounded option set.
- Family
- Choice
- Available import
@/components/ui/select- Status
- Thorbis wrapper and live specimen ship here.
select- Use
- Use when the list is concise, the current value matters, and native select is insufficient.
- Avoid
- Do not use for actions, multi-select, or options that require free-text search.
Use this component
GitHub source links require access to the private Thorbis design repository.
Wrapper sourceRepository and dependencies
- Value exports
SelectSelectContentSelectGroupSelectItemSelectItemDescriptionSelectItemTextSelectLabelSelectSeparatorSelectTriggerSelectValue
- Type exports
- None.
Setup, styles, and dependencies
These are React wrappers in the Thorbis design repository, not an installable npm UI package. In this repository, @/ resolves to src/ through tsconfig.json.
Use the repository's package.json and package-lock.json for dependencies. Load src/styles.css once at the application root for global tokens, typography, and themes. The wrappers compose colocated StyleX owners; include the generated token CSS and extracted StyleX output.
For another project, bring the wrapper and its transitive local imports, configure the alias, install the dependencies those files use, and provide the same token and style setup. Review the source before copying; the direct imports below are not a complete dependency closure.
Interactive examples require client-side React. This TanStack Start site server-renders and hydrates React; another host must hydrate or render the component on the client. Example code is the same TypeScript source used by its live preview.
Direct imports in this wrapper:
./field-target.stylex./select-parts.stylex./select.stylex@phosphor-icons/react@radix-ui/react-select@stylexjs/stylexreactreact-dom
Custom selection, validation and reset
Choose a local example window using grouped options with descriptions, then preview or reset it. Nothing is booked or saved. Select uses the installed Radix primitive with exported trigger, value, content and item parts; SelectContent includes its portal and a viewport with the platform scrollbar. Tab accepts the focused option and continues to the next control; Shift+Tab continues backward. Escape preserves the previous value. An option taller than the popup starts at its label; Page Up and Page Down scroll its details without changing selection. Put the option's primary text in SelectItemText and connect its description with aria-describedby. Use onValueChange for its string value and name for form data. Keep native option children and native change events on NativeSelect: previous native Select callers migrate to NativeSelect. Prefer NativeSelect for a short simple list. The custom trigger stays disabled with guidance until hydration; this noValidate example owns visible validation and focuses the trigger after an empty submission.
Read the complete example
import { exampleLayout } from "@/components/components/examples/example-layout.stylex";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { FormActions, FormError, FormField, FormHelp, FormLabel } from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemDescription,
SelectItemText,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const windows = [
{
value: "morning",
label: "Morning",
detail: "7–11 a.m. · The first visit of the day.",
group: "Daytime",
},
{
value: "midday",
label: "Midday",
detail: "11 a.m.–1 p.m. · Unavailable in this example.",
group: "Daytime",
disabled: true,
},
{
value: "afternoon",
label: "Afternoon",
detail: "1–5 p.m. · Keep the morning free.",
group: "Daytime",
},
{
value: "evening",
label: "Evening visit after the shop closes",
detail: "5–8 p.m. · Review the later arrival range before choosing.",
group: "Later",
},
];
export function JobSelectExample() {
const id = useId();
const trigger = useRef<HTMLButtonElement>(null);
const [ready, setReady] = useState(false);
const [value, setValue] = useState("");
const [error, setError] = useState(false);
const [preview, setPreview] = useState("");
useEffect(() => setReady(true), []);
const selected = windows.find((option) => option.value === value);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const submitted = new FormData(event.currentTarget).get("visit-window");
const option = windows.find((item) => item.value === submitted && !item.disabled);
if (!option) {
setError(true);
setPreview("");
trigger.current?.focus();
return;
}
setError(false);
setPreview(`Preview window: ${option.label}. Nothing was booked or saved.`);
}
return (
<form
aria-label="Visit window example"
aria-busy={!ready}
{...exampleLayout.stack}
noValidate
onSubmit={submit}
onReset={() => {
setValue("");
setError(false);
setPreview("");
}}
>
<FormField>
<FormLabel htmlFor={`${id}-window`}>Visit window</FormLabel>
<Select
name="visit-window"
value={value}
onValueChange={(next) => {
setValue(next);
setError(false);
setPreview("");
}}
required
disabled={!ready}
>
<SelectTrigger
ref={trigger}
id={`${id}-window`}
aria-invalid={error || undefined}
aria-describedby={`${id}-help`}
>
<SelectValue placeholder="Choose a window">{selected?.label}</SelectValue>
</SelectTrigger>
<SelectContent id={`${id}-window-options`}>
{(["Daytime", "Later"] as const).map((group, index) => (
<SelectGroup key={group}>
{index > 0 && <SelectSeparator />}
<SelectLabel>{group}</SelectLabel>
{windows
.filter((option) => option.group === group)
.map((option) => (
<SelectItem
key={option.value}
value={option.value}
textValue={option.label}
disabled={option.disabled}
aria-describedby={`${id}-${option.value}-detail`}
>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemDescription id={`${id}-${option.value}-detail`}>
{option.detail}
</SelectItemDescription>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{error ? (
<FormError id={`${id}-help`}>Choose an available visit window.</FormError>
) : (
<FormHelp id={`${id}-help`}>
{ready
? "Use arrow keys or type a name. Tab chooses the focused window and moves on; Shift+Tab moves back. Escape keeps the previous value. Page Up or Page Down scrolls a long option."
: "The interactive example is loading. If it remains unavailable, reload the page with JavaScript enabled."}
</FormHelp>
)}
</FormField>
<FormField>
<FormLabel htmlFor={`${id}-unavailable`}>Unavailable window</FormLabel>
<Select disabled>
<SelectTrigger id={`${id}-unavailable`} aria-describedby={`${id}-unavailable-help`}>
<SelectValue placeholder="No example windows" />
</SelectTrigger>
<SelectContent />
</Select>
<FormHelp id={`${id}-unavailable-help`}>
An empty option set stays unavailable and explains why.
</FormHelp>
</FormField>
<FormActions>
<Button type="submit" disabled={!ready}>
Preview window
</Button>
<Button type="reset" variant="ghost" disabled={!ready}>
Reset example
</Button>
</FormActions>
<p role="status" aria-live="polite">
{preview || "This example changes only its local preview."}
</p>
</form>
);
}
Adoption contract
- Start from the ShadCN accessible source primitive; keep upstream keyboard and focus behavior.
- Use Thorbis color, type, space, radius, shadow, and motion tokens only.
- Design hover, focus, selected, disabled, loading, empty, error, offline, and reduced-motion states where they apply.
- Verify desktop and 390px mobile, keyboard order, accessible names, and stable async geometry.