Tabs
A set of layered sections of content, displayed one at a time.
Account
Make changes to your account here. Click save when you're done.
Vertical tabs: general
Codeapp/demos/public/tabs.tsx
import { clientEntry, type Handle } from 'remix/component'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@stemsui/registry/ui/tabs.tsx'
const cardClass =
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm'
const inputClass =
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30'
const buttonClass =
'inline-flex h-9 w-fit items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs hover:bg-primary/90'
function Field(handle: Handle<{ id: string; label: string; password?: boolean; value?: string }>) {
return () => {
let { id, label, password, value } = handle.props
return (
<div class="grid gap-3">
<label for={id} class="text-sm leading-none font-medium">
{label}
</label>
{password ? (
<input id={id} type="password" class={inputClass} />
) : (
<input id={id} type="text" value={value} class={inputClass} />
)}
</div>
)
}
}
export const TabsDemo = clientEntry(import.meta.url, function TabsDemo(handle: Handle) {
let section = 'general'
return () => (
<div class="flex w-full max-w-sm flex-col gap-10">
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div class={cardClass}>
<div class="grid gap-1.5 px-6">
<h3 class="leading-none font-semibold">Account</h3>
<p class="text-sm text-muted-foreground">
Make changes to your account here. Click save when you're done.
</p>
</div>
<div class="grid gap-6 px-6">
<Field id="tabs-demo-name" label="Name" value="Pedro Duarte" />
<Field id="tabs-demo-username" label="Username" value="@peduarte" />
</div>
<div class="px-6">
<button type="button" class={buttonClass}>
Save changes
</button>
</div>
</div>
</TabsContent>
<TabsContent value="password">
<div class={cardClass}>
<div class="grid gap-1.5 px-6">
<h3 class="leading-none font-semibold">Password</h3>
<p class="text-sm text-muted-foreground">
Change your password here. After saving, you'll be logged out.
</p>
</div>
<div class="grid gap-6 px-6">
<Field id="tabs-demo-current" label="Current password" password />
<Field id="tabs-demo-new" label="New password" password />
</div>
<div class="px-6">
<button type="button" class={buttonClass}>
Save password
</button>
</div>
</div>
</TabsContent>
</Tabs>
<Tabs
orientation="vertical"
value={section}
onValueChange={(value) => {
section = value
void handle.update()
}}
>
<TabsList variant="line" activateOnFocus>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="billing" disabled>
Billing
</TabsTrigger>
</TabsList>
<TabsContent value="general" class="text-muted-foreground">
Workspace name, language, and time zone.
</TabsContent>
<TabsContent value="notifications" class="text-muted-foreground">
Email and push notification preferences.
</TabsContent>
<TabsContent value="billing" class="text-muted-foreground">
Plans and invoices.
</TabsContent>
</Tabs>
<p class="text-sm text-muted-foreground" data-testid="tabs-result">
Vertical tabs: <span class="font-medium text-foreground">{section}</span>
</p>
</div>
)
})
Installation
npx shadcn add @stemsui/tabsRequires the one-time setup. Behavior: stems-ui/tabs.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/tabs
stems-ui/tabs wires app-owned markup into an accessible tab list with tab panels: roving focus,
manual or automatic activation, orientation, RTL, and an optional indicator that tracks the active
tab through CSS variables.
Base UI equivalent: Tabs.
Usage
import * as tabs from 'stems-ui/tabs'
function Settings() {
return () => (
<tabs.Context defaultValue="account">
<div mix={tabs.root()}>
<div class="relative flex gap-1" mix={tabs.list()}>
<button mix={tabs.tab({ value: 'account' })}>Account</button>
<button mix={tabs.tab({ value: 'password' })}>Password</button>
<button mix={tabs.tab({ value: 'billing', disabled: true })}>Billing</button>
<span
class="absolute bottom-0 left-0 h-0.5 w-(--active-tab-width) translate-x-(--active-tab-left) bg-current transition-[translate,width]"
mix={tabs.indicator()}
/>
</div>
<div mix={tabs.panel({ value: 'account' })}>Account settings</div>
<div mix={tabs.panel({ value: 'password' })}>Password settings</div>
<div mix={tabs.panel({ value: 'billing' })}>Billing settings</div>
</div>
</tabs.Context>
)
}
Listen for changes with the callback or the bubbling event:
import { onTabsChange } from 'stems-ui/tabs'
;<section mix={onTabsChange((event) => console.log(event.value, event.activationDirection))}>
...
</section>
stems-ui/tabs
Context: provider that owns the tabs state.value:string | null. The active tab (controlled).nullmeans no tab is active. Tabs are controlled whenevervalueis notundefined.defaultValue:string | null. The initially active tab (uncontrolled). When omitted, the first enabled tab becomes active.nullstarts with no active tab.onValueChange(value, event): called when the user activates a different tab.eventis theTabsChangeEventthat was dispatched.orientation:'horizontal' | 'vertical'. Defaults to'horizontal'.
root(): the element that groups the list and panels. Sets state attributes only.list(options?):role="tablist",aria-orientation(vertical only), and keyboard navigation.activateOnFocus: activate tabs as arrow keys focus them. Defaults tofalse(manual activation: Enter, Space, or click).loopFocus: wrap focus from the last tab to the first and back. Defaults totrue.
tab({ value, disabled? }):role="tab",aria-selected,aria-controls, rovingtabindex, andtype="button"on<button>hosts. Disabled tabs get nativedisabledon<button>hosts andaria-disabled="true"on other hosts.indicator(): a decorative element (role="presentation") placed inside the list. It ishiddenuntil the active tab has been measured in the browser, and while no tab is active.panel({ value }):role="tabpanel",aria-labelledby, andtabindex="0"while active. Inactive panels arehiddenandinert.TabsChangeEvent:stems:tabs-change, dispatched (bubbling) from the activated tab. Fields:value,previousValue,activationDirection.onTabsChange(handler): typed listener forstems:tabs-change.TabsContextValue: read it in a component withhandle.context.get(tabs.Context). It exposesvalue,orientation,activationDirection,isActive(value),getTabId(value),getPanelId(value), andactivate(value).
Behavior notes
| Key | Action |
|---|---|
| ArrowRight / ArrowLeft | Horizontal: focus the next / previous enabled tab (mirrored with dir="rtl"). |
| ArrowDown / ArrowUp | Vertical: focus the next / previous enabled tab. |
| Home / End | Focus the first / last enabled tab. |
| Enter / Space | Activate the focused tab. |
- Arrow keys only move focus unless
activateOnFocusis set. Disabled tabs are skipped. - The active tab is the only tab in the page tab sequence (
tabindex="0"). When no tab is active, the first enabled tab is. - Text direction is read from the nearest
dirattribute (or computeddirection) when the key is pressed, so it can change at runtime. - Server-rendered HTML already has the correct active tab, panel visibility, ids, and data
attributes. Without a
valueordefaultValue, the first enabled tab is chosen in render order, so render the list before the panels. - Panels stay in the DOM (hidden and
inert): a mixin can't remove its host. Base UI'skeepMountedis implemented by the styledTabsContentcomponent, which drops inactive children unlesskeepMountedis set. Do the same in your own components withhandle.context.get(tabs.Context). - Explicit host props win over the mixins' defaults. Don't override
idon tabs or panels: the generated ids link them througharia-controls/aria-labelledby. onValueChangeonly fires for user activation (andactivate()), never for the implicit initial pick.- Tab values are strings.
Styling hooks
| Part | Attribute | Values |
|---|---|---|
| all parts | data-orientation | horizontal | vertical |
| all parts | data-activation-direction | left | right | up | down | none |
| tab | data-active | Present when the tab is active. |
| tab | data-disabled | Present when the tab is disabled. |
| panel | data-hidden | Present when the panel is hidden. |
| panel | data-index | The panel's index. |
| indicator | hidden | Present until measured, or with no active tab. |
data-activation-direction compares the newly active tab's position to the previous one, and is
none until the first change.
Indicator CSS variables (in px, relative to the list's padding box):
| Variable | Description |
|---|---|
--active-tab-left | Distance from the list's left edge to the tab. |
--active-tab-right | Distance from the tab to the list's right edge. |
--active-tab-top | Distance from the list's top edge to the tab. |
--active-tab-bottom | Distance from the tab to the list's bottom edge. |
--active-tab-width | Width of the active tab. |
--active-tab-height | Height of the active tab. |
The variables update after the active tab changes and whenever the list or a tab resizes
(ResizeObserver).