Latticecomponents

Tabs

Tablist primitive that owns the active value, registers triggers in order, moves selection with the arrow keys, and reveals panels with presence motion.

@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, 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.

Edit

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

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. Anything outside the tab group — a hotkey, a tutorial step, a deep link — can now switch panels by setting state.

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. Orientation is behavioral 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 active/inactive palette on selection.

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 the selection to the next enabled trigger automatically.

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

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

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.

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.