Tabs
A row of tabs over panels of the same subject: one panel shows at a time, and the page stays where it is.
- 0.3.1README maps set in New York
- 0.3.0Charts, theme switching without a flash
- 0.2.0Maps on MapLibre GL
- 0.1.1Calendar limits accept null
- 0.1.0First release
npm install cennge-ui- Click a tab, or focus one and use the arrows: each panel opens as the focus reaches its tab. Home and End go to the ends.
- Tab from the selected tab moves into its panel.
- The package-manager tabs are underlined in the primary colour, so they change with the accent of these docs.
Field marks
- Import
import { Tabs, TabsList, TabsTrigger, TabsContent } from "cennge-ui";- Size
- 1.3 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA tabs: a tablist whose tabs each control a tabpanel
- Keyboard
- Arrow left Arrow right HomeEnd
- State
- Controlled with
valueandonValueChange, ordefaultValue - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Activation
- A tab opens its panel as soon as it is focused
- Panels
- Only the open one has its content rendered
- Tests
- Unit tests
- Source
- src/components/tabs
When to use#
Tabs hold a few panels about one thing, a package's readme, versions and dependencies say, and show one at a time without leaving the page. Every TabsTrigger needs a TabsContent with the same value: screen readers go from the tab to the panel it controls.
Similar#
- ToggleGroup switches a filter or a view with no panels behind it; when nothing would go in a
TabsContent, use it instead. - Accordion opens sections in place, several at once if you like, all readable in one scroll; Tabs show one panel and hide the rest.
- Sidebar and NavigationMenu take the reader to another page; Tabs change what this page shows and leave the address alone.
Variants and colours#
solid sets the tabs on a sunken track and raises the selected one, underlined draws a line under the list and a bolder one under the selected tab, light tints the selected tab and nothing else. color paints the selected tab; primary follows the accent.
import { Tabs, TabsContent, TabsList, TabsTrigger, type TabsVariant } from "cennge-ui";
const files = [
{ value: "readme", label: "README.md", text: "Tactile, dark-first React components on Radix UI and Tailwind CSS v4." },
{ value: "changelog", label: "CHANGELOG.md", text: "0.3.1: the README's map examples are set in New York." },
{ value: "license", label: "LICENSE", text: "MIT License, copyright 2026 Cennge." },
];
const variants: TabsVariant[] = ["solid", "underlined", "light"];
export default function TabsVariants() {
return (
<div className="flex w-full flex-col gap-8">
{variants.map((variant) => (
<Tabs key={variant} defaultValue="readme" variant={variant} color={variant === "solid" ? "default" : "primary"}>
<TabsList aria-label={`Files, ${variant}`}>
{files.map((file) => (
<TabsTrigger key={file.value} value={file.value}>
{file.label}
</TabsTrigger>
))}
</TabsList>
{files.map((file) => (
<TabsContent key={file.value} value={file.value} className="text-sm text-muted">
{file.text}
</TabsContent>
))}
</Tabs>
))}
</div>
);
}Controlled#
With value and onValueChange the open tab is yours to set, from a button in one of the panels here, or from the address of the page.
Spread alerts for spredo-crypto-screener: one threshold per pair, read from settings.
import { useState } from "react";
import { Badge, Button, Tabs, TabsContent, TabsList, TabsTrigger } from "cennge-ui";
const commits = ["Add a spread alert per pair", "Read the alert threshold from settings", "Test the alert on a stale price"];
const checks = [
{ name: "lint", status: "Passed", color: "success" },
{ name: "test", status: "Passed", color: "success" },
{ name: "build", status: "Running", color: "warning" },
] as const;
export default function PullRequestTabs() {
const [tab, setTab] = useState("conversation");
return (
<Tabs value={tab} onValueChange={setTab} variant="underlined" color="primary" className="w-full max-w-md">
<TabsList aria-label="Pull request">
<TabsTrigger value="conversation">Conversation</TabsTrigger>
<TabsTrigger value="commits">Commits</TabsTrigger>
<TabsTrigger value="checks">Checks</TabsTrigger>
</TabsList>
<TabsContent value="conversation" className="flex flex-col items-start gap-3 text-sm">
<p>Spread alerts for spredo-crypto-screener: one threshold per pair, read from settings.</p>
<Button size="sm" variant="flat" onClick={() => setTab("checks")}>
See the checks
</Button>
</TabsContent>
<TabsContent value="commits">
<ul className="flex flex-col gap-2 text-sm">
{commits.map((commit) => (
<li key={commit}>{commit}</li>
))}
</ul>
</TabsContent>
<TabsContent value="checks">
<ul className="flex flex-col gap-2 text-sm">
{checks.map((check) => (
<li key={check.name} className="flex items-center justify-between gap-4">
<code className="font-code">{check.name}</code>
<Badge size="sm" variant="dot" color={check.color}>
{check.status}
</Badge>
</li>
))}
</ul>
</TabsContent>
</Tabs>
);
}Full width#
fullWidth stretches the list across its container and gives every tab an equal share. Only the open panel's content is rendered, so a field in a panel you leave starts empty when you come back; keep its value in state above the Tabs if it should last.
import { Button, Input, PasswordInput, Tabs, TabsContent, TabsList, TabsTrigger } from "cennge-ui";
export default function AccountTabs() {
return (
<Tabs defaultValue="sign-in" fullWidth className="w-full max-w-sm">
<TabsList aria-label="Account">
<TabsTrigger value="sign-in">Sign in</TabsTrigger>
<TabsTrigger value="create">Create account</TabsTrigger>
</TabsList>
<TabsContent value="sign-in" className="flex flex-col gap-4">
<Input label="Email" type="email" autoComplete="email" placeholder="dev@example.com" />
<PasswordInput label="Password" />
<Button color="primary">Sign in</Button>
</TabsContent>
<TabsContent value="create" className="flex flex-col gap-4">
<Input label="Email" type="email" autoComplete="email" placeholder="dev@example.com" />
<PasswordInput label="Password" autoComplete="new-password" />
<Button color="primary">Create account</Button>
</TabsContent>
</Tabs>
);
}Accessibility#
Name the list with aria-label on TabsList. Only the selected tab is in the tab order: the arrows move between tabs, skipping those with isDisabled, and Tab goes on into the open panel, which takes focus itself so its content is reachable even when it holds nothing focusable.
Props#
Tabs#
TabsTrigger#
Also takes every attribute of <button>.
TabsContent#
Also takes every attribute of <div>.