Tabs
Display content in switchable tab panels.
The Tabs component groups related content into separate panels. Selecting a tab displays its associated content.
Basic usage
With a scroller
Use a Scroller inside Tabs.Content when the panel contains more content than the available space.
import React from "@rbxts/react";
import { Scroller, Tabs, Text } from "@rbxts/react-clean-ui";
export function ScrollableTabsExample() {
return (
<Tabs>
<Tabs.List>
<Tabs.Title value="scrollable" text="Scrollable" />
</Tabs.List>
<Tabs.Body>
<Tabs.Content value="scrollable">
<Scroller height="200">
<Text text="Long scrollable content..." />
</Scroller>
</Tabs.Content>
</Tabs.Body>
</Tabs>
);
}Because a Tabs.Content panel stays mounted while hidden rather than unmounting, a Scroller's scroll position is preserved when switching away from its tab and back.
Controlled selection
By default Tabs manages its own selection. Pass value to control it from outside: the selected tab is then always value, and clicking a title only calls onValueChange with that title's value. Feed the new value back to actually switch tabs.
import React from "@rbxts/react";
import { Button, Tabs, Text, VStack } from "@rbxts/react-clean-ui";
export function ControlledTabsExample() {
const [tab, setTab] = React.useState("first");
return (
<VStack>
<Button text="Go to second tab" Event={{ Activated: () => setTab("second") }} />
<Tabs value={tab} onValueChange={setTab}>
<Tabs.List>
<Tabs.Title value="first" text="First Tab" />
<Tabs.Title value="second" text="Second Tab" />
</Tabs.List>
<Tabs.Body>
<Tabs.Content value="first">
<Text text="Content for the first tab." />
</Tabs.Content>
<Tabs.Content value="second">
<Text text="Content for the second tab." />
</Tabs.Content>
</Tabs.Body>
</Tabs>
</VStack>
);
}onValueChange can also be used without value, to observe clicks while Tabs stays uncontrolled.
Equal-width titles
Set fill on Tabs.List to make every title share the list's width equally, regardless of text length. Each title's text is centred within its share.
<Tabs>
<Tabs.List fill>
<Tabs.Title value="stats" text="Stats" />
<Tabs.Title value="inventory" text="Inventory" />
<Tabs.Title value="achievements" text="Achievements" />
</Tabs.List>
<Tabs.Body>
{/* ... */}
</Tabs.Body>
</Tabs>Without fill, each title is sized to fit its text.
Placing tabs in a card header
Tabs.List and Tabs.Body don't need to be siblings — each reads the current selection from Tabs via context wherever it's mounted, so the button bar and the content panel can live in entirely different parts of the layout. This makes it possible to put the buttons in a Card.Header while the panel content lives in the Card.Body:
import React from "@rbxts/react";
import { Card, Tabs, Text } from "@rbxts/react-clean-ui";
export function TabsInCardHeaderExample() {
return (
<Tabs>
<Card>
<Card.Header>
<Tabs.List>
<Tabs.Title value="profile" text="Profile" />
<Tabs.Title value="settings" text="Settings" />
</Tabs.List>
</Card.Header>
<Card.Body>
<Tabs.Body>
<Tabs.Content value="profile">
<Text text="Profile content..." />
</Tabs.Content>
<Tabs.Content value="settings">
<Text text="Settings content..." />
</Tabs.Content>
</Tabs.Body>
</Card.Body>
</Card>
</Tabs>
);
}The only rule is that a Tabs.Title and the Tabs.Content it should reveal share the same value; nesting and rendering order don't matter.
Structure
Tabs is composed from four independent child components:
Tabs.Listis the themed button bar. It renders whatever children it's given — typically one or moreTabs.Titleelements.Tabs.Titleis a single tab button. Itsvalueis matched against aTabs.Content'svalueto pair them.Tabs.Bodyis the themed content panel. It renders whatever children it's given — typically one or moreTabs.Contentelements.Tabs.Contentrenders itschildrenonly while itsvaluematches the currently selectedTabs.Title.
Tabs itself wraps its children in a VStack, so the default buttons-above-content layout doesn't need an extra wrapper around Tabs.List and Tabs.Body.
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | First mounted tab | Sets which tab's value starts selected. Only used when value is omitted. |
value | string | undefined | Makes Tabs controlled: the selected tab is always this value. |
onValueChange | (value: string) => void | undefined | Called with a title's value whenever that title is clicked, in both controlled and uncontrolled mode. |
Tabs.List
| Prop | Type | Default | Description |
|---|---|---|---|
fill | boolean | false | Makes the titles share the list's width equally instead of sizing to their text. |
children | React.ReactNode | — | Tabs.Title elements rendered as the button bar. |
Shared props
| Interface | Purpose |
|---|---|
ScalableElementProps | Configures the scale used when resolving the button bar's theme values. |
Tabs.Title
| Prop | Type | Description |
|---|---|---|
value | string | Identifies this tab. Must match a Tabs.Content's value to pair them. |
text | string | Text displayed in the tab button. |
Shared props
| Interface | Purpose |
|---|---|
PaddingProps | Configures individual padding values for the tab button. |
Tabs.Body
| Prop | Type | Default | Description |
|---|---|---|---|
backgroundImage | CssBackgroundImage | Theme value | Overrides the background image of the content panel. |
backgroundGradient | CssBackgroundGradient | Theme value | Overrides the background gradient of the content panel. |
LayoutOrder | number | undefined | Native Roblox layout order of the content panel, for placing it within a parent list layout. |
children | React.ReactNode | — | Tabs.Content elements rendered inside the panel. |
Shared props
| Interface | Purpose |
|---|---|
ScalableElementProps | Configures the scale used when resolving the content panel's theme values. |
Tabs.Content
| Prop | Type | Description |
|---|---|---|
value | string | Matches this panel to the Tabs.Title with the same value. |
children | React.ReactNode | Content displayed while this tab is selected. |
Behaviour
- The first
Tabs.Titleto mount is selected by default. SetdefaultValueonTabsto choose a different initial tab. - When
valueis set, clicking a title does not change the selection by itself; only a newvaluedoes. Avaluethat matches no title shows no panel, and titles never claim the selection on mount. onValueChangefires on every title click, including clicks on the already-selected title. It never fires for the initial selection or fordefaultValue.- If the selected
Tabs.Titleunmounts, the selection is not moved to another tab. - Every
Tabs.Contentstays mounted at all times — switching tabs toggles visibility rather than mounting and unmounting, so a panel's local state (such as aScroller's scroll position) is preserved when switching away and back. - Hovering or selecting a
Tabs.Titleswitches its background, border, gradient, and text colors to the theme'shover/focusintent states. fillonly affects titles rendered inside thatTabs.List.
Theme values
Tabs reads its appearance from theme.components.tabs:
gap— pixel gap between the direct children ofTabs(normallyTabs.ListandTabs.Body). Unset uses the defaultVStackgap. Negative values make them overlap. Has no effect whenTabs.List/Tabs.Bodyare placed elsewhere, such as in aCard.borderColor,borderThickness,cornerRadius,spacing,padding,backgroundImage, andbackgroundGradient— styleTabs.Body. AborderThicknessof0renders no border.list.backgroundColor,list.backgroundTransparency,list.backgroundImage,list.backgroundGradient,list.cornerRadius,list.spacing, andlist.padding— style theTabs.Listbar.list.gap— pixel gap between titles. Unset uses the defaultHStackgap. This is separate fromlist.spacing, which only controls the bar's padding.button.spacing,button.padding,button.boxShadow, andbutton.typography— style each title button.button.cornerRadius— either a single size or a per-corner object{ topLeft?, topRight?, bottomLeft?, bottomRight? }. With the per-corner object, any corner you leave out useslist.cornerRadius. A single size is ignored, and every corner useslist.cornerRadius. When every corner resolves to0, noUICorneris added.button.borderThickness— thickness of the title button's inner border.0(the value in every shipped theme) renders no border.button.intents.primary.default,.hover, and.focus— the unselected, hovered, and selected title states. Each state can settextColor,backgroundColor,backgroundTransparency,borderColor,borderThickness(overridesbutton.borderThickness),backgroundImage,backgroundGradient, andboxShadow. Titles always use theprimaryintent.
A UIGradient multiplies the background colour, so a state that sets backgroundGradient should also set backgroundColor to white and backgroundTransparency to 0.
Tabs sitting on the panel
A common look puts the titles directly on top of the panel: an outline around both, rounded top corners on the titles, and a gradient on the selected title only. You can get close to this with theme values alone.
A UIStroke can't leave out one side, and there's no per-side border. So the recipe overlaps the title row onto the body by exactly the border thickness (gap: -3 with a 3px border). The bottom of each title's border then sits on the top of the body's border, and the two draw as one 3px line.
The limitation: that shared line also runs under the selected title, so the selected tab doesn't open into the panel.
This example extends WoodenTheme:
import { WoodenTheme, extendTheme } from "@rbxts/react-clean-ui";
const TabsOnPanelTheme = extendTheme(WoodenTheme, {
components: {
tabs: {
gap: -3,
borderColor: Color3.fromHex("#331D07"),
borderThickness: 3,
cornerRadius: 0,
list: {
backgroundTransparency: 1,
backgroundImage: { image: "" },
padding: "0px",
cornerRadius: 0,
},
button: {
borderThickness: 3,
cornerRadius: { topLeft: 8, topRight: 8, bottomLeft: 0, bottomRight: 0 },
intents: {
primary: {
default: {
textColor: Color3.fromHex("#D3CBA3"),
borderColor: Color3.fromHex("#331D07"),
backgroundColor: Color3.fromHex("#5C3A18"),
backgroundTransparency: 0,
backgroundImage: { image: "" },
},
hover: {
textColor: Color3.fromHex("#D3CBA3"),
backgroundTransparency: 0,
},
focus: {
textColor: Color3.fromHex("#FFF7CF"),
backgroundColor: Color3.fromHex("#FFFFFF"),
backgroundTransparency: 0,
backgroundGradient: {
colors: [Color3.fromHex("#BA854A"), Color3.fromHex("#A16B30"), Color3.fromHex("#7A4A20")],
stops: [0, 0.48, 1],
rotation: 90,
},
},
},
},
},
},
},
});gaponly overlaps the two whenTabs.ListandTabs.Bodyare direct children ofTabs.list.paddingmust be0at the bottom. Otherwise the padding pushes the titles away from the body and the borders no longer line up.WoodenThemeputs images on the list and the buttons. A theme merge can't remove a key, so setimage: ""to clear them.