Latticecomponents

Tabs

A tablist owning the active value, with arrow-key movement and presence-revealed panels.

@lattice-ui/react-tabsStable directionimport Tabsdepends on runtime, focus, layer, motion

Tabs 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

Edit

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 anatomy
<Tabs.Root>
<Tabs.List>
<Tabs.Trigger value="..." />
</Tabs.List>
<Tabs.Content value="..." />
</Tabs.Root>
PartRequiredResponsibility
Tabs.RootyesOwns the active value, the trigger registry, and orientation; shares them through context.
Tabs.ListyesA container frame that groups the triggers.
Tabs.TriggeryesA textbutton that selects its value on activation, selection, or Enter/Space.
Tabs.ContentyesA 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.

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

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

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

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

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

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

API reference

Tabs.Root

PropTypeDescription
valuestringControlled active value. Pair with onValueChange.
defaultValuestringInitial active value for uncontrolled usage. When omitted, the first enabled trigger is selected.
onValueChange(value: string) => voidCalled whenever the active value changes. Always receives a defined value.
orientation"horizontal" | "vertical"Arrow-key movement axis. Defaults to "horizontal".
childrenReact.ReactNodeThe List and Content parts.

Tabs.List

PropTypeDescription
asChildbooleanMerge the list onto the single child element instead of the frame the part renders.
childrenReact.ReactNodeThe trigger elements. Required as a single element when asChild is set.

Tabs.Trigger

PropTypeDescription
value *stringThe value this trigger selects when activated.
asChildbooleanMerge selection behavior onto the single child element instead of the textbutton the part renders.
disabledbooleanRemoves the trigger from selection and movement and prevents it from becoming active. Defaults to false.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Tabs.Content

PropTypeDescription
value *stringThe value this panel is shown for.
asChildbooleanMerge the panel onto the single child element instead of the frame the part renders.
forceMountbooleanKeeps the panel mounted at all times and toggles visibility instead of unmounting on exit.
transitionPresenceMotionConfigReveal/exit motion. None by default; pass createSurfaceRevealRecipe() for a rise-and-fade.
childrenReact.ReactNodeThe panel contents.