@lattice-ui/react-comboboxStable directionimport Comboboxdepends on runtime, layer, motion, popperCombobox is the primitive for a filterable, single-value picker: searchable selects, command palettes, autocomplete fields. It owns three pieces of state and keeps them in sync: the selected value, the input text and the open state. It also repairs a selection that no longer resolves to an enabled item.
Preview
import { Combobox } from "@lattice-ui/react-combobox";import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { menuItemRecipe, panelRecipe } from "../../../playground/src/client/theme/recipes";
const frameworks = ["Next.js", "SvelteKit", "Nuxt", "Remix", "Astro"];
function ComboboxExample() { const { theme } = useTheme();
return ( <frame BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <Combobox.Root> <frame BackgroundTransparency={1} Position={UDim2.fromOffset(0, 0)} Size={UDim2.fromOffset(240, 64)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Size={UDim2.fromOffset(240, 16)} Text="Framework" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} />
<Combobox.Input asChild placeholder="Search framework…"> <textbox BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} ClearTextOnFocus={false} PlaceholderColor3={theme.colors.textSecondary} Position={UDim2.fromOffset(0, 24)} Size={UDim2.fromOffset(240, 40)} Text="" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingLeft={new UDim(0, theme.space[12])} PaddingRight={new UDim(0, theme.space[12])} /> </textbox> </Combobox.Input> </frame>
<Combobox.Portal> <Combobox.Content asChild placement="bottom" sideOffset={6}> <frame {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { AutomaticSize: Enum.AutomaticSize.Y, Size: UDim2.fromOffset(240, 0), }) 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])} />
{frameworks.map((framework) => ( <Combobox.Item asChild key={framework} textValue={framework} value={framework}> <textbutton {...(mergeGuiProps(menuItemRecipe({ intent: "default", disabled: "false" }, theme), { Size: UDim2.fromOffset(228, 32), Text: framework, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> <uipadding PaddingLeft={new UDim(0, theme.space[8])} /> </textbutton> </Combobox.Item> ))} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> </frame> );}Import
import { Combobox } from "@lattice-ui/react-combobox";Anatomy
Root, Portal, Content and Item form the working picker. Use either Input (type-to-filter)
or Trigger + Value as the anchor, and Group, Label and Separator to structure longer
lists.
Combobox anatomy
<Combobox.Root> <Combobox.Trigger> <Combobox.Value /> </Combobox.Trigger> <Combobox.Input /> <Combobox.Portal> <Combobox.Content> <Combobox.Label /> <Combobox.Group> <Combobox.Item value="..." /> </Combobox.Group> <Combobox.Separator /> <Combobox.Item value="..." /> </Combobox.Content> </Combobox.Portal></Combobox.Root>| Part | Required | Responsibility |
|---|---|---|
Combobox.Root | yes | Owns value, input, and open state, plus the item registry and filter function. |
Combobox.Input | no | A TextBox that opens the list on focus and drives the query as the user types. Acts as the anchor. |
Combobox.Trigger | no | A button that toggles the list and acts as the anchor when no input is present. |
Combobox.Value | no | A label that displays the selected item’s text (or a placeholder). |
Combobox.Portal | yes | Renders the listbox into a ScreenGui outside the local tree. |
Combobox.Content | yes | The positioned, dismissable, motion-driven listbox. |
Combobox.Item | yes | A selectable option that registers its value and text, and hides itself when filtered out. |
Combobox.Group | no | A container that visually groups related items. |
Combobox.Label | no | A non-interactive heading for a group or section. |
Combobox.Separator | no | A thin divider between items or groups. |
Examples
Basic filtered list
An uncontrolled root, an input textbox as the anchor, and items on a plain list surface. Typing narrows the list in place. Selecting sets the value, fills the field and closes the list.
textValue does not render as the item’s label, so each item is given a Text of its own. It still
drives filtering and the resolved value label, and defaults to value.
import { Combobox } from "@lattice-ui/react-combobox";
const BIOMES = ["Ashlands", "Frostpeak", "Gladewood", "Mirelow", "Sunspire"];
export function BiomePicker() { return ( <Combobox.Root defaultValue="Gladewood" onValueChange={(value) => print(`picked: ${value}`)}> <Combobox.Input placeholder="Search biomes" />
<Combobox.Portal> <Combobox.Content sideOffset={4}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> {BIOMES.map((biome) => ( <Combobox.Item key={biome} Text={biome} value={biome} /> ))} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> );}Controlled value and input
The two are independent: value/onValueChange tracks the committed selection,
inputValue/onInputValueChange the field text. Expect onInputValueChange to fire for
programmatic syncs too — selecting sets the input to the item’s text, and closing re-syncs it from
the value.
import { useState } from "@rbxts/react";import { Combobox } from "@lattice-ui/react-combobox";
const QUESTS = ["Cinder Trial", "Echo Vault", "Gale Run", "Hollow March"];
export function QuestSearch() { const [value, setValue] = useState<string>(); const [inputValue, setInputValue] = useState("");
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 80)}> <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Combobox.Root value={value} onValueChange={setValue} inputValue={inputValue} onInputValueChange={setInputValue} > <Combobox.Input placeholder="Search quests" />
<Combobox.Portal> <Combobox.Content sideOffset={4}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> {QUESTS.map((quest) => ( <Combobox.Item key={quest} Text={quest} value={quest} /> ))} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root>
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(260, 18)} Text={value !== undefined ? `Tracking: ${value}` : "No quest tracked"} TextColor3={Color3.fromRGB(168, 176, 191)} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> );}Pass defaultValue, defaultInputValue or defaultOpen to let Combobox own its state. All three are independently controllable — control only what something outside needs to drive.
Custom filter function
The root’s filterFn decides whether each item matches the query, defaulting to a case-insensitive
substring match. Define it at module scope or memoize it — it is shared through context, and a new
function every render churns every item.
import { Combobox } from "@lattice-ui/react-combobox";import type { ComboboxFilterFn } from "@lattice-ui/react-combobox";
const COMMANDS = ["Teleport", "Trade", "Track quest", "Toggle HUD"];
const prefixFilter: ComboboxFilterFn = (itemText, query) => { return string.sub(string.lower(itemText), 1, query.size()) === string.lower(query);};
export function CommandPalette() { return ( <Combobox.Root filterFn={prefixFilter} onValueChange={(command) => print(`run: ${command}`)}> <Combobox.Input placeholder="Type a command" />
<Combobox.Portal> <Combobox.Content sideOffset={4}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> {COMMANDS.map((command) => ( <Combobox.Item key={command} Text={command} value={command} /> ))} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> );}Groups and labels
Group, Label and Separator are purely visual. They do not affect filtering or selection, and a
label stays visible even when every item under it is filtered out. All three render unstyled.
import { Combobox } from "@lattice-ui/react-combobox";
const AMERICAS = ["Chicago", "Dallas", "Sao Paulo"];const EUROPE = ["Frankfurt", "London", "Warsaw"];
export function ServerRegionPicker() { return ( <Combobox.Root> <Combobox.Input placeholder="Search regions" />
<Combobox.Portal> <Combobox.Content sideOffset={4}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Combobox.Group asChild> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> <Combobox.Label asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 18)} Text="Americas" TextColor3={Color3.fromRGB(168, 176, 191)} TextSize={13} TextXAlignment={Enum.TextXAlignment.Left} /> </Combobox.Label> {AMERICAS.map((region) => ( <Combobox.Item key={region} Text={region} value={region} /> ))} </frame> </Combobox.Group>
<Combobox.Separator />
<Combobox.Group asChild> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> <Combobox.Label asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 18)} Text="Europe" TextColor3={Color3.fromRGB(168, 176, 191)} TextSize={13} TextXAlignment={Enum.TextXAlignment.Left} /> </Combobox.Label> {EUROPE.map((region) => ( <Combobox.Item key={region} Text={region} value={region} /> ))} </frame> </Combobox.Group> </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> );}Empty state when nothing matches
Filtering hides items one by one, so the listbox stays open even when nothing matches, and there is
no built-in empty-state part. To show “no matches”, control inputValue, mirror the filtering with
the exported filterComboboxOptions, and render your own label when the count hits zero.
import { useState } from "@rbxts/react";import { Combobox, filterComboboxOptions } from "@lattice-ui/react-combobox";
const SPELLS = ["Arc Nova", "Ember Coil", "Frost Lattice", "Stone Ward"];const SPELL_OPTIONS = SPELLS.map((spell) => ({ value: spell, disabled: false, textValue: spell }));
export function SpellSearch() { const [inputValue, setInputValue] = useState(""); const matchCount = filterComboboxOptions(SPELL_OPTIONS, inputValue).size();
return ( <Combobox.Root inputValue={inputValue} onInputValueChange={setInputValue}> <Combobox.Input placeholder="Search spells" />
<Combobox.Portal> <Combobox.Content sideOffset={4}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(240, 0)} > <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> {SPELLS.map((spell) => ( <Combobox.Item key={spell} Text={spell} value={spell} /> ))} {matchCount === 0 && ( <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 32)} Text="No spells match" TextColor3={Color3.fromRGB(153, 161, 177)} /> )} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> );}Custom input styling with asChild
Use asChild on Combobox.Input to project the input behaviour onto your own textbox. The
primitive drives Text, TextEditable, PlaceholderText, ClearTextOnFocus, the change handler
and the anchor ref. Keep placeholder on the part, and put your styling in colours, strokes,
corners and padding.
import { Combobox } from "@lattice-ui/react-combobox";
const COSMETICS = ["Aurora Cape", "Drift Visor", "Ember Trail", "Void Crown"];
export function StyledCosmeticSearch() { return ( <Combobox.Root> <Combobox.Input placeholder="Search cosmetics" asChild> <textbox BackgroundColor3={Color3.fromRGB(24, 26, 32)} BorderSizePixel={0} Size={UDim2.fromOffset(260, 40)} TextColor3={Color3.fromRGB(236, 241, 249)} TextSize={15} TextXAlignment={Enum.TextXAlignment.Left} > <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)} /> </textbox> </Combobox.Input>
<Combobox.Portal> <Combobox.Content sideOffset={6}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundColor3={Color3.fromRGB(28, 32, 42)} Size={UDim2.fromOffset(260, 0)} > <uicorner CornerRadius={new UDim(0, 8)} /> <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} /> {COSMETICS.map((cosmetic) => ( <Combobox.Item key={cosmetic} Text={cosmetic} value={cosmetic} /> ))} </frame> </Combobox.Content> </Combobox.Portal> </Combobox.Root> );}How it behaves
Open state
Pass open and onOpenChange to control the listbox, or defaultOpen to run uncontrolled.
Trigger toggles on activation and on Return/Space. Input opens the list on focus, where an
empty query matches every item, and typing keeps it open. Selecting closes it, and a disabled root
refuses to open but can still close.
Value and input state
Value and input text are separate, independently controllable state. Selecting sets the value and syncs the input to that item’s display text, ignoring a disabled item. Typing updates the query and opens the list without changing the value. Closing re-syncs the input from the value.
While the list is open, a selected value that no longer resolves to an enabled registered item is
replaced with the first enabled one. The repair never fills an empty selection, and
onValueChange fires only for defined values.
Combobox.Value displays the selected item’s text from the registry, or its placeholder.
disabled blocks all state changes. readOnly blocks input edits but still allows selection
through items.
Filtering
Filtering is per-item: each item evaluates the root’s filterFn(textValue, query) and a
non-matching one sets its own Visible to false and becomes non-interactive, under asChild too.
The content never removes or reorders nodes, so your layout collapses around hidden items.
The active query is what the user typed. Selecting syncs the field text without changing the open list’s query, so the list does not collapse to the selected item. Closing re-syncs the query from the field text, so reopening shows the list filtered by the selection.
The default filter is a case-insensitive substring match. defaultComboboxFilter,
filterComboboxOptions and resolveComboboxInputValue are exported as plain functions for
mirroring the filtering in your own logic.
Positioning
Combobox.Content is positioned by the popper foundation against the active anchor, the input when
present and otherwise the trigger. It flips to the opposite side on collision. Tune it with
placement (default "bottom"), sideOffset (default 0), alignOffset (default 0) and
collisionPadding (default 8).
Dismissal
Combobox.Content is always non-modal, so the rest of the UI stays interactive. The trigger and
input are registered as inside refs. Any other outside press closes the list. Use
onPointerDownOutside and onInteractOutside to observe or veto first.
Motion and presence
Combobox.Content runs no motion of its own. Pass a transition to animate it — createPopperEntranceRecipe(placement) matches the frame the content renders and animates from the resolved placement. forceMount keeps the content mounted through its exit (useful when you drive motion yourself or need the node to persist).
Combobox reconciles the three automatically. Selecting syncs the input, closing re-syncs it from the value, and an invalid selection is repaired against the registry while the list is open. Mixing a controlled value with an uncontrolled input is fully supported.
Each item toggles its own visibility, so the listbox stays open and mounted even when nothing matches. Detect zero matches with filterComboboxOptions over your own option data and render your own label — see the empty state example.
required is exposed on context for your own validation wiring and changes no interaction. The value repair runs regardless and never fills an empty value, so validate value !== undefined yourself before submit.
API reference
Combobox.Root
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled selected value. Pair with onValueChange. |
| defaultValue | string | Initial selected value for uncontrolled usage. |
| onValueChange | (value: string) => void | Called whenever the selected value changes. Fires only for defined values. |
| inputValue | string | Controlled input text. Pair with onInputValueChange. |
| defaultInputValue | string | Initial input text for uncontrolled usage. Defaults to an empty string. |
| onInputValueChange | (inputValue: string) => void | Called whenever the input text changes, including programmatic syncs from selection and close. |
| open | boolean | Controlled open state of the listbox. Pair with onOpenChange. |
| defaultOpen | boolean | Initial open state for uncontrolled usage. Defaults to false. |
| onOpenChange | (open: boolean) => void | Called whenever the open state changes. |
| disabled | boolean | Disables the whole combobox, blocking opening, input edits, and selection. Defaults to false. |
| readOnly | boolean | Blocks input edits while still allowing selection through items. Defaults to false. |
| required | boolean | Marks the combobox as required for your own form/validation wiring. Exposed on context; does not change interaction on its own. Defaults to false. |
| filterFn | (itemText: string, query: string) => boolean | Decides whether an item matches the query. Defaults to a case-insensitive substring match. Keep its identity stable. |
| children | React.ReactNode | The combobox parts. |
Combobox.Input
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge input behavior onto the single child element instead of the textbox the part renders. The child must be a textbox. |
| disabled | boolean | Disables the input. Combined with the root's disabled state. |
| readOnly | boolean | Blocks edits to the input text. Combined with the root's readOnly state. |
| placeholder | string | Placeholder text shown when the input is empty. Defaults to "Type to filter". |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Combobox.Trigger
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge trigger behavior onto the single child element instead of the textbutton the part renders. |
| disabled | boolean | Prevents the trigger from toggling the list. Combined with the root's disabled state. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Combobox.Value
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the resolved value onto the single child element instead of the textlabel the part renders. |
| 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. |
Combobox.Portal
| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Target PlayerGui to render the listbox 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. |
Combobox.Content
| Prop | Type | Description |
|---|---|---|
| placement | "top" | "bottom" | "left" | "right" | Requested side to position the listbox on. Flips on collision. Defaults to "bottom". |
| sideOffset | number | Gap in pixels between the anchor and the listbox. Defaults to 0. |
| alignOffset | number | Shift in pixels along the anchor's cross axis. Defaults to 0. |
| collisionPadding | number | Minimum distance in pixels to keep from the screen edge. Defaults to 8. |
| asChild | boolean | Render the single child element inside the positioned wrapper instead of the frame the part renders. |
| forceMount | boolean | Keeps the listbox mounted while exit motion runs. |
| transition | PresenceMotionConfig | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. |
| onPointerDownOutside | (event: LayerInteractEvent) => void | Called when a pointer press occurs outside the listbox, before dismissal. |
| onInteractOutside | (event: LayerInteractEvent) => void | Called for any other outside interaction, before dismissal. |
| children | React.ReactNode | The listbox contents. |
Combobox.Item
| Prop | Type | Description |
|---|---|---|
| value | string | Required. The value selected when this item is chosen. |
| textValue | string | Text used for filtering and for the resolved value label. Defaults to value. Since 0.7.0 it does not render as the item's own label — supply that as a child or via a forwarded Text prop. |
| disabled | boolean | Prevents selection and excludes the item from open-list value repair. |
| asChild | boolean | Merge item behavior onto the single child element instead of the textbutton the part renders. The child's Visible property is bound to the filter match. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Combobox.Group
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the group onto the single child element instead of the frame the part renders. |
| children | React.ReactElement | The grouped items to render. Required when asChild is set. |
Combobox.Label
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the label 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. |
Combobox.Separator
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the separator 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. |