@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. It coordinates open state, the selected value, item registration, popper positioning and outside-press dismissal.
Your component renders a trigger, a value label and the items.
Preview
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 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.
Each Select.Item needs its label as a child — textValue draws nothing in the list, and
matters only for what Select.Value shows in the closed trigger.
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 as it happens. It fires once per accepted
selection, never for a disabled item, and only ever reports a string.
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 and are purely visual — registration order
and value resolution ignore them. All three render unstyled.
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, and is excluded from value resolution. A value pointing at
one is re-selected to the first enabled item the next time the items mount, reported 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 behaviour onto your own button, and the resolved text onto your
own label. The trigger slot merges Active, its handlers and its ref, so pass an activatable button
class. The Value slot drives the child’s Text, so its child must be text-bearing. Colour,
alignment and size stay 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}. With no room on the preferred side it flips
automatically, staying 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> );}Select is single-value only: value/defaultValue are a single string, not an array. Track multiple selections with separate controls or your own state.
How it behaves
Open state
Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled. Trigger
toggles on activation and on Return/Space, and selecting closes it. A disabled root blocks
opening only. Trigger takes its own disabled, combined with the root’s.
The trigger renders an unstyled textbutton, so give it a size and colours. With asChild, the
slot merges Active, the Activated and InputBegan handlers and its ref onto your child element.
Value and selection
The selected value is a single string, controlled via value/onValueChange or uncontrolled via
defaultValue. Each item registers its value, textValue, disabled state and document order on
mount, so while the content is open the root knows the full ordered set.
Selection is guarded twice. A disabled item ignores activation, and setValue refuses a value
resolving to one. The root also reconciles the value against the registry. With no matching
enabled item it re-selects the first enabled one, or clears the value when none is registered. The
re-selection is reported through onValueChange. The clear is not.
Items register only while mounted
Item registration is tied to component lifetime, and items live inside Select.Content, which
unmounts while closed unless forceMount is set. Two consequences:
- Run the value controlled. With the content closed the registry is empty, so reconciliation clears uncontrolled value state —
defaultValueincluded. Controlledvalueis unaffected. textValueonly resolves while items are mounted. Closed,Select.Valuefalls back to the raw value string. Keep value strings display-ready, or passforceMountonContent.
The value label
Select.Value resolves its text through a chain: the registered item’s textValue, then the raw
value string, then placeholder. Writing Text is this part’s behaviour, so it owns that
property and only that one — it does not dim while showing the placeholder.
Positioning
Select.Content is positioned with popper against the trigger. placement picks the side,
sideOffset pushes it away, alignOffset slides it along the cross axis, and collisionPadding
keeps it inside the viewport. Until the first measurement it is parked offscreen, so you never see
an unpositioned frame.
Dismissal
Select.Content is non-modal: interaction behind it is not blocked, but an outside press closes it.
Use onPointerDownOutside and onInteractOutside to observe first. A focus scope wraps the content
and restores focus on close, but does not trap it.
Motion and presence
Select.Content runs no motion of its own. Pass a transition:
createPopperEntranceRecipe(placement) matches the frame it renders, on the default path and
under asChild, travelling from the resolved placement side. forceMount keeps it mounted while
closed and through its exit.
Items animate their own hover state from MouseEnter/MouseLeave and gamepad
SelectionGained/SelectionLost, skipped while disabled. Under asChild the slot merges Active,
the activation and hover handlers, and its ref onto your child.
Select.Portal renders into a ScreenGui on the player’s PlayerGui. Use container and displayOrderBase to target a specific GUI and order it against other layers. Items track gamepad hover state, but Select does not install native directional selection or keyboard navigation between items. The trigger and items render with Selectable set to false. Build gamepad list traversal with your own selectable elements 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. |