Styleframe Logo
Navigation

Tabs

An accessible tabbed interface for organizing content into selectable panels. Composed of a root wrapper, a tab list, individual triggers, and content panels — with line, pill, and soft styles, light/dark/neutral surfaces, three sizes, and horizontal or vertical orientation.

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 / tabpanel roles.
  • 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:

src/components/tabs.styleframe.ts
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:

src/components/Tabs.tsx
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

ColorActive indicatorUse Case
light@color.gray-900 text / surfaceLight surfaces, stays light in dark mode
dark@color.white text / surfaceDark surfaces, stays dark in light mode
neutralAdaptive (light ↔ dark)Default color, adapts to the current color scheme
Pro tip: Use 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

SizeTrigger fontTrigger 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
Good to know: The 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.

Good to know: Pass the same 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 set aria-selected on the active tab.
  • Hover. Inactive triggers lift toward the emphasis color on hover for affordance.
  • Focus. Keyboard focus shows a 2px @color.primary outline ring via :focus-visible. The content panel shows the same ring when focused.
  • Disabled. A disabled trigger (<button disabled>) is dimmed to 50% opacity with cursor: not-allowed and ignores pointer events.

Anatomy

The Tabs recipe is composed of four independent recipes that work together:

PartRecipeRole
RootuseTabsRecipe()Wrapper that lays out the bar and panel; owns orientation and the gap
ListuseTabsListRecipe()The role="tablist" bar; paints the track (line) or container (pill)
TriggeruseTabsTriggerRecipe()Each role="tab" button; renders the active indicator per variant
ContentuseTabsContentRecipe()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>
Pro tip: Pass 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 trigger role="tab", and give each panel role="tabpanel". Mark the active trigger with aria-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/End jumping to the first/last tab. Set aria-orientation on the list to match the layout.
  • Connect triggers and panels. Give each trigger an id and point its aria-controls at the panel, and give each panel aria-labelledby pointing back at its trigger.
  • Verify contrast ratios. The dark color 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:

src/components/tabs.styleframe.ts
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:

src/components/tabs.styleframe.ts
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;
Good to know: Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent.

API Reference

useTabsRecipe(s, options?)

Creates the root wrapper recipe. Lays out the list and panel and owns the orientation.

Variants:

VariantOptionsDefault
orientationhorizontal, verticalhorizontal
sizesm, md, lgmd

useTabsListRecipe(s, options?)

Creates the tab list recipe. Paints the line track or the pill container.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantline, pill, softline
sizesm, md, lgmd
orientationhorizontal, verticalhorizontal

useTabsTriggerRecipe(s, options?)

Creates the trigger recipe. Renders the active indicator per variant via the aria-selected modifier.

Variants:

VariantOptionsDefault
colorlight, dark, neutralneutral
variantline, pill, softline
sizesm, md, lgmd
orientationhorizontal, verticalhorizontal

useTabsContentRecipe(s, options?)

Creates the content panel recipe with adaptive text color, a focus ring, and size-based padding.

Variants:

VariantOptionsDefault
sizesm, md, lgmd

Learn more about recipes →

Best Practices

  • Always set aria-selected: The active styling is driven by aria-selected="true". Without it, no tab appears selected.
  • Pass color, variant, and orientation consistently: The list track and the trigger indicator must share these values to line up.
  • Use neutral for general-purpose tabs: It adapts to light and dark mode automatically.
  • Pick pill for a segmented control feel: Reserve line for low-chrome navigation and soft for dense layouts.
  • Filter what you don't need: If your tabs only use one color, pass a filter option to reduce generated CSS.

FAQ