@lattice-ui/react-tabsStable directionimport Tabsdepends on runtime, focus, layer, motionTabs is the primitive for switching between mutually exclusive views: settings categories, inventory sections, shop pages, or any place where one panel is visible at a time. It owns the active value, keeps its triggers ordered, handles arrow-key movement between them, and mounts the matching panel with presence motion.
Reach for Tabs when a set of triggers should drive value-based selection — exactly one active at a time — with keyboard and gamepad movement across the list and panels that mount and unmount as the value changes.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { Tabs } from "@lattice-ui/react-tabs";import { buttonRecipe } from "../../../playground/src/client/theme/recipes";
type AccountTabKey = "account" | "password";
type FieldSpec = { label: string; value: string;};
function ExampleField(props: { field: FieldSpec; layoutOrder: number }) { const { theme } = useTheme(); const { field, layoutOrder } = props;
return ( <frame BackgroundTransparency={1} LayoutOrder={layoutOrder} Size={UDim2.fromOffset(280, 60)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Size={UDim2.fromOffset(280, 16)} Text={field.label} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} Position={UDim2.fromOffset(0, 22)} Size={UDim2.fromOffset(280, 38)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingLeft={new UDim(0, theme.space[12])} /> <Text BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={field.value} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> </frame> );}
function TabsExample() { const { theme } = useTheme(); const [tab, setTab] = React.useState<AccountTabKey>("account");
const panels: Array<{ value: AccountTabKey; action: string; fields: Array<FieldSpec> }> = [ { value: "account", action: "Save changes", fields: [ { label: "Name", value: "Jane Doe" }, { label: "Username", value: "@jane" }, ], }, { value: "password", action: "Update password", fields: [ { label: "Current password", value: "••••••••" }, { label: "New password", value: "••••••••" }, ], }, ];
const tabs: Array<{ value: AccountTabKey; label: string }> = [ { value: "account", label: "Account" }, { value: "password", label: "Password" }, ];
return ( <frame BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <Tabs.Root onValueChange={(nextValue) => setTab(nextValue as AccountTabKey)} value={tab}> <Tabs.List asChild> <frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} Size={UDim2.fromOffset(320, 40)}> <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[4])} PaddingLeft={new UDim(0, theme.space[4])} PaddingRight={new UDim(0, theme.space[4])} PaddingTop={new UDim(0, theme.space[4])} /> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, theme.space[4])} />
{tabs.map((entry, index) => { const selected = tab === entry.value; return ( <Tabs.Trigger asChild key={entry.value} value={entry.value}> <textbutton AutoButtonColor={false} BackgroundColor3={theme.colors.surfaceElevated} BackgroundTransparency={selected ? 0 : 1} BorderSizePixel={0} Font={selected ? Enum.Font.GothamMedium : Enum.Font.Gotham} LayoutOrder={index} Size={UDim2.fromOffset(154, 32)} Text={entry.label} TextColor3={selected ? theme.colors.textPrimary : theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} > <uicorner CornerRadius={new UDim(0, theme.radius.sm)} /> {selected ? <uistroke Color={theme.colors.border} Thickness={1} /> : undefined} </textbutton> </Tabs.Trigger> ); })} </frame> </Tabs.List>
{panels.map((panel) => ( <Tabs.Content asChild key={panel.value} value={panel.value}> <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Position={UDim2.fromOffset(0, 52)} Size={UDim2.fromOffset(320, 224)} > <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[20])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[20])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[12])} />
{panel.fields.map((field, index) => ( <ExampleField field={field} key={field.label} layoutOrder={index} /> ))}
<frame BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(280, 40)}> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "primary", size: "sm" }, theme), { AnchorPoint: new Vector2(1, 1), Position: UDim2.fromScale(1, 1), Size: UDim2.fromOffset(150, 36), Text: panel.action, TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> </textbutton> </frame> </frame> </Tabs.Content> ))} </Tabs.Root> </frame> );}Import
import { Tabs } from "@lattice-ui/react-tabs";Anatomy
Compose Root around a List of Triggers and one Content per value. Every Trigger and Content is tied to the active value through a shared value string.
<Tabs.Root> <Tabs.List> <Tabs.Trigger value="..." /> </Tabs.List> <Tabs.Content value="..." /></Tabs.Root>| Part | Required | Responsibility |
|---|---|---|
Tabs.Root | yes | Owns the active value, the trigger registry, and orientation; shares them through context. |
Tabs.List | yes | A container frame that groups the triggers. |
Tabs.Trigger | yes | A textbutton that selects its value on activation, selection, or Enter/Space. |
Tabs.Content | yes | A panel that mounts and animates while its value is active. |
Examples
Basic tabs
An uncontrolled tab group. defaultValue picks the starting panel, and the default Trigger renders a textbutton whose label is its value. The List and Content defaults are zero-size transparent frames, so pass asChild with your own sized elements to lay them out.
import { Tabs } from "@lattice-ui/react-tabs";
export function ShopTabs() { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 280)}> <Tabs.Root defaultValue="weapons"> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <Tabs.Trigger value="weapons" /> <Tabs.Trigger value="armor" /> <Tabs.Trigger value="potions" /> </frame> </Tabs.List>
<Tabs.Content value="weapons" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 220)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Weapons for sale" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content>
<Tabs.Content value="armor" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 220)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Armor for sale" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content>
<Tabs.Content value="potions" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 220)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Potions for sale" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content> </Tabs.Root> </frame> );}Controlled tabs
Drive the active value from your own state with value/onValueChange. Anything outside the tab group — a hotkey, a tutorial step, a deep link — can now switch panels by setting state.
import { useState } from "@rbxts/react";import { Tabs } from "@lattice-ui/react-tabs";
const CATEGORIES = ["general", "audio", "graphics"];
export function SettingsTabs() { const [tab, setTab] = useState("general");
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 280)}> <Tabs.Root value={tab} onValueChange={setTab}> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> {CATEGORIES.map((category) => ( <Tabs.Trigger key={category} value={category} /> ))} </frame> </Tabs.List>
{CATEGORIES.map((category) => ( <Tabs.Content key={category} value={category} asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 200)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={`${category} settings`} TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content> ))} </Tabs.Root> </frame> );}Vertical tabs
Set orientation="vertical" to move selection with Up/Down instead of Left/Right. Orientation is behavioral only — lay the list out yourself with a vertical uilistlayout.
import { Tabs } from "@lattice-ui/react-tabs";
export function QuestCategories() { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(460, 240)}> <Tabs.Root defaultValue="active" orientation="vertical"> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(140, 240)}> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, 4)} /> <Tabs.Trigger value="active" /> <Tabs.Trigger value="completed" /> <Tabs.Trigger value="daily" /> </frame> </Tabs.List>
<Tabs.Content value="active" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(150, 0)} Size={UDim2.fromOffset(310, 240)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="3 active quests" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content>
<Tabs.Content value="completed" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(150, 0)} Size={UDim2.fromOffset(310, 240)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="12 completed quests" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content>
<Tabs.Content value="daily" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(150, 0)} Size={UDim2.fromOffset(310, 240)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Daily quests reset in 4 hours" TextColor3={Color3.fromRGB(235, 240, 248)} /> </frame> </Tabs.Content> </Tabs.Root> </frame> );}Custom triggers with asChild
Pass asChild on Trigger to supply your own button while keeping registration, activation, and movement wiring. The trigger’s response motion still animates the child’s BackgroundColor3 and TextColor3 between the built-in active/inactive palette on selection.
import { Tabs } from "@lattice-ui/react-tabs";
function StyledTrigger(props: { value: string; label: string }) { return ( <Tabs.Trigger value={props.value} asChild> <textbutton AutoButtonColor={false} BackgroundColor3={Color3.fromRGB(47, 53, 68)} BorderSizePixel={0} Size={UDim2.fromOffset(110, 36)} Text={props.label} TextColor3={Color3.fromRGB(235, 240, 248)} TextSize={14} > <uicorner CornerRadius={new UDim(0, 8)} /> </textbutton> </Tabs.Trigger> );}
export function StyledTabs() { return ( <Tabs.Root defaultValue="stats"> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(360, 36)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} /> <StyledTrigger value="stats" label="Stats" /> <StyledTrigger value="gear" label="Gear" /> <StyledTrigger value="pets" label="Pets" /> </frame> </Tabs.List>
<Tabs.Content value="stats" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 44)} Size={UDim2.fromOffset(360, 180)} /> </Tabs.Content> <Tabs.Content value="gear" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 44)} Size={UDim2.fromOffset(360, 180)} /> </Tabs.Content> <Tabs.Content value="pets" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 44)} Size={UDim2.fromOffset(360, 180)} /> </Tabs.Content> </Tabs.Root> );}Disabled triggers
A disabled trigger cannot be activated, is skipped by arrow-key movement, and never becomes the active value. If the active value becomes disabled, Root moves the selection to the next enabled trigger automatically.
import { Tabs } from "@lattice-ui/react-tabs";
export function PrestigeTabs(props: { prestigeUnlocked: boolean }) { return ( <Tabs.Root defaultValue="skills"> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <Tabs.Trigger value="skills" /> <Tabs.Trigger value="talents" /> <Tabs.Trigger value="prestige" disabled={!props.prestigeUnlocked} /> </frame> </Tabs.List>
<Tabs.Content value="skills" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 200)} /> </Tabs.Content> <Tabs.Content value="talents" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 200)} /> </Tabs.Content> <Tabs.Content value="prestige" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 200)} /> </Tabs.Content> </Tabs.Root> );}Keeping panels mounted
By default a panel unmounts after its exit animation. Pass forceMount to keep it in the tree at all times — Tabs then only toggles its Visible property. Use this when a panel is expensive to rebuild, like a viewportframe world map.
import { Tabs } from "@lattice-ui/react-tabs";
export function MapTabs() { return ( <Tabs.Root defaultValue="inventory"> <Tabs.List asChild> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(420, 34)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} /> <Tabs.Trigger value="inventory" /> <Tabs.Trigger value="map" /> </frame> </Tabs.List>
<Tabs.Content value="inventory" asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 240)} /> </Tabs.Content>
<Tabs.Content value="map" forceMount asChild> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromOffset(0, 40)} Size={UDim2.fromOffset(420, 240)} > <viewportframe BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} /> </frame> </Tabs.Content> </Tabs.Root> );}How it behaves
Value state
Tabs.Root is controllable. Pass value and onValueChange to control it, or defaultValue to run uncontrolled. The active value is a plain string that each Trigger and Content matches against. onValueChange fires only with a defined value, so you never receive an undefined selection.
When the active value points at no enabled trigger — on first mount with no default, or after the selected trigger is removed or disabled — Root resolves a replacement: it falls back to the first enabled trigger, or to the next enabled trigger after the one that was last selected. This keeps a valid panel visible as triggers mount, unmount, or toggle their disabled state.
Trigger registration and selection
Each Tabs.Trigger registers itself with Root in mount order, exposing a stable order used to resolve fallbacks and arrow-key movement. A trigger becomes active in three ways: pointer activation, Roblox SelectionGained (so moving a gamepad cursor onto a trigger selects it immediately), and pressing Enter or Space while focused. Disabled triggers set Active and Selectable to false, are skipped during movement, and never become the active value.
Tabs.Trigger registers with the focus system through useFocusNode, so it participates in gamepad selection alongside other focusable nodes.
Orientation and arrow keys
orientation defaults to "horizontal". It controls which arrow keys move the selection: Left/Right when horizontal, Up/Down when vertical. Pressing a movement key focuses the next enabled trigger in that direction and selects it in one step. Orientation is purely behavioral — it does not lay out the List, so add your own uilistlayout (as in the examples) to position the triggers.
Panels and presence
Tabs.Content is tied to a value and is present only while that value is active. By default it mounts through a Presence boundary running a surface-reveal recipe, so the panel animates in when selected and animates out when another value takes over, unmounting after the exit completes. Override the recipe with transition. Pass forceMount to keep the panel mounted at all times — the content stays in the tree and toggles its Visible property based on the active value and motion phase.
The default Tabs.List and Tabs.Content render transparent frames sized UDim2.fromOffset(0, 0). They group and reveal children but do not lay anything out. In practice, pass asChild with your own sized frame, as every example above does, so panels and lists occupy real space.
The built-in Tabs.Trigger renders a 132x34 textbutton whose Text is its value, with response motion between an active and inactive color. With asChild, the same motion still drives your child’s BackgroundColor3 and TextColor3 between the built-in palette values whenever the selected state changes — plan your custom styling around that.
API reference
Tabs.Root
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled active value. Pair with onValueChange. |
| defaultValue | string | Initial active value for uncontrolled usage. When omitted, the first enabled trigger is selected. |
| onValueChange | (value: string) => void | Called whenever the active value changes. Always receives a defined value. |
| orientation | "horizontal" | "vertical" | Arrow-key movement axis. Defaults to "horizontal". |
| children | React.ReactNode | The List and Content parts. |
Tabs.List
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the list onto the single child element instead of the frame the part renders. |
| children | React.ReactNode | The trigger elements. Required as a single element when asChild is set. |
Tabs.Trigger
| Prop | Type | Description |
|---|---|---|
| value * | string | The value this trigger selects when activated. |
| asChild | boolean | Merge selection behavior onto the single child element instead of the textbutton the part renders. |
| disabled | boolean | Removes the trigger from selection and movement and prevents it from becoming active. Defaults to false. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Tabs.Content
| Prop | Type | Description |
|---|---|---|
| value * | string | The value this panel is shown for. |
| asChild | boolean | Merge the panel onto the single child element instead of the frame the part renders. |
| forceMount | boolean | Keeps the panel mounted at all times and toggles visibility instead of unmounting on exit. |
| transition | PresenceMotionConfig | Reveal/exit motion. None by default; pass createSurfaceRevealRecipe() for a rise-and-fade. |
| children | React.ReactNode | The panel contents. |