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

Source: https://docs.astra-void.xyz/lattice-ui/components/select/

`@lattice-ui/react-select` · Feature limited · import `Select` · depends 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.

_Interactive preview._

## Import

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

```tsx title="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](#the-value-label)); it draws nothing in the list.

```tsx title="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.

```tsx title="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`.

```tsx title="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`.

```tsx title="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.

```tsx title="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.

```tsx title="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>
  );
}
```

> **Single value only**
>
> 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. 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.

> **Roblox gotchas**
>
> `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` (required) | `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. |

## Related

- [Positioning with Popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
