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.
3 deploys today. The last one finished 4 minutes ago.
Project settings are managed by your workspace admin.
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/reactusage.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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; label: ReactNode; content: ReactNode }[] | — | Tabs in order. id must be unique. |
| label | string | — | Accessible name for the tab list. Required. |
| defaultIndex | number | 0 | Tab selected on first render. |
| className | string | — | 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>
);
}