@lattice-ui/react-selectFeature limitedimport Selectdepends on runtime, focus, layer, motion, popperSelect is the primitive for picking one value from a list: difficulty pickers, region menus, sort dropdowns, and any “choose one” control. It coordinates open state, the selected value, item registration, popper positioning, and outside-press dismissal so your component only has to render a trigger, a value label, and the items.
Reach for Select when a control needs to hold a single value, open an anchored popup over a trigger, and dismiss predictably on selection or an outside interaction.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { Select } from "@lattice-ui/react-select";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { panelRecipe } from "../../../playground/src/client/theme/recipes";
const sounds = ["Chime", "Echo", "Pulse", "Ripple", "Off"];
function SelectExample() { const { theme } = useTheme();
return ( <frame BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Size={UDim2.fromOffset(240, 16)} Text="Notification sound" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} />
<Select.Root> <Select.Trigger asChild> <textbutton AutoButtonColor={false} BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Position={UDim2.fromOffset(0, 24)} Size={UDim2.fromOffset(240, 40)} Text="" > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <Select.Value asChild placeholder="Select a sound"> <textlabel BackgroundTransparency={1} Position={UDim2.fromOffset(12, 0)} Size={UDim2.fromOffset(196, 40)} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </Select.Value> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(212, 0)} Size={UDim2.fromOffset(16, 40)} Text="▾" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} /> </textbutton> </Select.Trigger>
<Select.Portal> <Select.Content asChild placement="bottom" sideOffset={6}> <frame {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { Size: UDim2.fromOffset(240, 208), }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[6])} PaddingLeft={new UDim(0, theme.space[6])} PaddingRight={new UDim(0, theme.space[6])} PaddingTop={new UDim(0, theme.space[6])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[2])} />
<Select.Label asChild> <Text BackgroundTransparency={1} Size={UDim2.fromOffset(228, 24)} Text="Sounds" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </Text> </Select.Label>
{sounds.map((sound) => ( <Select.Item asChild key={sound} textValue={sound} value={sound}> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} Size={UDim2.fromOffset(228, 32)} Text={sound} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </textbutton> </Select.Item> ))} </frame> </Select.Content> </Select.Portal> </Select.Root> </frame> );}Import
import { Select } from "@lattice-ui/react-select";Anatomy
Root, Trigger, Portal, Content, and at least one Item form the minimum useful select. Value, Group, Label, and Separator are optional and help you structure the trigger label and the list.
Select anatomy
<Select.Root> <Select.Trigger> <Select.Value /> </Select.Trigger> <Select.Portal> <Select.Content> <Select.Group> <Select.Label /> <Select.Item value="..." /> <Select.Separator /> <Select.Item value="..." /> </Select.Group> </Select.Content> </Select.Portal></Select.Root>| Part | Required | Responsibility |
|---|---|---|
Select.Root | yes | Owns open + value state and the item registry, shared through context. |
Select.Trigger | yes | A button that toggles the content open and closed. |
Select.Value | no | Renders the selected item’s text, or a placeholder when nothing is chosen. |
Select.Portal | yes | Renders the content into a ScreenGui outside the local tree. |
Select.Content | yes | The popper-positioned, dismissable list surface. |
Select.Item | yes | A selectable option that registers itself and sets the value on activation. |
Select.Group | no | A non-semantic container for grouping related items. |
Select.Label | no | A heading for a group. |
Select.Separator | no | A thin divider between items or groups. |
Examples
Basic select
A controlled root, a trigger button with a Value label inside, and a handful of items on a plain list surface.
Two things this example has to supply that older versions did for you: each Select.Item needs its label as a child — textValue no longer renders — and every part needs its own styling. textValue still matters, but only for what Select.Value shows in the closed trigger (see How it behaves); it draws nothing in the list.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
const REGIONS = ["NA East", "NA West", "Europe", "Asia"];
export function RegionSelect() { const [region, setRegion] = useState<string>();
return ( <Select.Root value={region} onValueChange={setRegion}> <Select.Trigger BackgroundColor3={Color3.fromRGB(32, 36, 46)} Size={UDim2.fromOffset(220, 36)} > <uicorner CornerRadius={new UDim(0, 6)} /> <uipadding PaddingLeft={new UDim(0, 10)} /> <Select.Value placeholder="Select a region" Size={UDim2.fromScale(1, 1)} TextColor3={region ? Color3.fromRGB(236, 241, 249) : Color3.fromRGB(140, 148, 166)} TextXAlignment={Enum.TextXAlignment.Left} /> </Select.Trigger>
<Select.Portal> <Select.Content> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} />
{REGIONS.map((value) => ( <Select.Item key={value} Size={UDim2.fromOffset(220, 32)} Text={value} TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} value={value} > <uipadding PaddingLeft={new UDim(0, 10)} /> </Select.Item> ))} </frame> </Select.Content> </Select.Portal> </Select.Root> );}Saving a setting on change
Wire value/onValueChange to persist a selection the moment it happens — a graphics quality setting written back to your settings store on every change. onValueChange fires once per accepted selection: presses on disabled items never reach it, and it only ever reports a string, so the handler is a safe place to save.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
const QUALITY_LEVELS = ["Low", "Medium", "High", "Ultra"];
export function GraphicsQualitySelect(props: { savedQuality: string; onSave: (quality: string) => void;}) { const [quality, setQuality] = useState(props.savedQuality);
return ( <Select.Root value={quality} onValueChange={(value) => { setQuality(value); props.onSave(value); }} > <Select.Trigger> <Select.Value placeholder="Graphics quality" /> </Select.Trigger>
<Select.Portal> <Select.Content> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> {QUALITY_LEVELS.map((level) => ( <Select.Item key={level} Text={level} value={level} /> ))} </frame> </Select.Content> </Select.Portal> </Select.Root> );}Grouped options
Group, Label, and Separator structure a longer list. Groups are purely visual — item registration order and value resolution ignore them. All three render unstyled: Group is a bare frame with no layout of its own, Label renders no copy (pass Text), and Separator draws nothing until you give it a Size and BackgroundColor3.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
export function SortSelect() { const [sort, setSort] = useState<string>("Newest");
return ( <Select.Root value={sort} onValueChange={setSort}> <Select.Trigger> <Select.Value /> </Select.Trigger>
<Select.Portal> <Select.Content> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} />
<Select.Group asChild> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> <Select.Label asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(220, 20)} Text="Date" TextColor3={Color3.fromRGB(168, 176, 191)} TextSize={13} TextXAlignment={Enum.TextXAlignment.Left} /> </Select.Label> <Select.Item Text="Newest" value="Newest" /> <Select.Item Text="Oldest" value="Oldest" /> </frame> </Select.Group>
<Select.Separator />
<Select.Group asChild> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> <Select.Label asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(220, 20)} Text="Rarity" TextColor3={Color3.fromRGB(168, 176, 191)} TextSize={13} TextXAlignment={Enum.TextXAlignment.Left} /> </Select.Label> <Select.Item Text="Rarest first" value="Rarest first" /> <Select.Item Text="Common first" value="Common first" /> </frame> </Select.Group> </frame> </Select.Content> </Select.Portal> </Select.Root> );}Disabled options
A disabled item stays visible but inert: pressing it does nothing — the value is unchanged and the content stays open — and it skips its hover motion. Disabled items are also excluded from value resolution, so if the current value points at one (here, a premium tier after the pass expires), the root re-selects the first enabled item the next time the items mount and reports it through onValueChange.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
export function ServerTierSelect(props: { hasPremium: boolean }) { const [tier, setTier] = useState<string>("Standard");
return ( <Select.Root value={tier} onValueChange={setTier}> <Select.Trigger> <Select.Value /> </Select.Trigger>
<Select.Portal> <Select.Content> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> <Select.Item Text="Standard" value="Standard" /> <Select.Item Text="Large" value="Large" /> <Select.Item disabled={!props.hasPremium} Text="Premium (pass required)" textValue="Premium (pass required)" value="Premium" /> </frame> </Select.Content> </Select.Portal> </Select.Root> );}Custom trigger and value with asChild
Use asChild to project the trigger behavior onto your own button and the resolved text onto your own label. The trigger slot merges its Active state, its Activated/InputBegan handlers, and its ref onto your child, so pass an activatable textbutton or imagebutton. The Value slot drives the child’s Text property with the resolved label — placeholder while nothing is selected, the matching item’s textValue otherwise — so its child must be a text-bearing element; everything else about the label (color, alignment, size) is yours.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
export function LoadoutSelect() { const [loadout, setLoadout] = useState<string>();
return ( <Select.Root value={loadout} onValueChange={setLoadout}> <Select.Trigger asChild> <textbutton AutoButtonColor={false} BackgroundColor3={Color3.fromRGB(32, 36, 47)} BorderSizePixel={0} Size={UDim2.fromOffset(240, 40)} Text="" > <uicorner CornerRadius={new UDim(0, 8)} /> <uistroke Color={Color3.fromRGB(88, 142, 255)} Thickness={1} /> <uipadding PaddingLeft={new UDim(0, 12)} PaddingRight={new UDim(0, 12)} />
<Select.Value placeholder="Pick a loadout" asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(216, 40)} TextColor3={Color3.fromRGB(235, 241, 248)} TextSize={15} TextXAlignment={Enum.TextXAlignment.Left} /> </Select.Value> </textbutton> </Select.Trigger>
<Select.Portal> <Select.Content> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(240, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> <Select.Item Text="Assault" value="Assault" /> <Select.Item Text="Recon" value="Recon" /> <Select.Item Text="Support" value="Support" /> </frame> </Select.Content> </Select.Portal> </Select.Root> );}Tuning placement and offsets
Content anchors to the trigger with popper defaults of placement="bottom", sideOffset={0}, alignOffset={0}, and collisionPadding={8}. A select sitting in a bottom HUD bar should open upward instead — and if there is not enough room on the preferred side, the content flips to the opposite side automatically, staying at least collisionPadding pixels from the viewport edges.
import { useState } from "@rbxts/react";import { Select } from "@lattice-ui/react-select";
export function HudSortSelect() { const [sort, setSort] = useState<string>("Rarity");
return ( <Select.Root value={sort} onValueChange={setSort}> <Select.Trigger> <Select.Value /> </Select.Trigger>
<Select.Portal> <Select.Content placement="top" sideOffset={8} collisionPadding={16}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(220, 0)} > <uilistlayout SortOrder={Enum.SortOrder.LayoutOrder} /> <Select.Item Text="Rarity" value="Rarity" /> <Select.Item Text="Power" value="Power" /> <Select.Item Text="Newest" value="Newest" /> </frame> </Select.Content> </Select.Portal> </Select.Root> );}This release of Select is single-value only. There is no multi-select mode, and value/defaultValue are a single string, not an array. Track multiple selections with separate controls or your own state until multi-select lands.
How it behaves
Open state
Select.Root is controllable. Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled (defaults to false). Select.Trigger toggles open on activation and on Return/Space, and selecting an item closes it. A disabled root blocks opening only — an already-open content can still close, so dismissal keeps working if you disable the select while it is open. Select.Trigger also takes its own disabled prop, which combines with the root’s.
The trigger renders an unstyled textbutton — give it a size and colors. With asChild, the slot merges Active, the Activated and InputBegan handlers, and its ref onto your single child element, so use an activatable button class.
Value and selection
The selected value is a single string. Select.Root is controllable via value/onValueChange, or uncontrolled via defaultValue. Each Select.Item registers itself with the root on mount — recording its value, textValue, disabled state, and document order — so while the content is open the root knows the full ordered set of options. Activating an item (click, Return, or Space) sets the value and closes the content.
Selection is guarded twice: a disabled item ignores activation outright (no value change, content stays open), and setValue refuses any value that resolves to a disabled registered item. On top of that, the root continuously reconciles the current value against the registry: if the value has no matching enabled registered item, it re-selects the first enabled item — or clears the value when no enabled item is registered. The fallback re-selection is reported through onValueChange; the clear is not, so onValueChange only ever receives a string.
Items register only while mounted
Item registration is tied to component lifetime, and items live inside Select.Content, which unmounts while the select is closed (unless forceMount is set). Two practical consequences:
- Run the value controlled. With the content closed the registry is empty, so the reconciliation described above clears uncontrolled value state — including
defaultValue, which is dropped before the content ever opens. Controlledvalueis unaffected: the clear never overrides your prop and never callsonValueChange. textValueonly resolves while items are mounted. With the content closed,Select.Valuecannot look up the selected item’stextValueand falls back to the raw value string. Either keep value strings display-ready (as in the examples above) or passforceMountonContentto keep items registered while closed.
The value label
Select.Value resolves its text through a chain: the registered item’s textValue, then the raw value string, then placeholder (default "") when no value is selected. Writing Text is this part’s behavior, so it owns that property in both modes — but only that one. It no longer dims its color while showing the placeholder; branch on your own state if you want a muted placeholder. With asChild, the slot drives your child’s Text, so pass a text-bearing element.
Positioning
Select.Content is positioned with popper, anchored to the trigger. Control the side with placement ("top" | "bottom" | "left" | "right", defaulting to "bottom"), push it away from the trigger with sideOffset (default 0), slide it along the cross axis with alignOffset (default 0), and keep it inside the viewport with collisionPadding (default 8). The content is measured after mount and flipped to the opposite side automatically when it would collide with a viewport edge; until the first measurement completes it is parked offscreen, so you never see an unpositioned frame.
Dismissal
Select.Content participates in dismissable-layer behavior in non-modal mode: interaction behind it is not blocked, but an outside press closes it. Use onPointerDownOutside and onInteractOutside to observe those interactions before the content dismisses. While open, a focus scope wraps the content and restores focus to the previously focused element on close; it does not trap focus.
Motion and presence
Select.Content runs no motion of its own. Pass a transition to animate it: createPopperEntranceRecipe(placement) matches the frame the content renders, on the default path and under asChild where your own element replaces it. It travels from the resolved placement side. forceMount keeps the content mounted while closed and through its exit.
Items animate their own hover state: MouseEnter/MouseLeave and gamepad SelectionGained/SelectionLost drive a background-color settle using the selection response recipe, skipped while the item is disabled. With asChild on an item, the slot merges Active, the activation and hover handlers, and its ref onto your child element.
Select.Portal renders into a ScreenGui on the player’s PlayerGui, not the local component tree; use container and displayOrderBase to target a specific GUI and order it against other layers. Items respond to gamepad SelectionGained/SelectionLost for hover state, but Select does not install Roblox native directional selection or keyboard navigation between items — the trigger and items render with Selectable set to false (the asChild slot pins it too), so build gamepad list traversal with your own selectable elements around the primitive if you need it.
API reference
Select.Root
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled selected value. Pair with onValueChange. Values resolving to disabled items re-select the first enabled item once items mount. |
| defaultValue | string | Initial value for uncontrolled usage. Only honored while a matching enabled item is mounted — prefer controlled value, or forceMount the content. |
| onValueChange | (value: string) => void | Called once per accepted selection, including fallback re-selection away from a disabled value. Never called with undefined. |
| open | boolean | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | Initial open state for uncontrolled usage. Defaults to false. |
| onOpenChange | (open: boolean) => void | Called when the open state changes. |
| disabled | boolean | Disables the whole select: the trigger cannot open and values cannot change. An already-open content can still close. Defaults to false. |
| required | boolean | Marks the select as required; surfaced through context for consumer use. Does not change interaction on its own. Defaults to false. |
| children | React.ReactNode | The select parts. |
Select.Trigger
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge trigger behavior (Active, Activated/InputBegan handlers, ref) onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| disabled | boolean | Prevents this trigger from opening the select, in addition to the root's disabled state. Defaults to false. |
| children | React.ReactNode | Rendered inside the trigger button (typically a Select.Value). Must be a single element when asChild is set. |
Select.Value
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Drive the single child element's Text property with the resolved label instead of the textlabel the part renders. The child must be a text-bearing element. |
| placeholder | string | Text shown when no value is selected. Defaults to an empty string. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Select.Portal
| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Target PlayerGui to render the content into. Defaults to the surrounding portal context's container. |
| displayOrderBase | number | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. |
| children | React.ReactNode | The content part. |
Select.Content
| Prop | Type | Description |
|---|---|---|
| placement | "top" | "bottom" | "left" | "right" | Preferred side to anchor the content against the trigger; flips automatically on collision. Defaults to "bottom". |
| sideOffset | number | Gap in pixels between the trigger and the content along the placement axis. Defaults to 0. |
| alignOffset | number | Offset in pixels along the cross axis from the aligned edge. Defaults to 0. |
| collisionPadding | number | Minimum distance in pixels to keep from the viewport edges when repositioning. Defaults to 8. |
| transition | PresenceMotionConfig | Reveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, under asChild as well. |
| forceMount | boolean | Keeps the content — and therefore the item registry — mounted while closed and through exit motion. |
| asChild | boolean | Render onto the single child element instead of the frame the part renders. createPopperEntranceRecipe fits either path; supply a canvasgroup here if you want the whole subtree to fade as one layer. |
| onPointerDownOutside | (event: LayerInteractEvent) => void | Called when a pointer press occurs outside the content, before dismissal. |
| onInteractOutside | (event: LayerInteractEvent) => void | Called for any other outside interaction, before dismissal. |
| children | React.ReactNode | The list surface contents. |
Select.Item
| Prop | Type | Description |
|---|---|---|
| value * | string | The value this item selects when activated. |
| textValue | string | Text Select.Value shows when this item is selected. Defaults to value. Resolves only while the item is mounted. Since 0.7.0 it does not render as the item's own label — supply that as a child. |
| disabled | boolean | Prevents selection and removes the item from value resolution; a value pointing at a disabled item re-resolves to the first enabled item. Defaults to false. |
| asChild | boolean | Merge item behavior (Active, activation and hover handlers, ref) onto the single child element instead of the textbutton the part renders. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Select.Group
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge onto the single child element instead of the frame the part renders. |
| children | React.ReactElement | The grouped items (and optional label). |
Select.Label
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge onto the single child element instead of the textlabel the part renders. |
| children | React.ReactElement | The label element to render. Required when asChild is set. |
Select.Separator
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge onto the single child element instead of the frame the part renders. |
| children | React.ReactElement | The divider element to render. Required when asChild is set. |