Skip to main content
Gremorie

Select

Dropdown chooser for short fixed lists - compound primitive built on Radix Select with portaled, animated, and scroll-aware content.

Overview

Select is a compound primitive built on @radix-ui/react-select. The trigger stays in the page flow, while the listbox is portalled to document.body, animated, and scrolls automatically when overflowing. Use it for short fixed lists where the user picks exactly one value.

For lists longer than ~10 items, reach for a Combobox so users can type to filter. For boolean values use Switch or Checkbox. For 2-5 mutually exclusive options where icon affordances make sense, use ToggleGroup with type="single".

Preview

Installation

bash npx gremorie@latest add rx-select
bash pnpm dlx gremorie@latest add rx-select
bash yarn dlx gremorie@latest add rx-select
bash bunx --bun gremorie@latest add rx-select

Usage

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@gremorie/rx-forms";

export function Example() {
  return (
    <Select>
      <SelectTrigger>
        <SelectValue placeholder="Pick a primitive" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="message">Message</SelectItem>
        <SelectItem value="reasoning">Reasoning</SelectItem>
        <SelectItem value="tool">Tool</SelectItem>
      </SelectContent>
    </Select>
  );
}

Angular edition planned for Phase 5h. Star the repo to track progress.

API

<Select>

Root provider. Owns the selected value and open state.

PropTypeDefaultDescription
valuestring-Controlled value.
defaultValuestring-Uncontrolled initial value.
onValueChange(value: string) => void-Fires when the user picks an option.
openboolean-Controlled open state.
onOpenChange(open: boolean) => void-Fires when the listbox opens or closes.
disabledbooleanfalseDisables the trigger.
namestring-Form field name; submits the selected value with surrounding <form>.
requiredbooleanfalseMarks the underlying form input as required.

Forwards to SelectPrimitive.Root.

<SelectTrigger>

The visible button users click to open the listbox.

PropTypeDefaultDescription
size"sm" | "default""default"sm is h-8; default is h-9.

Renders the trigger surface and a trailing ChevronDownIcon.

<SelectValue>

PropTypeDefaultDescription
placeholderReactNode-Rendered when the value is empty.

Reads from the parent Select context and renders the current value.

<SelectContent>

The portalled listbox. Animates in / out and respects --radix-select-content-available-height.

PropTypeDefaultDescription
position"item-aligned" | "popper""item-aligned"item-aligned matches the selected item to the trigger; popper floats below the trigger like a Popover.
align"start" | "center" | "end""center"Cross-axis alignment of the popover.
sideOffsetnumber0Distance between trigger and content.

Wraps SelectScrollUpButton + SelectPrimitive.Viewport + SelectScrollDownButton so long lists scroll naturally.

<SelectItem>

PropTypeDefaultDescription
valuestring-The value reported back to the parent Select. Required.
disabledbooleanfalseWhen true, removes the item from the active set.

Renders a CheckIcon indicator (visible only when selected) and the children as the item text.

<SelectGroup> + <SelectLabel>

Group related items under a non-selectable label. Always render SelectItem inside a SelectGroup.

<SelectSeparator>

Decorative 1px line between groups.

<SelectScrollUpButton> / <SelectScrollDownButton>

Auto-included by SelectContent so you usually don't render them directly. Show up when the listbox is taller than the viewport.

Composition

  1. <Select> is the root context.
  2. <SelectTrigger> + <SelectValue> is the visible affordance the user clicks.
  3. <SelectContent> is the portalled listbox - wraps a Viewport with scroll buttons.
  4. <SelectItem> always goes inside <SelectGroup> - this is how Radix announces grouping to assistive tech. If your list has no logical groups, wrap all items in one default <SelectGroup> anyway.
  5. <SelectLabel> names a group; <SelectSeparator> divides groups.

Variations

Simple list

For small fixed lists with no groups.

<Select>
  <SelectTrigger>
    <SelectValue placeholder="Pick a primitive" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectItem value="message">Message</SelectItem>
      <SelectItem value="reasoning">Reasoning</SelectItem>
      <SelectItem value="tool">Tool</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Grouped with labels and separator

When items fall into distinct buckets, label and separate them so users can scan.

<Select>
  <SelectTrigger>
    <SelectValue placeholder="Pick a region" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>North America</SelectLabel>
      <SelectItem value="us-east">US East (Virginia)</SelectItem>
      <SelectItem value="us-west">US West (Oregon)</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectGroup>
      <SelectLabel>Europe</SelectLabel>
      <SelectItem value="eu-central">EU Central (Frankfurt)</SelectItem>
      <SelectItem value="eu-west">EU West (Dublin)</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Controlled with form name

For form submission. name makes the value participate in the surrounding <form> payload.

function ControlledSelect() {
  const [model, setModel] = React.useState('opus');
  return (
    <Select value={model} onValueChange={setModel} name="model">
      <SelectTrigger>
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectItem value="opus">Claude Opus</SelectItem>
          <SelectItem value="sonnet">Claude Sonnet</SelectItem>
          <SelectItem value="haiku">Claude Haiku</SelectItem>
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}

Popper position

Switch position="popper" when the listbox should float below the trigger (rather than aligning the selected item with the trigger). Useful for very long lists or when the trigger sits near the top of the page.

<Select>
  <SelectTrigger>
    <SelectValue placeholder="Choose..." />
  </SelectTrigger>
  <SelectContent position="popper" sideOffset={4}>
    <SelectGroup>
      {longList.map((item) => (
        <SelectItem key={item.value} value={item.value}>
          {item.label}
        </SelectItem>
      ))}
    </SelectGroup>
  </SelectContent>
</Select>

Accessibility

  • ARIA combobox pattern: Radix wires the full role="combobox" + aria-expanded + aria-controls + listbox / option dance.
  • Keyboard:
    • Space / Enter / ArrowDown opens the listbox from the trigger.
    • ArrowUp / ArrowDown moves between items.
    • Home / End jump to first / last item.
    • Typing characters performs typeahead search.
    • Esc closes the listbox without changing the value.
  • Focus: trigger gets a 3px focus-visible ring; the open listbox returns focus to the trigger on close.
  • Scroll: long lists show scroll-up / scroll-down buttons automatically. Pointing at them auto-scrolls; pressing Enter or clicking them scrolls in steps.
  • Disabled items are announced as disabled and skipped by keyboard navigation.
  • Portal: the listbox is portalled to document.body, so it escapes parent overflow clipping without you opting in.
  • Button Group - mix SelectTrigger with Buttons in the same cluster
  • Toggle Group - icon-led single-select for 2-5 options
  • Radio Group - label-led single-select that always shows all options
  • Form - wire Select into react-hook-form via FormField + Controller

On this page