npx facet-rbxts add tabsCopies 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.
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>import React from "@rbxts/react";import { Label } from "../ui/label";import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";import { MODE } from "../facet-mode";
/** * `defaultValue` is not optional here in practice: the mirrored state cannot * see the primitive's own first-enabled-trigger fallback, so without one the * list renders with nothing styled as selected. */export function LoadoutTabs() { return ( <Tabs defaultValue="gear"> <TabsList> <TabsTrigger value="gear" Text="Gear" /> <TabsTrigger value="stats" Text="Stats" /> <TabsTrigger value="locked" Text="Locked" disabled /> </TabsList> <TabsContent value="gear"> <Label Text="Iron pickaxe, rope, two torches." /> </TabsContent> <TabsContent value="stats"> <Label Text="14 runs, 3 deaths, 1,204 coins." /> </TabsContent> </Tabs> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps, useControllableState,} from "@lattice-ui/react-runtime";import { Tabs as TabsPrimitive } from "@lattice-ui/react-tabs";import { TextSlot } from "~/lib/text";import { type ClassName, cn } from "~/lib/utils";
/** * The selected value is mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and a * trigger's surface changes with whether it is the selected one. The primitive * is then driven controlled, so there is exactly one copy of the state. * * Give `Tabs` a `defaultValue`: the mirror cannot see the primitive's own * first-enabled-trigger fallback, so without one no trigger styles as selected. * * One recipe object rather than five exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const tabsVariants = { root: fv("flex-col gap-2 w-full h-fit"), list: fv("flex-row items-center justify-center w-fit h-9 rounded-lg bg-muted p-1"), trigger: fv( "flex-row items-center justify-center gap-1.5 h-7 w-fit px-2 py-1 rounded-md border border-transparent transition duration-150", ), triggerLabel: fv("whitespace-nowrap text-sm font-medium text-foreground/60"), content: fv("flex-col gap-2 w-full h-fit"),};
const TabsContext = React.createContext<{ value?: string } | undefined>(undefined);
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export type TabsProps = { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type TabsListProps = { className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type TabsTriggerProps = { value: string; disabled?: boolean; Text?: string; className?: ClassName; children?: React.ReactNode;};
export type TabsContentProps = { value: string; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
const ROOT_OWN_PROPS = ["value", "defaultValue", "onValueChange", "className", "children"] as const;const LIST_OWN_PROPS = ["className", "children"] as const;const CONTENT_OWN_PROPS = ["value", "className", "children"] as const;
export function Tabs(props: TabsProps) { const [value, setValue] = useControllableState<string | undefined>({ value: props.value, defaultValue: props.defaultValue, onChange: props.onValueChange as (value: string | undefined) => void, });
const contextValue = React.useMemo(() => ({ value }), [value]);
return ( <TabsPrimitive.Root onValueChange={setValue} value={value}> <TabsContext.Provider value={contextValue}> <frame className={cn(tabsVariants.root({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, ROOT_OWN_PROPS)} > {props.children} </frame> </TabsContext.Provider> </TabsPrimitive.Root> );}
export function TabsList(props: TabsListProps) { return ( <TabsPrimitive.List className={cn(tabsVariants.list({ className: props.className }))} {...toSlotProps(getPassthroughProps<Frame>(props, LIST_OWN_PROPS))} > {props.children} </TabsPrimitive.List> );}
export function TabsTrigger(props: TabsTriggerProps) { const tabs = React.useContext(TabsContext); if (tabs === undefined) { error("[TabsTrigger] must be rendered inside Tabs."); }
const selected = tabs.value === props.value; const disabled = props.disabled === true;
return ( <TabsPrimitive.Trigger className={tabsVariants.trigger({ className: cn(selected && "bg-background", disabled && "opacity-50", props.className), })} disabled={props.disabled} value={props.value} > <TextSlot Text={props.Text} TextTransparency={disabled ? 0.5 : 0} className={cn(tabsVariants.triggerLabel(), selected && "text-foreground")} > {props.children} </TextSlot> </TabsPrimitive.Trigger> );}
export function TabsContent(props: TabsContentProps) { return ( <TabsPrimitive.Content className={cn(tabsVariants.content({ className: props.className }))} value={props.value} {...toSlotProps(getPassthroughProps<Frame>(props, CONTENT_OWN_PROPS))} > {props.children} </TabsPrimitive.Content> );}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
Lattice’s Tabs.Root has its own fallback: with no value it selects the first enabled trigger. The
mirrored state 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
<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.
The state classes sit inside the recipe’s slot and ahead of props.className, which is
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.