Latticecomponents

Select

Single-value selection with registered items and popper-positioned content in a portal.

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

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

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.

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 as it happens. It fires once per accepted selection, never for a disabled item, and only ever reports a string.

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 and are purely visual — registration order and value resolution ignore them. All three render unstyled.

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

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

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}. With no room on the preferred side it flips automatically, staying 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

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 — defaultValue included. Controlled value is unaffected.
  • textValue only resolves while items are mounted. Closed, Select.Value falls back to the raw value string. Keep value strings display-ready, or pass forceMount on Content.

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.

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.