Skip to content
ThorbisInterface Guidelines (opens in a new tab)
All components

Select

A trigger-owned popup for choosing one value from a bounded option set.

Adopted
Family
Choice
Available import
@/components/ui/select
Status
Thorbis wrapper and live specimen ship here.
Live Thorbis specimenselect

The interactive example is loading. If it remains unavailable, reload the page with JavaScript enabled.

An empty option set stays unavailable and explains why.

This example changes only its local preview.

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
  • Select
  • SelectContent
  • SelectGroup
  • SelectItem
  • SelectItemDescription
  • SelectItemText
  • SelectLabel
  • SelectSeparator
  • SelectTrigger
  • SelectValue
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/stylex
  • react
  • react-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.

The interactive example is loading. If it remains unavailable, reload the page with JavaScript enabled.

An empty option set stays unavailable and explains why.

This example changes only its local preview.

Example source
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

  1. Start from the ShadCN accessible source primitive; keep upstream keyboard and focus behavior.
  2. Use Thorbis color, type, space, radius, shadow, and motion tokens only.
  3. Design hover, focus, selected, disabled, loading, empty, error, offline, and reduced-motion states where they apply.
  4. Verify desktop and 390px mobile, keyboard order, accessible names, and stable async geometry.
Official ShadCN component directory

Applied bookmark research

Reviewed 2026-09-06. These examples reinforce the existing owners; they do not introduce another visual style or claim the product behavior is implemented. Read the 26-source review and access limits.

A control keeps its meaning through every state

A button that changes width, a field that loses input, or a dialog that loses focus breaks the task even when the resting design looks precise.

Review the approved primitive as a sequence: resting, focused, activated, pending, resolved, and recovered. Keep the action label and footprint stable, associate errors with their fields, and preserve a visible keyboard path.

Thorbis example. Save estimate remains Save estimate while its state mark changes. A validation error keeps the entered values, explains the repair beside the field, and moves focus only when the interaction calls for it.

Verify. Test Tab, Shift-Tab, Enter, Escape where applicable, paste, invalid input, rapid activation, and a slow response. Repeat at 390px, in both themes, with zoom and reduced motion.

Do not import. No hand-built duplicate primitive, hidden label, disabled browser zoom, or borrowed shadow-only outer edge that violates D-017.

Open the ui skill · Evidence: Web Interface Guidelines; Invisible Details; components.build; Details that make interfaces feel better

Transfer the intent, not the desktop gesture

An interaction that works with a precise pointer or modifier key may be unusable for a tech holding a phone.

Preserve the record and task while adapting the visible action path to the device. Use the existing native owner for target sizing, safe areas, focus, and host behavior; no critical action relies only on hover or a hidden gesture.

Thorbis example. A field photo remains attached to the same job when the tech changes orientation or returns from another app. The visible capture action and retry path remain available without discovering a desktop shortcut.

Verify. Check one-handed use, software keyboard opening, text enlargement, orientation, focus visibility, and reduced motion on the relevant device. Keep permission refusal and return-from-interruption states in the review.

Do not import. No desktop hover contract on a phone, blocked zoom, silent clipboard access, or unverified claim that a web imitation is native behavior.

Open the native skill · Evidence: Web Interface Guidelines; Design Engineer Principles; Devouring Details

Loading search…

Loading search. You can close this message and keep reading.