Select
Reach for Select when a value comes from a short, fixed set of options and the user picks exactly one: a time zone, a plan, a deploy region. It renders a button that opens a listbox, keeps the chosen label in the trigger, and submits through a hidden input when it sits in a form. It has typeahead but no filtering — for lists long enough to need a search field, use Combobox instead.
Pass items to <Select.Root> so <Select.Value> renders the option's label
instead of the raw value — without it the trigger above would read
Europe/London. The popup opens over the trigger with the selected row's text
sitting on the trigger's text; that is alignItemWithTrigger, on by default —
see Placement for the trade-off and how to turn it off.
Import
import { Select } from "@forte-ui/react";Examples
Labelling
<Select.Label> is the labelling pattern to reach for. It renders a <div>
wired to the trigger with aria-labelledby, so clicking it focuses the trigger
without opening the popup.
Grouped options
<Select.Group> renders role="group" and takes its accessible name from the
<Select.GroupLabel> inside it, so screen readers announce the heading along
with its members. The group label is indented past the check-mark column so its
text lines up with the item text.
Placement
By default the popup overlaps the trigger, with the selected row's text landing
on the trigger's own text — alignItemWithTrigger, which is what a native
<select> does on macOS. Pass alignItemWithTrigger={false} to drop the popup
below the trigger instead, the way a Menu opens.
Aligned placement puts the current value under the pointer at the moment the popup opens, so re-picking the same option is a click without a journey. It costs the trigger: the popup covers it, and it needs room above and below, which is why Base UI drops back to side placement for touch input and when the viewport is too tight — so it is the mode users see least consistently. Menu placement never covers the trigger and behaves identically everywhere, which is what you want for a select sitting in a dense toolbar or next to a value the user is comparing against.
The two modes animate differently, and that is not a knob. Aligned mode has no
side to rise from, so it unfurls out of the selected row with
--transform-origin pinned there; side placement unfolds away from the trigger
edge and adds a short slide when it lands on an inline side. side, align,
sideOffset and alignOffset apply only to the second — Base UI ignores all
four while the popup is aligned.
Sizes
size sets the trigger's height, inline padding and font size — nothing else.
The popup is unaffected: items keep their own type scale and their
--forte-target-min (24px) minimum row height whatever size the trigger is.
Variants
variant changes only how much weight the trigger carries: outline reads as
a form control, soft as a filled field, ghost as an inline affordance.
Disabled items
A disabled item stays in the list and stays announced, so the option remains
discoverable — it just cannot be chosen. Typeahead skips it, matching what a
native <select> does. Say in the item's own text why it is unavailable;
greying alone tells the user nothing.
Accessibility
| Key | Behaviour |
|---|---|
| Enter | With the trigger focused, opens the popup and highlights the selected item. With the popup open, selects the highlighted item and closes. |
| Space | Same as Enter, except while typeahead is mid-word, where it types a space instead of selecting. |
| Arrow Down | Opens the popup from the trigger; moves the highlight down one item once open. |
| Arrow Up | Opens the popup from the trigger; moves the highlight up one item once open. |
| Home | Highlights the first item. |
| End | Highlights the last item. |
| A–Z | Typeahead. With the popup open, highlights the first item whose text matches what you type; with it closed, it changes the value directly, like a native select. Disabled items are skipped and the buffer resets 750ms after the last keystroke. |
| Esc | Closes the popup without changing the value and returns focus to the trigger. |
The highlight does not wrap: Arrow Down on the last item and Arrow Up on the first stay put rather than cycling.
data-highlighted is the focus analogue for items. Base UI moves real DOM
focus between options with a roving tabindex, so the highlighted item is the
focused element, and pointer hover sets the same attribute (highlightItemOnHover
on <Select.Root>, on by default — set it to false if you need CSS :hover
to be distinguishable from the focused state). Style rows off
[data-highlighted] rather than :hover, or keyboard users get no highlight
at all.
The scroll arrows at the top and bottom of a long popup are a pointer
convenience only. Base UI does not render them for touch input, marks them
aria-hidden, and hides them once the list cannot scroll further — the list
itself scrolls by wheel, drag, keyboard and typeahead, so no item is ever
reachable only through an arrow. Turning them off with scrollArrows={false}
removes nothing but the affordance.
Theming
Every --forte-select-* property below is declared on the surface that owns it,
and an element's own declaration beats an inherited value — so setting one on
:root, on a theme scope or on any ancestor has no effect. The popup is
portalled to <body> as well, so an ancestor of the trigger is not even in
its tree. Set each property on the part that declares it, through that part's
className (an unlayered rule beats the library's @layer forte.components,
whatever its specificity) or an inline style:
Select.Labeldeclares the fourlabel-*properties.Select.Triggerdeclares everytrigger-*property.Select.Popupdeclares thepopup-*properties plus--forte-select-item-px,--forte-select-item-gapand--forte-select-item-indicator-size— the layout knobs the list, the items and the group labels read but never redeclare, so the popup is the right place to set them.positionerClassNamereaches--forte-select-z-indexand--forte-select-positioner-min-height, and nothing else: those two are the only properties declared on the positioner.Select.Itemdeclares--forte-select-item-fg,--forte-select-item-bg-highlighted,--forte-select-item-fg-highlighted,--forte-select-item-fg-selectedand--forte-select-item-font-size.--forte-select-backdrop-z-index,--forte-select-list-padding-yand--forte-select-scroll-arrow-sizeare declared on the backdrop, the list and the scroll arrows — elementsSelect.Popuprenders internally and exposes no class name for, so they cannot be retargeted from outside.
The global tokens these resolve to — --forte-color-*, --forte-control-*,
--forte-radius-*, --forte-space-* — are the exception: the component only reads
those and never redeclares them, so re-pointing them on :root or on a theme
scope does re-skin every select.
| Property | Controls | Default |
|---|---|---|
--forte-select-label-fg | Label text colour | var(--forte-color-foreground) |
--forte-select-label-font-size | Label font size | var(--forte-font-size-2) |
--forte-select-label-font-weight | Label font weight | var(--forte-font-weight-medium) |
--forte-select-label-gap | Space between label and trigger | var(--forte-space-1) |
--forte-select-trigger-height | Trigger height (reset per size) | var(--forte-control-h-md) |
--forte-select-trigger-padding-x | Trigger inline padding (reset per size) | var(--forte-control-px-md) |
--forte-select-trigger-font-size | Trigger font size (reset per size) | var(--forte-font-size-2) |
--forte-select-trigger-gap | Gap between value and icon | var(--forte-control-gap) |
--forte-select-trigger-radius | Trigger corner radius | var(--forte-radius-control) |
--forte-select-trigger-border-width | Trigger border width | 1px |
--forte-select-trigger-bg | Trigger background (reset per variant) | var(--forte-color-background) |
--forte-select-trigger-bg-hover | Trigger background on hover | var(--forte-color-panel-hover) |
--forte-select-trigger-bg-active | Trigger background while pressed or open | var(--forte-color-panel-active) |
--forte-select-trigger-fg | Trigger text colour | var(--forte-color-foreground) |
--forte-select-trigger-border-color | Trigger border colour (reset per variant) | var(--forte-color-border) |
--forte-select-backdrop-z-index | Stacking of the optional backdrop layer | 49 |
--forte-select-z-index | Stacking of the positioner | 50 |
--forte-select-positioner-min-height | How tall the popup must be able to get before alignItemWithTrigger falls back to side placement — smaller falls back less often | calc(var(--forte-control-h-md) * 2.5) |
--forte-select-popup-bg | Popup surface colour | var(--forte-color-overlay) |
--forte-select-popup-fg | Popup text colour | var(--forte-color-foreground) |
--forte-select-popup-radius | Popup corner radius | var(--forte-radius-surface) |
--forte-select-popup-shadow | Popup shadow | var(--forte-shadow-4) |
--forte-select-popup-border-width | Width of the hairline that separates the popup from the page | 1px |
--forte-select-popup-border-color | Colour of that hairline | var(--forte-color-border) |
--forte-select-popup-overhang | Extra width the popup claims past the trigger in alignItemWithTrigger mode | var(--forte-space-5) |
--forte-select-item-px | Inline padding shared by items and group labels | var(--forte-space-3) |
--forte-select-item-gap | Gap between the indicator column and the item text | var(--forte-control-gap) |
--forte-select-item-indicator-size | Width of the check-mark column | var(--forte-space-4) |
--forte-select-list-padding-y | Block padding inside the scrolling list | var(--forte-space-1) |
--forte-select-scroll-arrow-size | Height of a scroll arrow | var(--forte-space-5) |
--forte-select-item-fg | Item text colour | var(--forte-color-foreground) |
--forte-select-item-bg-highlighted | Background of the highlighted item | var(--forte-color-primary-soft) |
--forte-select-item-fg-highlighted | Text colour of the highlighted item | var(--forte-color-primary-text) |
--forte-select-item-fg-selected | Text colour of the selected item | var(--forte-color-primary-text) |
--forte-select-item-font-size | Item font size | var(--forte-font-size-2) |
API reference
Select.Root
Groups every part and owns the value and open state. It renders no DOM element,
so it takes neither className nor ref, and it forwards Base UI's own
Select.Root props unchanged — including value, defaultValue,
onValueChange, items, multiple, name, disabled, readOnly,
required, modal and highlightItemOnHover. Both generics are preserved, so
<Select.Root<Theme>> types onValueChange and defaultValue for you.
Select.Label
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Select.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
fullWidth | boolean | false | Stretch the trigger to fill the width of its container. |
size | SelectSize | md | Size of the trigger. Actual dimensions also follow the ambient `data-forte-density` setting. |
variant | SelectVariant | outline | How much visual weight the trigger carries. `outline` reads as a form control, `soft` as a filled field, `ghost` as an inline affordance. |
Select.Value
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Select.Icon
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | <CaretUpDownIcon /> | Icon to render. Defaults to a caret pair, which stays correct whichever side the popup ends up on — including `alignItemWithTrigger` mode, where it opens over the trigger and has no side at all. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Select.Popup
Renders the whole floating half of the anatomy — portal, optional backdrop, positioner, popup, scroll arrows and the scrolling list — as one part.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How the popup aligns along the chosen side. Ignored while `alignItemWithTrigger` is in effect. |
alignItemWithTrigger | boolean | true | Whether the popup overlaps the trigger so the selected item's text lines up with the trigger's value text. While this is active `data-side` is `"none"` on both the positioner and the popup, `side`/`align`/`sideOffset` are ignored, and the enter/exit transition is suppressed. Base UI also disables it automatically for touch input and when the viewport is too tight, in which case ordinary side placement takes over. |
alignOffset | number | OffsetFunction | 0 | Extra offset in pixels along the alignment axis. Ignored while `alignItemWithTrigger` is in effect. |
backdrop | boolean | false | Render a dimming layer behind the popup. Off by default — a select is a menu, not a dialog — but useful on small screens where the popup covers most of the page. |
children | ReactNode | The items, separators and groups to render. They are placed inside `<Select.List>`, which is the element that actually scrolls. | |
className | string | Additional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`. | |
collisionPadding | Padding | 5 | Space to keep between the popup and the edge of its collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Render the popup into a different container instead of `<body>`. | |
finalFocus | boolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null) | the trigger | Where focus goes when the popup closes. There is no `initialFocus` counterpart on a select popup — Base UI always moves focus to the highlighted item on open. |
positionerClassName | string | Additional class name(s) for the positioner — the absolutely positioned wrapper around the popup. Use this for `z-index` or a `min-height` that tunes how often `alignItemWithTrigger` falls back. | |
scrollArrows | boolean | true | Render the hover-to-scroll arrows at the top and bottom of the list. They are a pointer convenience only: Base UI does not render them for touch input, and the list scrolls by wheel, drag, keyboard and typeahead regardless, so no item is ever reachable only through them. |
side | Side | bottom | Which side of the trigger the popup opens on. Ignored while `alignItemWithTrigger` is in effect. |
sideOffset | number | OffsetFunction | 4 | Gap in pixels between the trigger and the popup. Ignored while `alignItemWithTrigger` is in effect. |
Select.Item
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The item's label. Rendered inside `<Select.ItemText>`, which is what `<Select.Value>` mirrors in the trigger and what typeahead matches on. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
disabled | boolean | false | Whether the item ignores user interaction. Disabled items stay in the list and stay announced; typeahead skips them. |
indicator | ReactNode | <CheckIcon /> | What marks the item as selected. Rendered inside `<Select.ItemIndicator>`, which only mounts while the item is selected. |
label | string | Plain-text label used for typeahead when `children` is not a string. | |
value | unknown | null | The value this item selects. `null` is a legitimate value and is how you offer a "clear the selection" row inside the list. |
Select.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Select.GroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Select.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
orientation | Orientation | horizontal | Orientation of the rule. A select list is vertical, so the separator between two groups runs horizontally. |