Skip to content

Tabs

A set of layered sections of content, where only one panel is visible at a time. Follows the WAI-ARIA tabs pattern with roving tabindex and full keyboard support. Built from scratch with React, TypeScript, and Tailwind CSS.

Code

tabs/
tabs-root.tsx
"use client";

import { useCallback, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { TabsValueContext } from "../context/tabs-value-context";
import { TabsMetaContext } from "../context/tabs-meta-context";
import type { TabsProps } from "../types";

export function Tabs({
  value: controlledValue,
  defaultValue,
  onValueChange,
  activationMode = "automatic",
  orientation = "horizontal",
  children,
  className,
}: TabsProps) {
  const [uncontrolledValue, setUncontrolledValue] = useState<string | null>(
    defaultValue ?? null,
  );

  const activeValue = controlledValue !== undefined ? controlledValue : uncontrolledValue;

  // ─── Stable-ref pattern ───────────────────────────────────────────────────
  const controlledValueRef = useRef(controlledValue);
  controlledValueRef.current = controlledValue;

  const onValueChangeRef = useRef(onValueChange);
  onValueChangeRef.current = onValueChange;

  const listRef = useRef<HTMLDivElement>(null);
  const baseId = useId();

  const selectTab = useCallback((tabValue: string) => {
    if (controlledValueRef.current === undefined) setUncontrolledValue(tabValue);
    onValueChangeRef.current?.(tabValue);
  }, []);

  const valueCtx = useMemo(() => ({ value: activeValue }), [activeValue]);

  // Reference-stable for the lifetime of the component: selectTab, listRef,
  // and baseId never change, and activationMode/orientation are expected to
  // stay constant (documented in the MDX trade-offs).
  const metaCtx = useMemo(
    () => ({ selectTab, listRef, baseId, activationMode, orientation }),
    [selectTab, baseId, activationMode, orientation],
  );

  return (
    <TabsMetaContext.Provider value={metaCtx}>
      <TabsValueContext.Provider value={valueCtx}>
        <div
          data-orientation={orientation}
          className={cn(
            "w-full",
            orientation === "vertical" && "flex gap-4",
            className,
          )}
        >
          {children}
        </div>
      </TabsValueContext.Provider>
    </TabsMetaContext.Provider>
  );
}

Examples

Default

Automatic activation: moving focus with the arrow keys also selects the tab. The selected tab is the only one in the page tab order (roving tabindex), so a single Tab press moves from the list into the panel.

Preview
Update your display name and email address.

Manual activation

With activationMode="manual", arrow keys only move focus; Enter or Space selects. Prefer this when switching panels is expensive, for example when a panel fetches data on selection.

Preview
Arrow keys only move focus here. Press Enter or Space to select.

Vertical

orientation="vertical" switches layout and navigation together: ArrowUp and ArrowDown move between tabs, and the list announces aria-orientation="vertical" to assistive technology.

Preview
Project name, description, and visibility.

Disabled tab

Disabled tabs use the native disabled attribute: they are skipped by both the Tab key and arrow navigation.

Preview
Get started with the basics, no credit card required.

Keyboard support

KeyBehavior
TabMoves into the list (lands on the selected tab), then out to the panel
ArrowRight / ArrowLeftHorizontal: focus next / previous enabled tab, wrapping; flipped in RTL
ArrowDown / ArrowUpVertical: focus next / previous enabled tab, wrapping
Home / EndFocus first / last enabled tab
Enter / SpaceSelect the focused tab (required in manual mode)

In automatic mode, every focus movement also selects the focused tab.


Design Decisions

Built in JavaScript because the platform has no tabs primitive

Before writing any code, the 2026 platform baseline was checked. Native <details name> gives exclusive open/close groups, but its semantics are disclosure, not tabs: no tablist role, no aria-selected, no roving tabindex. The CSS radio-button hack fails the same test. Since assistive technology behavior is the core of the tabs pattern and no Baseline feature provides it, this is one of the cases where a JS implementation is genuinely required. The panels are still server-rendered in full, so content is present in the HTML without JavaScript.

Two contexts, split by what actually changes

Following the same architecture as the accordion, state is split into a volatile TabsValueContext (the selected value) and a stable TabsMetaContext (selectTab, listRef, baseId, activationMode, orientation). Unlike the accordion, every trigger and panel legitimately depends on the selected value, so they subscribe to the volatile context by necessity. The split still pays: the meta context never changes reference, so future parts that only need plumbing (an animated indicator, for instance) can subscribe without re-rendering per selection.

DOM queries instead of an item registration system

Radix routes keyboard navigation through a roving-focus-group with per-item registration; Base UI maintains a CompositeList map of elements to metadata. Both exist so the library can know the ordered list of tabs. This implementation asks the DOM instead: listRef.current.querySelectorAll('[role="tab"]:not(:disabled)') at keydown time. The list is always current (no registration bookkeeping), disabled tabs are excluded by the selector itself, and the pure index math lives in resolveTabTarget, which is small enough to unit test in isolation.

Automatic activation by default, like the APG recommends

The five references disagree: Radix, React Aria, and Headless UI activate on focus by default, while Base UI ships manual activation by default. The APG recommends automatic activation when panels can display instantly, which is guaranteed here because every panel stays mounted. activationMode="manual" remains available for panels with expensive side effects, using Radix's prop naming.

aria-controls always present, unlike React Aria

React Aria only sets aria-controls on the selected tab, reasoning that the attribute is only useful when the panel exists and is visible. Radix and the APG example set it unconditionally. Since panels here are always mounted (merely hidden), the target id always exists, so the simpler unconditional form is also the more correct one for this architecture.


Trade-offs and limitations

A selection is required for keyboard reachability

Roving tabindex means the selected tab holds tabindex="0" and all others -1. If neither value nor defaultValue is provided, no tab is selected and the entire list becomes unreachable by keyboard until a mouse click selects one. Radix and Base UI solve this with fallback machinery that auto-selects the first enabled tab; this implementation documents the requirement instead. Always pass defaultValue (or value).

The panel is always a tab stop

tabIndex={0} is set on every panel so keyboard users can always move from the tab list into the panel, even when it contains no focusable elements. React Aria removes it when the panel has a tabbable child, saving one Tab press, at the cost of a runtime DOM scan and mutation observer per panel. That optimization was deliberately skipped; the extra tab stop is harmless and the panel's focus ring makes it legible.

Every panel stays in the DOM

Inactive panels are hidden with the hidden attribute, not unmounted. This keeps content in the server HTML for SEO and lets panel-local state survive tab switches, but it means heavy panels cost memory even when invisible, and hidden toggling cannot be animated with CSS transitions. Radix's forceMount and Base UI's keepMounted exist to make this a choice per panel; here it is the only behavior.

Tab values must be usable as element ids

Trigger and panel ids are derived as {baseId}-tab-{value} and {baseId}-panel-{value} for the aria-controls and aria-labelledby wiring. Values containing spaces or CSS-significant characters will produce awkward ids. Use short slug-like values.

activationMode and orientation are fixed at mount

Both live in the stable meta context, whose reference is intentionally never invalidated by selection changes. Changing them at runtime works but is not a supported pattern, matching the accordion's stance on its type prop.