Tabs
Overview
The Tabs component organizes related content into a set of panels, one of which is visible at a time. It is composed of four recipe parts: useTabsRecipe() for the root wrapper that lays out the bar and the active panel, useTabsListRecipe() for the tab list (role="tablist"), useTabsTriggerRecipe() for each individual tab button (role="tab"), and useTabsContentRecipe() for the content panels (role="tabpanel"). Each composable creates a fully configured recipe with compound variants that handle the color-variant combinations — including the active-tab indicator and dark mode overrides — automatically.
The active tab is matched through the aria-selected attribute, so the selected styling is driven by accessible markup rather than an extra class. The Tabs recipes integrate directly with the default design tokens preset and generate type-safe utility classes at build time with zero runtime CSS.
Why use the Tabs recipe?
The Tabs recipe helps you:
- Ship faster with sensible defaults: Get 3 styles, 3 surfaces, 3 sizes, and 2 orientations out of the box with a single set of composable calls.
- Compose an accessible structure: Four coordinated recipes (root, list, trigger, content) map cleanly onto the
tablist/tab/tabpanelroles. - Maintain consistency: Compound variants ensure every color-variant combination follows the same rules, including the active indicator and dark mode overrides.
- Customize without forking: Override base styles, default variants, or filter out options you don't need — all through the options API.
- Stay type-safe: Full TypeScript support means your editor catches invalid color, variant, size, or orientation values at compile time.
- Integrate with your tokens: Every value references the design tokens preset, so theme changes propagate automatically.
Usage
Register the recipes
Add the Tabs recipes to a local Styleframe instance. The global styleframe.config.ts provides design tokens and utilities, while the component-level file registers the recipes themselves:
import { styleframe } from 'virtual:styleframe';
import {
useTabsRecipe,
useTabsListRecipe,
useTabsTriggerRecipe,
useTabsContentRecipe,
} from '@styleframe/theme';
const s = styleframe();
const tabs = useTabsRecipe(s);
const tabsList = useTabsListRecipe(s);
const tabsTrigger = useTabsTriggerRecipe(s);
const tabsContent = useTabsContentRecipe(s);
export default s;
Build the component
Import the tabs, tabsList, tabsTrigger, and tabsContent runtime functions from the virtual module. Track the active value yourself and mark the selected trigger with aria-selected so the recipe paints the active state:
import { useState } from "react";
import { tabs, tabsList, tabsTrigger, tabsContent } from "virtual:styleframe";
const items = [
{ value: "account", label: "Account" },
{ value: "password", label: "Password" },
];
export function Tabs() {
const [active, setActive] = useState("account");
return (
<div className={tabs({ orientation: "horizontal", size: "md" })}>
<div role="tablist" className={tabsList({ color: "neutral", variant: "line", size: "md" })}>
{items.map((item) => (
<button
key={item.value}
type="button"
role="tab"
aria-selected={active === item.value}
className={tabsTrigger({ color: "neutral", variant: "line", size: "md" })}
onClick={() => setActive(item.value)}
>
{item.label}
</button>
))}
</div>
{items.map((item) =>
active === item.value ? (
<div key={item.value} role="tabpanel" className={tabsContent({ size: "md" })}>
{item.label} panel
</div>
) : null,
)}
</div>
);
}
See it in action
Colors
The Tabs recipe includes 3 color surfaces: light, dark, and neutral. Like the Card recipe, Tabs use neutral-spectrum colors designed for content surfaces rather than status communication. The color controls the list track, the active-tab indicator, and the panel text, combined with every visual style through compound variants — including dark mode overrides.
The neutral color adapts automatically: it uses a light appearance in light mode and a dark appearance in dark mode, making it the safest default for general-purpose tabs.
Color Reference
| Color | Active indicator | Use Case |
|---|---|---|
light | @color.gray-900 text / surface | Light surfaces, stays light in dark mode |
dark | @color.white text / surface | Dark surfaces, stays dark in light mode |
neutral | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme |
neutral as your default. It adapts automatically to the user's color scheme, so you don't need to manage light and dark surfaces separately.Variants
Three visual style variants control how the active tab is rendered. Each variant is combined with the selected color through compound variants, so you always get the correct indicator, surface, and text colors for your chosen color.
Line
A colored rule on the active edge (the bottom in horizontal orientation, the inline end in vertical), with a matching track on the list. The classic, low-chrome tabs style and the default.
Pill
A segmented bar: the list sits on a muted, rounded surface and the active tab is raised onto its own surface with a subtle shadow. Matches the shadcn/Radix default look.
Soft
The active tab gets a soft tinted fill with no underline or bar. A gentle style that works well in dense layouts.
Sizes
Three size variants from sm to lg control the trigger's font size and padding, the list gap, and the panel padding.
Size Reference
| Size | Trigger font | Trigger padding (V / H) | Panel padding |
|---|---|---|---|
sm | @font-size.xs | @0.375 / @0.625 | @0.5 |
md | @font-size.sm | @0.5 / @0.75 | @0.75 |
lg | @font-size.md | @0.625 / @0.875 | @1 |
size prop must be passed to each part individually. The trigger controls its own font and padding, the list controls the gap, and the content controls the panel padding.Orientation
The orientation axis switches between a horizontal tab bar (the default) and a vertical one. In vertical orientation the root places the list beside the panels, the list stacks its triggers, and the line indicator moves from the bottom edge to the inline end.
orientation value to the root, list, and trigger parts so the layout and the active-edge indicator stay in sync.States
The trigger responds to the standard interactive states:
- Active. The selected trigger is matched through
aria-selected="true"and receives the variant's active styling (indicator, surface, or fill). Always setaria-selectedon the active tab. - Hover. Inactive triggers lift toward the emphasis color on hover for affordance.
- Focus. Keyboard focus shows a 2px
@color.primaryoutline ring via:focus-visible. The content panel shows the same ring when focused. - Disabled. A disabled trigger (
<button disabled>) is dimmed to 50% opacity withcursor: not-allowedand ignores pointer events.
Anatomy
The Tabs recipe is composed of four independent recipes that work together:
| Part | Recipe | Role |
|---|---|---|
| Root | useTabsRecipe() | Wrapper that lays out the bar and panel; owns orientation and the gap |
| List | useTabsListRecipe() | The role="tablist" bar; paints the track (line) or container (pill) |
| Trigger | useTabsTriggerRecipe() | Each role="tab" button; renders the active indicator per variant |
| Content | useTabsContentRecipe() | Each role="tabpanel"; the visible panel with size-based padding |
<!-- All four parts working together -->
<div class="tabs(...)">
<div role="tablist" class="tabsList(...)">
<button role="tab" aria-selected="true" class="tabsTrigger(...)">Tab 1</button>
<button role="tab" aria-selected="false" class="tabsTrigger(...)">Tab 2</button>
</div>
<div role="tabpanel" class="tabsContent(...)">Panel 1</div>
</div>
color, variant, and orientation consistently to the list and trigger so the track and the active indicator line up. The content panel only needs a size.Accessibility
- Use the tab roles. Wrap the triggers in an element with
role="tablist", give each triggerrole="tab", and give each panelrole="tabpanel". Mark the active trigger witharia-selected="true"— the recipe relies on it for the active styling. - Wire up keyboard navigation. A tablist should support arrow-key movement between tabs, with
Home/Endjumping to the first/last tab. Setaria-orientationon the list to match the layout. - Connect triggers and panels. Give each trigger an
idand point itsaria-controlsat the panel, and give each panelaria-labelledbypointing back at its trigger. - Verify contrast ratios. The
darkcolor places light text on a dark surface. Default tokens meet WCAG AA 4.5:1 contrast. If you override colors, verify with the WebAIM Contrast Checker.
Customization
Overriding Defaults
Each tabs composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify what changes:
import { styleframe } from 'virtual:styleframe';
import { useTabsListRecipe, useTabsTriggerRecipe } from '@styleframe/theme';
const s = styleframe();
const tabsList = useTabsListRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'pill',
size: 'md',
orientation: 'horizontal',
},
});
const tabsTrigger = useTabsTriggerRecipe(s, {
defaultVariants: {
color: 'neutral',
variant: 'pill',
size: 'md',
orientation: 'horizontal',
},
});
export default s;
Filtering Variants
If you only need a subset of the available variants, use the filter option to limit which values are generated. This reduces the output CSS and keeps your component API focused:
import { styleframe } from 'virtual:styleframe';
import { useTabsTriggerRecipe } from '@styleframe/theme';
const s = styleframe();
// Only generate the neutral color with the line and pill styles
const tabsTrigger = useTabsTriggerRecipe(s, {
filter: {
color: ['neutral'],
variant: ['line', 'pill'],
},
});
export default s;
API Reference
useTabsRecipe(s, options?)
Creates the root wrapper recipe. Lays out the list and panel and owns the orientation.
Variants:
| Variant | Options | Default |
|---|---|---|
orientation | horizontal, vertical | horizontal |
size | sm, md, lg | md |
useTabsListRecipe(s, options?)
Creates the tab list recipe. Paints the line track or the pill container.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | line, pill, soft | line |
size | sm, md, lg | md |
orientation | horizontal, vertical | horizontal |
useTabsTriggerRecipe(s, options?)
Creates the trigger recipe. Renders the active indicator per variant via the aria-selected modifier.
Variants:
| Variant | Options | Default |
|---|---|---|
color | light, dark, neutral | neutral |
variant | line, pill, soft | line |
size | sm, md, lg | md |
orientation | horizontal, vertical | horizontal |
useTabsContentRecipe(s, options?)
Creates the content panel recipe with adaptive text color, a focus ring, and size-based padding.
Variants:
| Variant | Options | Default |
|---|---|---|
size | sm, md, lg | md |
Best Practices
- Always set
aria-selected: The active styling is driven byaria-selected="true". Without it, no tab appears selected. - Pass
color,variant, andorientationconsistently: The list track and the trigger indicator must share these values to line up. - Use
neutralfor general-purpose tabs: It adapts to light and dark mode automatically. - Pick
pillfor a segmented control feel: Reservelinefor low-chrome navigation andsoftfor dense layouts. - Filter what you don't need: If your tabs only use one color, pass a
filteroption to reduce generated CSS.
FAQ
tablist / tab / tabpanel roles.aria-selected modifier ([aria-selected="true"]). Set aria-selected="true" on the selected trigger and the recipe paints the variant's active state — an underline for line, a raised surface for pill, or a tinted fill for soft. This keeps the styling tied to accessible markup instead of an extra class.ref, useState, signal, or attribute) and toggle aria-selected plus the panel visibility. This keeps the recipe framework-agnostic.orientation: 'vertical' flips the root to a row layout (list beside panels), stacks the list's triggers in a column, and moves the line indicator from the bottom edge to the inline end. Pass the same orientation to the root, list, and trigger so everything stays aligned.light, dark, and neutral to provide surface variations that work across all content without implying a status.@color.gray-200, @border-radius.lg, and @box-shadow.sm through string refs. These tokens need to be defined in your Styleframe instance for the recipe to generate valid CSS. The easiest way is useDesignTokensPreset(s), but you can also define the required tokens manually.Sidebar
A persistent navigation surface for application shells. Composed of fifteen coordinated recipes — panel, header, content, footer, groups, menus, interactive menu buttons, sub-menus, separators, actions, and badges — with three colors, three surface styles, three sizes, and a collapsible icon rail.
Badge
A compact labeling component for status indicators, counts, and categorization. Supports multiple colors, visual styles, and sizes through the recipe system.