@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. It owns the active value, keeps its triggers ordered, handles arrow-key movement between them, and mounts the matching panel with presence motion.
Exactly one trigger is active at a time, with keyboard and gamepad movement across the list and panels that mount and unmount as the value changes.
Preview
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, each 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 labelled with its value. List and Content default to zero-size
transparent frames, so pass asChild with your own sized elements.
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, so a hotkey, a tutorial
step or a deep link can switch panels.
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. It is behavioural
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 palette.
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 selection to the next enabled
trigger.
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. forceMount keeps it in the tree and only
toggles Visible — worth it when a panel is expensive to rebuild.
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
Pass value and onValueChange to control it, or defaultValue to run uncontrolled. The active
value is a plain string each Trigger and Content matches against, and onValueChange fires only
with a defined value.
The active value can point at no enabled trigger, on first mount with no default or after the
selected trigger is removed or disabled. Root then falls back to the first enabled trigger, or the
next enabled one after whichever was last selected.
Trigger registration and selection
Each Tabs.Trigger registers with Root in mount order, exposing a stable order used for
fallbacks and arrow-key movement. A trigger becomes active on pointer activation, on
SelectionGained, or on Enter/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" and controls which arrow keys move the selection. Pressing
a movement key focuses the next enabled trigger in that direction and selects it in one step. It
does not lay out the List, so add your own uilistlayout.
Panels and presence
Tabs.Content is present only while its value is active. By default it mounts through a
Presence boundary running a surface-reveal recipe, and unmounts after the exit completes. Override
it with transition. forceMount keeps the panel in the tree, toggling Visible from 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 lay nothing out. Pass asChild with your own sized frame 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 colour. With asChild the same motion drives your child’s BackgroundColor3 and TextColor3 between those palette values — plan your 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. |