Tabs
Organises related content into labelled panels, showing one at a time. Use tabs to reduce visual complexity when content is mutually exclusive but equally important.
Preview
Project Alpha is a design system documentation site built with Next.js and Tailwind CSS. It defines tokens, components, and guidelines for the Sitka design language.
Variants
Tabs support an optional badge next to the label for counts or status markers.
Showing all items.
Viewing inbox.
When to use
| Component | Use when |
|---|---|
| Tabs | Switching between related views within the same context. Content does not need to be compared side-by-side. |
| Segmented Button | Choosing a single option from a small, mutually exclusive set (2–4 options). Acts as a filter or view toggle, not a navigation element. |
| Navigation | Moving between top-level sections of an application. Each section has a distinct URL. |
| Accordion | Progressive disclosure within a single view. Sections are not mutually exclusive — multiple can be open at once. |
Anatomy
| Element | Spec |
|---|---|
| Tab bar | role='tablist', flex row, border-bottom: 1px --border, px-1 horizontal padding |
| Tab trigger | role='tab', px-3 py-2.5, 13px medium, gap-1.5 for label + badge, rounded-t focus ring |
| Active indicator | 2px --accent, absolute bottom-0, rounded-t-full, aria-hidden |
| Badge | 10px semibold, px-1.5 py-0.5, rounded-full, --accent-subtle bg, --accent text |
| Tab panel | role='tabpanel', pt-4 top padding, full width, aria-labelledby pointing to active tab |
| Inactive label | --text-tertiary, hover: --text-secondary, transition 150ms ease |
| Active label | --text-primary |
Implementation
The React version uses a render prop — children is a function that receives the active tab id and returns the panel content. This keeps state ownership inside Tabs while giving callers full control over what each panel renders.
"use client";
import { useState } from "react";
import { cn } from "@/lib";
export interface Tab {
id: string;
label: string;
badge?: string;
}
export interface TabsProps {
tabs: Tab[];
defaultTab?: string;
children: (activeId: string) => React.ReactNode;
className?: string;
}
export function Tabs({ tabs, defaultTab, children, className }: TabsProps) {
const [active, setActive] = useState(defaultTab ?? tabs[0]?.id);
return (
<div className={cn("flex flex-col gap-0", className)}>
<div
role="tablist"
aria-label="Content sections"
className="flex items-center gap-0.5 border-b border-[rgb(var(--border))] px-1"
>
{tabs.map((tab) => (
<button
key={tab.id}
id={`tab-${tab.id}`}
role="tab"
aria-selected={active === tab.id}
aria-controls={`panel-${tab.id}`}
onClick={() => setActive(tab.id)}
className={cn(
"relative flex items-center gap-1.5 px-3 py-2.5 text-[13px] font-medium transition-colors duration-150 outline-none",
"focus-visible:ring-2 focus-visible:ring-[rgb(var(--accent))] rounded-t",
active === tab.id
? "text-[rgb(var(--text-primary))]"
: "text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--text-secondary))]"
)}
>
{tab.label}
{tab.badge && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-[rgb(var(--accent-subtle))] text-[rgb(var(--accent))]">
{tab.badge}
</span>
)}
{active === tab.id && (
<span
className="absolute bottom-0 left-0 right-0 h-[2px] bg-[rgb(var(--accent))] rounded-t-full"
aria-hidden="true"
/>
)}
</button>
))}
</div>
<div
role="tabpanel"
id={`panel-${active}`}
aria-labelledby={`tab-${active}`}
className="pt-4"
>
{children(active)}
</div>
</div>
);
}
// Usage
<Tabs
tabs={[
{ id: "overview", label: "Overview" },
{ id: "files", label: "Files", badge: "12" },
{ id: "activity", label: "Activity", badge: "3" },
{ id: "settings", label: "Settings" },
]}
>
{(active) => (
<div>
{active === "overview" && <p>Overview content</p>}
{active === "files" && <p>Files content</p>}
{active === "activity" && <p>Activity content</p>}
{active === "settings" && <p>Settings content</p>}
</div>
)}
</Tabs>Props — Tabs
Extends HTMLDivElement props.
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | Tab[] | — | Ordered array of tab descriptors. Each item requires an id and label; badge is optional. |
defaultTab | string | — | Id of the tab that is active on first render. Defaults to the first tab if omitted. |
children | (activeId: string) => ReactNode | — | Render prop called with the currently active tab id. Render the panel content conditionally on this value. |
className | string | — | Additional class names applied to the outermost wrapper. |
Props — Tab
Shape of each item in the tabs array.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique identifier for this tab. Used to match activeId in the render prop and as the defaultTab value. |
label | string | — | Visible tab label. Keep it short — 1–2 words. |
badge | string | — | Short count or label rendered as a pill next to the tab label. Use for counts (e.g. '12') or status labels (e.g. 'New'). |
Motion
The active indicator slides between tabs using a matchedGeometryEffect on iOS/macOS and a CSS transition on web. Panel content switches instantly — animate the panel content independently if entry motion is needed.
| Interaction | Property | Value | Easing |
|---|---|---|---|
| Tab hover | color | --text-secondary | 150ms ease |
| Tab select | color | --text-primary | instant |
| Indicator slide | left / width | per-tab position | spring(300, 30) — iOS/macOS |
| Indicator appear | opacity | 0 → 1 | 150ms ease — web |
| Focus ring | box-shadow | 2px --accent | instant |
ARIA roles
| Element | Role | Key attributes |
|---|---|---|
| Tab bar | tablist | aria-label |
| Tab trigger | tab | aria-selected, aria-controls |
| Tab panel | tabpanel | aria-labelledby |
Keyboard
| Key | Action |
|---|---|
| Arrow Right / Left | Move focus to and activate the next / previous tab |
| Home | Move focus to and activate the first tab |
| End | Move focus to and activate the last tab |
| Tab | Move focus from the tab list into the active panel |
| Shift+Tab | Move focus back to the active tab from the panel |
Accessibility
- →The tab bar carries role='tablist'. Each trigger has role='tab' and aria-selected — screen readers announce which tab is active.
- →aria-controls on each tab points to the id of its panel. aria-labelledby on the panel points back to its tab trigger.
- →Give the tablist an aria-label ('Content sections', 'Project views') so screen reader users understand what is being tabbed.
- →Arrow Left / Right should move focus and activate tabs. Home jumps to the first tab; End jumps to the last. Implement this in the keydown handler — see the HTML example above.
- →Tab key moves focus out of the tablist into the panel. Do not trap Tab inside the tablist.
- →Never put navigation behind tabs that changes the URL — use a link-based Nav component instead so the browser history is correct.
- →Badge counts should be read aloud. Add an aria-label to the badge element: aria-label='12 files'.