stream/ui
Browse the library

Tabs

Switch between related panels of content without leaving the page.

betaTabs.tsx

Examples

Basic

Use the arrow keys, Home, and End to move between tabs.

Traffic is up 12% week over week, led by organic search.

tabs/basic.tsx
import { Tabs } from "@streamui/react";

const items = [
  {
    id: "overview",
    label: "Overview",
    content: <p className="leading-relaxed">Traffic is up 12% week over week, led by organic search.</p>,
  },
  {
    id: "activity",
    label: "Activity",
    content: <p className="leading-relaxed">3 deploys today. The last one finished 4 minutes ago.</p>,
  },
  {
    id: "settings",
    label: "Settings",
    content: <p className="leading-relaxed">Project settings are managed by your workspace admin.</p>,
  },
];

export default function TabsBasic() {
  return <Tabs items={items} label="Project" className="w-full max-w-md" />;
}

Installation

bun add @streamui/react
usage.tsx
import { Tabs } from "@streamui/react";

<Tabs
  label="Project"
  items={[
    { id: "overview", label: "Overview", content: <p>…</p> },
    { id: "activity", label: "Activity", content: <p>…</p> },
  ]}
/>

API

PropTypeDefaultDescription
items{ id: string; label: ReactNode; content: ReactNode }[]—Tabs in order. id must be unique.
labelstring—Accessible name for the tab list. Required.
defaultIndexnumber0Tab selected on first render.
classNamestring—Classes for the wrapper.

Accessibility

  • Follows the WAI-ARIA tabs pattern: tablist, tab, and tabpanel roles wired with ids.
  • Only the selected tab is in the tab order; arrow keys move and select.
  • Panels are focusable so their content can be reached right after the tab list.

Source

packages/react/src/Tabs.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "./cn";

export type TabItem = { id: string; label: ReactNode; content: ReactNode };

export type TabsProps = {
  items: TabItem[];
  /** Accessible name for the tab list. */
  label: string;
  defaultIndex?: number;
  className?: string;
};

/**
 * WAI-ARIA tabs with automatic activation: arrow keys move and select,
 * Home/End jump to the ends, and only the active tab is in the tab order.
 */
export function Tabs({ items, label, defaultIndex = 0, className }: TabsProps) {
  const [active, setActive] = useState(defaultIndex);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const baseId = useId();

  function onKeyDown(e: KeyboardEvent) {
    const last = items.length - 1;
    const next =
      e.key === "ArrowRight" ? (active === last ? 0 : active + 1)
      : e.key === "ArrowLeft" ? (active === 0 ? last : active - 1)
      : e.key === "Home" ? 0
      : e.key === "End" ? last
      : null;
    if (next === null) return;
    e.preventDefault();
    setActive(next);
    refs.current[next]?.focus();
  }

  return (
    <div className={className}>
      <div role="tablist" aria-label={label} className="flex" onKeyDown={onKeyDown}>
        {items.map((t, i) => {
          const selected = i === active;
          return (
            <button
              key={t.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="tab"
              id={`${baseId}-tab-${t.id}`}
              aria-selected={selected}
              aria-controls={`${baseId}-panel-${t.id}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(i)}
              className={cn(
                "-mr-0.5 cursor-pointer border-2 border-ink px-4 py-2 text-sm font-bold transition-colors duration-150",
                selected
                  ? "relative z-10 -mb-0.5 border-b-paper-raised bg-paper-raised"
                  : "bg-paper-sunken text-ink-muted hover:bg-lime hover:text-on-bright",
              )}
            >
              {t.label}
            </button>
          );
        })}
      </div>
      {items.map((t, i) => (
        <div
          key={t.id}
          role="tabpanel"
          id={`${baseId}-panel-${t.id}`}
          aria-labelledby={`${baseId}-tab-${t.id}`}
          hidden={i !== active}
          tabIndex={0}
          className="border-2 border-ink bg-paper-raised p-5 shadow-brutal"
        >
          {t.content}
        </div>
      ))}
    </div>
  );
}