Latticecomponents

Select

Single-value selection primitive that owns open and value state, registers items in order, and anchors popper-positioned content in a portal while you own the visuals.

@lattice-ui/react-selectFeature limitedimport Selectdepends on runtime, focus, layer, motion, popper

Select 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.

Edit

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 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>
PartRequiredResponsibility
Select.RootyesOwns open + value state and the item registry, shared through context.
Select.TriggeryesA button that toggles the content open and closed.
Select.ValuenoRenders the selected item’s text, or a placeholder when nothing is chosen.
Select.PortalyesRenders the content into a ScreenGui outside the local tree.
Select.ContentyesThe popper-positioned, dismissable list surface.
Select.ItemyesA selectable option that registers itself and sets the value on activation.
Select.GroupnoA non-semantic container for grouping related items.
Select.LabelnoA heading for a group.
Select.SeparatornoA 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 childtextValue 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.

RegionSelect.tsx
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.

GraphicsQualitySelect.tsx
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.

SortSelect.tsx
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.

ServerTierSelect.tsx
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.

LoadoutSelect.tsx
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.

HudSortSelect.tsx
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>
);
}

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. Controlled value is unaffected: the clear never overrides your prop and never calls onValueChange.
  • textValue only resolves while items are mounted. With the content closed, Select.Value cannot look up the selected item’s textValue and falls back to the raw value string. Either keep value strings display-ready (as in the examples above) or pass forceMount on Content to 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.

API reference

Select.Root

PropTypeDescription
valuestringControlled selected value. Pair with onValueChange. Values resolving to disabled items re-select the first enabled item once items mount.
defaultValuestringInitial value for uncontrolled usage. Only honored while a matching enabled item is mounted — prefer controlled value, or forceMount the content.
onValueChange(value: string) => voidCalled once per accepted selection, including fallback re-selection away from a disabled value. Never called with undefined.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanInitial open state for uncontrolled usage. Defaults to false.
onOpenChange(open: boolean) => voidCalled when the open state changes.
disabledbooleanDisables the whole select: the trigger cannot open and values cannot change. An already-open content can still close. Defaults to false.
requiredbooleanMarks the select as required; surfaced through context for consumer use. Does not change interaction on its own. Defaults to false.
childrenReact.ReactNodeThe select parts.

Select.Trigger

PropTypeDescription
asChildbooleanMerge 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.
disabledbooleanPrevents this trigger from opening the select, in addition to the root's disabled state. Defaults to false.
childrenReact.ReactNodeRendered inside the trigger button (typically a Select.Value). Must be a single element when asChild is set.

Select.Value

PropTypeDescription
asChildbooleanDrive 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.
placeholderstringText shown when no value is selected. Defaults to an empty string.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Select.Portal

PropTypeDescription
containerBasePlayerGuiTarget PlayerGui to render the content into. Defaults to the surrounding portal context's container.
displayOrderBasenumberBase DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value.
childrenReact.ReactNodeThe content part.

Select.Content

PropTypeDescription
placement"top" | "bottom" | "left" | "right"Preferred side to anchor the content against the trigger; flips automatically on collision. Defaults to "bottom".
sideOffsetnumberGap in pixels between the trigger and the content along the placement axis. Defaults to 0.
alignOffsetnumberOffset in pixels along the cross axis from the aligned edge. Defaults to 0.
collisionPaddingnumberMinimum distance in pixels to keep from the viewport edges when repositioning. Defaults to 8.
transitionPresenceMotionConfigReveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, under asChild as well.
forceMountbooleanKeeps the content — and therefore the item registry — mounted while closed and through exit motion.
asChildbooleanRender 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) => voidCalled when a pointer press occurs outside the content, before dismissal.
onInteractOutside(event: LayerInteractEvent) => voidCalled for any other outside interaction, before dismissal.
childrenReact.ReactNodeThe list surface contents.

Select.Item

PropTypeDescription
value *stringThe value this item selects when activated.
textValuestringText 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.
disabledbooleanPrevents 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.
asChildbooleanMerge item behavior (Active, activation and hover handlers, ref) onto the single child element instead of the textbutton the part renders.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Select.Group

PropTypeDescription
asChildbooleanMerge onto the single child element instead of the frame the part renders.
childrenReact.ReactElementThe grouped items (and optional label).

Select.Label

PropTypeDescription
asChildbooleanMerge onto the single child element instead of the textlabel the part renders.
childrenReact.ReactElementThe label element to render. Required when asChild is set.

Select.Separator

PropTypeDescription
asChildbooleanMerge onto the single child element instead of the frame the part renders.
childrenReact.ReactElementThe divider element to render. Required when asChild is set.