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.

12
Files
3
Contributors
28d
Last updated

Variants

Tabs support an optional badge next to the label for counts or status markers.

Showing all items.

Viewing inbox.

When to use

ComponentUse when
TabsSwitching between related views within the same context. Content does not need to be compared side-by-side.
Segmented ButtonChoosing a single option from a small, mutually exclusive set (2–4 options). Acts as a filter or view toggle, not a navigation element.
NavigationMoving between top-level sections of an application. Each section has a distinct URL.
AccordionProgressive disclosure within a single view. Sections are not mutually exclusive — multiple can be open at once.

Anatomy

ElementSpec
Tab barrole='tablist', flex row, border-bottom: 1px --border, px-1 horizontal padding
Tab triggerrole='tab', px-3 py-2.5, 13px medium, gap-1.5 for label + badge, rounded-t focus ring
Active indicator2px --accent, absolute bottom-0, rounded-t-full, aria-hidden
Badge10px semibold, px-1.5 py-0.5, rounded-full, --accent-subtle bg, --accent text
Tab panelrole='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.

Tabs.tsx
tsx
"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.

PropTypeDefaultDescription
tabsTab[]Ordered array of tab descriptors. Each item requires an id and label; badge is optional.
defaultTabstringId of the tab that is active on first render. Defaults to the first tab if omitted.
children(activeId: string) => ReactNodeRender prop called with the currently active tab id. Render the panel content conditionally on this value.
classNamestringAdditional class names applied to the outermost wrapper.

Props — Tab

Shape of each item in the tabs array.

PropTypeDefaultDescription
idstringUnique identifier for this tab. Used to match activeId in the render prop and as the defaultTab value.
labelstringVisible tab label. Keep it short — 1–2 words.
badgestringShort 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.

InteractionPropertyValueEasing
Tab hovercolor--text-secondary150ms ease
Tab selectcolor--text-primaryinstant
Indicator slideleft / widthper-tab positionspring(300, 30) — iOS/macOS
Indicator appearopacity0 → 1150ms ease — web
Focus ringbox-shadow2px --accentinstant

ARIA roles

ElementRoleKey attributes
Tab bartablistaria-label
Tab triggertabaria-selected, aria-controls
Tab paneltabpanelaria-labelledby

Keyboard

KeyAction
Arrow Right / LeftMove focus to and activate the next / previous tab
HomeMove focus to and activate the first tab
EndMove focus to and activate the last tab
TabMove focus from the tab list into the active panel
Shift+TabMove 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'.