# Tabs

> A list, some triggers, and switched panels — plus one prop you have to pass or nothing looks selected.

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

```bash
npx facet-rbxts add tabs
```

Copies `ui/tabs.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-tabs@^0.8.0`.

```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../shared/ui/tabs";

<Tabs defaultValue="gear">
  <TabsList>
    <TabsTrigger value="gear" Text="Gear" />
    <TabsTrigger value="stats" Text="Stats" />
  </TabsList>
  <TabsContent value="gear">…</TabsContent>
  <TabsContent value="stats">…</TabsContent>
</Tabs>
```

_Interactive preview: Two live triggers and one disabled. The selected trigger takes bg-background; its label takes text-foreground._

## The parts

| Part | Renders | Classes |
| --- | --- | --- |
| `Tabs` | `Frame` | `flex-col gap-2 w-full h-fit` |
| `TabsList` | `Frame` | `flex-row items-center justify-center gap-1 w-fit h-9 rounded-lg bg-muted p-1` |
| `TabsTrigger` | `TextButton` | `flex-row items-center justify-center h-7 w-fit px-3 rounded-md transition duration-150` |
| `TabsContent` | `Frame` | `flex-col gap-2 w-full h-fit` |

Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.

That is `Tabs`, `TabsList` and `TabsContent`. `TabsTrigger` has no passthrough bag — it renders a
button and a label, and there is no single instance for an unknown prop to land on.

## Pass `defaultValue`

> **Without one, nothing styles as selected**
>
> Lattice's `Tabs.Root` has its own fallback: with no value it selects the first enabled trigger. The
> [mirrored state](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for) in this file
> cannot see that decision — it holds `undefined`, so `selected` is false for every trigger, and the
> list renders with nothing highlighted while the content panel below it switches correctly.
>
> It compiles, it works, and it looks broken. Give `Tabs` a `defaultValue` (or a controlled `value`).

This is the sharp edge of mirroring a private context: the mirror is only as good as what it was
told, and a fallback that lives inside the primitive is exactly what it was not told.

## Props

### `Tabs`

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting value. Effectively required — see above. |
| `onValueChange` | `(value: string) => void` | Fires when a different trigger is selected. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |

### `TabsTrigger` and `TabsContent`

`TabsTrigger` takes a required `value`, plus `disabled`, `Text`, `children` and `className`.
`TabsContent` takes a required `value`, plus `children` and `className`.

## The selected state lands on two instances

```tsx
<TabsPrimitive.Trigger
  className={tabsVariants.trigger({
    className: cn(selected && "bg-background", disabled && "opacity-50", props.className),
  })}
>
  <TextSlot className={cn(tabsVariants.triggerLabel(), selected && "text-foreground")}>
```

The surface takes `bg-background`; the label takes `text-foreground` over its resting
`text-muted-foreground`. Two classes on two instances, because nothing inherits — the same shape as
[`Toggle group`](https://docs.astra-void.xyz/facet/components/toggle-group.md#the-pressed-state-and-where-it-lands).

The state classes sit **inside** the recipe's slot and ahead of `props.className`, which is
[the one rule](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule): resolution is last-token-wins, so
anything after the consumer's class is an override they cannot undo.

## `w-fit` on the list, `h-9` on the list

The list hugs its triggers horizontally and states a fixed height, which is what gives the pill its
shape: `p-1` insets the `h-7` triggers by 4px on each edge inside the `h-9` track. Changing one
number means changing the other — there is no variant, because the file is short enough to edit.
