# Tabs

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

Source: https://docs.astra-void.xyz/lattice-ui/components/tabs/

`@lattice-ui/react-tabs` · Stable direction · import `Tabs` · depends 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.

_Interactive preview._

## Import

```ts
import { Tabs } from "@lattice-ui/react-tabs";
```

## Anatomy

Compose `Root` around a `List` of `Trigger`s and one `Content` per value. Every `Trigger` and `Content` is tied to the active value through a shared `value` string.

```tsx title="Tabs anatomy"
<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.

```tsx title="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.

```tsx title="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`.

```tsx title="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.

```tsx title="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.

```tsx title="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.

```tsx title="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.

> **Bring your own layout**
>
> 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.

> **Default trigger visuals**
>
> 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` (required) | `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` (required) | `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. |

## Related

- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
