Stems UI
Menu

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.

Workspace name, language, and time zone.

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/tabs

Requires 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). null means no tab is active. Tabs are controlled whenever value is not undefined.
    • defaultValue: string | null. The initially active tab (uncontrolled). When omitted, the first enabled tab becomes active. null starts with no active tab.
    • onValueChange(value, event): called when the user activates a different tab. event is the TabsChangeEvent that 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 to false (manual activation: Enter, Space, or click).
    • loopFocus: wrap focus from the last tab to the first and back. Defaults to true.
  • tab({ value, disabled? }): role="tab", aria-selected, aria-controls, roving tabindex, and type="button" on <button> hosts. Disabled tabs get native disabled on <button> hosts and aria-disabled="true" on other hosts.
  • indicator(): a decorative element (role="presentation") placed inside the list. It is hidden until the active tab has been measured in the browser, and while no tab is active.
  • panel({ value }): role="tabpanel", aria-labelledby, and tabindex="0" while active. Inactive panels are hidden and inert.
  • TabsChangeEvent: stems:tabs-change, dispatched (bubbling) from the activated tab. Fields: value, previousValue, activationDirection.
  • onTabsChange(handler): typed listener for stems:tabs-change.
  • TabsContextValue: read it in a component with handle.context.get(tabs.Context). It exposes value, orientation, activationDirection, isActive(value), getTabId(value), getPanelId(value), and activate(value).

Behavior notes

KeyAction
ArrowRight / ArrowLeftHorizontal: focus the next / previous enabled tab (mirrored with dir="rtl").
ArrowDown / ArrowUpVertical: focus the next / previous enabled tab.
Home / EndFocus the first / last enabled tab.
Enter / SpaceActivate the focused tab.
  • Arrow keys only move focus unless activateOnFocus is 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 dir attribute (or computed direction) 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 value or defaultValue, 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's keepMounted is implemented by the styled TabsContent component, which drops inactive children unless keepMounted is set. Do the same in your own components with handle.context.get(tabs.Context).
  • Explicit host props win over the mixins' defaults. Don't override id on tabs or panels: the generated ids link them through aria-controls / aria-labelledby.
  • onValueChange only fires for user activation (and activate()), never for the implicit initial pick.
  • Tab values are strings.

Styling hooks

PartAttributeValues
all partsdata-orientationhorizontal | vertical
all partsdata-activation-directionleft | right | up | down | none
tabdata-activePresent when the tab is active.
tabdata-disabledPresent when the tab is disabled.
paneldata-hiddenPresent when the panel is hidden.
paneldata-indexThe panel's index.
indicatorhiddenPresent 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):

VariableDescription
--active-tab-leftDistance from the list's left edge to the tab.
--active-tab-rightDistance from the tab to the list's right edge.
--active-tab-topDistance from the list's top edge to the tab.
--active-tab-bottomDistance from the tab to the list's bottom edge.
--active-tab-widthWidth of the active tab.
--active-tab-heightHeight of the active tab.

The variables update after the active tab changes and whenever the list or a tab resizes (ResizeObserver).