Tabs
Views of one thing, shown one at a time. Every panel renders on the server and stays in the HTML, so hidden text is still indexed.
Tabs
split - size not measured yetViews of one thing, shown one at a time. Every panel renders on the server and stays in the HTML, so hidden text is still indexed.
Try it
The code
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
<TabsRoot defaultValue="overview">
<TabsList aria-label="Project">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="activity">Activity</TabsTab>
<TabsTab value="settings">Settings</TabsTab>
</TabsList>
<TabsPanel value="overview">Revenue is up 12% against last quarter.</TabsPanel>
<TabsPanel value="activity">Three people edited this project today.</TabsPanel>
<TabsPanel value="settings">Only an owner can rename or delete this project.</TabsPanel>
</TabsRoot>keepMounted is true here, where Base UI's default is false: inactive panels stay in the server HTML, hidden.
Every state
2 statesImport from the package
import { TabsRoot, TabsList, TabsPanel, TabsTab } from 'topclassui/tabs'Install once:npm i topclassui@beta zod
Examples
- Four flat parts and no bare
Tabs:TabsRoot,TabsList,TabsTabandTabsPanel. Each passes the rest of its props to the element it draws. - Always pass
defaultValue, orvalue. Without it the server HTML shows no panel until the page hydrates. - Write a panel's content in your Server Component. A heavy panel can take
keepMounted={false}, which leaves it out until its tab opens. variantonTabsRoot:linedraws a bar under the active tab,enclosedraises it inside a tinted box.sizeonTabsRootsets every tab.customsets nothing: give eachTabsTabits ownclassName.- Many tabs scroll sideways inside the list, and the page does not. A long label ends in an ellipsis and is cut at 100 characters.
valuewithonValueChangeneeds a'use client'file, as the controlled example shows.
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
/** The drawn sample, in the line look: the active tab is semibold over a bar of the primary
* colour. Each panel is written here, on the server, and all three are in the HTML. */
export default function Demo() {
return (
<TabsRoot defaultValue="overview">
<TabsList aria-label="Project">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="activity">Activity</TabsTab>
<TabsTab value="settings">Settings</TabsTab>
</TabsList>
<TabsPanel value="overview">Revenue is up 12% against last quarter.</TabsPanel>
<TabsPanel value="activity">Three people edited this project today.</TabsPanel>
<TabsPanel value="settings">Only an owner can rename or delete this project.</TabsPanel>
</TabsRoot>
)
}
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
/** The enclosed look: the tabs sit in a tinted box as wide as they are, and the active one
* is lifted onto a raised surface. */
export default function Demo() {
return (
<TabsRoot variant="enclosed" defaultValue="overview">
<TabsList aria-label="Project">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="activity">Activity</TabsTab>
<TabsTab value="settings">Settings</TabsTab>
</TabsList>
<TabsPanel value="overview">Revenue is up 12% against last quarter.</TabsPanel>
<TabsPanel value="activity">Three people edited this project today.</TabsPanel>
<TabsPanel value="settings">Only an owner can rename or delete this project.</TabsPanel>
</TabsRoot>
)
}
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
const LOOKS = ['line', 'enclosed'] as const
/** size goes on the root and reaches every tab. custom emits no height, padding or type
* step, so each tab takes them from its own className. */
export default function Demo() {
return (
<div className="grid gap-8">
{LOOKS.map((variant) => (
<div key={variant} className="grid gap-4">
{SIZES.map((size) => (
<TabsRoot key={size} variant={variant} size={size} defaultValue="one">
<TabsList aria-label={`${variant} ${size}`}>
<TabsTab value="one">Size {size}</TabsTab>
<TabsTab value="two">Props</TabsTab>
</TabsList>
<TabsPanel value="one">The {size} tabs of the {variant} look.</TabsPanel>
<TabsPanel value="two">Props of {size}.</TabsPanel>
</TabsRoot>
))}
<TabsRoot variant={variant} size="custom" defaultValue="one">
<TabsList aria-label={`${variant} custom`}>
<TabsTab value="one" className="h-14 px-5 text-lg">Your own size</TabsTab>
<TabsTab value="two" className="h-14 px-5 text-lg">Props</TabsTab>
</TabsList>
<TabsPanel value="one">Tabs sized by their own classes.</TabsPanel>
<TabsPanel value="two">Props of your own size.</TabsPanel>
</TabsRoot>
</div>
))}
</div>
)
}
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
/** A disabled tab is dimmed and cannot be made active. The arrow keys still stop on it, so
* a screen reader hears that it is there and that it is unavailable. */
export default function Demo() {
return (
<div className="grid gap-8">
<TabsRoot defaultValue="overview">
<TabsList aria-label="Release">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="changelog" disabled>Changelog</TabsTab>
<TabsTab value="props">Props</TabsTab>
</TabsList>
<TabsPanel value="overview">Version 2.4 ships on Monday.</TabsPanel>
<TabsPanel value="changelog">The changelog is written after the release.</TabsPanel>
<TabsPanel value="props">Two props changed their default.</TabsPanel>
</TabsRoot>
<TabsRoot variant="enclosed" defaultValue="overview">
<TabsList aria-label="Release, enclosed">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="changelog" disabled>Changelog</TabsTab>
<TabsTab value="props">Props</TabsTab>
</TabsList>
<TabsPanel value="overview">Version 2.4 ships on Monday.</TabsPanel>
<TabsPanel value="changelog">The changelog is written after the release.</TabsPanel>
<TabsPanel value="props">Two props changed their default.</TabsPanel>
</TabsRoot>
</div>
)
}
- Amal Haddad
- Omar Abed
- Lina Saleh
- Yousef Nasser
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
const MEMBERS = ['Amal Haddad', 'Omar Abed', 'Lina Saleh', 'Yousef Nasser']
/** What a panel holds is rendered on the server and handed to the client part as children:
* a list read from data, and a form whose field and button are Server Components too.
* It opens on the form; the list waits in the HTML, hidden, until its tab is opened. */
export default function Demo() {
return (
<TabsRoot defaultValue="invite">
<TabsList aria-label="Team">
<TabsTab value="members">Members</TabsTab>
<TabsTab value="invite">Invite</TabsTab>
</TabsList>
<TabsPanel value="members">
<ul className="grid gap-2 text-sm">
{MEMBERS.map((member) => <li key={member}>{member}</li>)}
</ul>
</TabsPanel>
<TabsPanel value="invite">
<form className="grid max-w-sm gap-2">
<label htmlFor="demo-invite" className="text-sm font-medium text-body">Email</label>
<Input id="demo-invite" name="email" type="email" autoComplete="email" required fullWidth />
<Button type="submit" className="justify-self-start">Send the invite</Button>
</form>
</TabsPanel>
</TabsRoot>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
/** value and onValueChange need a client file: a Server Component page cannot pass a
* function. Here a button outside the list moves to the last tab. */
export default function Demo() {
const [value, setValue] = useState('draft')
return (
<div className="grid gap-4">
<TabsRoot value={value} onValueChange={(next: string) => { setValue(next) }}>
<TabsList aria-label="Post">
<TabsTab value="draft">Draft</TabsTab>
<TabsTab value="review">Review</TabsTab>
<TabsTab value="published">Published</TabsTab>
</TabsList>
<TabsPanel value="draft">Only you can see a draft.</TabsPanel>
<TabsPanel value="review">Two reviewers have been asked.</TabsPanel>
<TabsPanel value="published">Everyone can read a published post.</TabsPanel>
</TabsRoot>
<Button variant="outline" className="justify-self-start" onClick={() => { setValue('published') }}>
Go to Published
</Button>
</div>
)
}
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
const SECTIONS = ['Overview', 'Activity', 'Members', 'Billing', 'Integrations', 'Security', 'Notifications', 'Changelog']
/** Eight tabs, more than a phone is wide: each list scrolls sideways inside itself, and the
* page does not. An arrow key brings the next tab into view. */
export default function Demo() {
return (
<div className="grid gap-8">
<TabsRoot defaultValue="Overview">
<TabsList aria-label="Workspace">
{SECTIONS.map((section) => <TabsTab key={section} value={section}>{section}</TabsTab>)}
</TabsList>
{SECTIONS.map((section) => <TabsPanel key={section} value={section}>Everything under {section}.</TabsPanel>)}
</TabsRoot>
<TabsRoot variant="enclosed" defaultValue="Overview">
<TabsList aria-label="Workspace, enclosed">
{SECTIONS.map((section) => <TabsTab key={section} value={section}>{section}</TabsTab>)}
</TabsList>
{SECTIONS.map((section) => <TabsPanel key={section} value={section}>Everything under {section}.</TabsPanel>)}
</TabsRoot>
</div>
)
}
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
const LONG = 'Quarterly revenue, costs and forecast for every region the finance team reports on'
/** A label too long for its list ends in an ellipsis: no tab is ever wider than the list it
* sits in, so the other tabs are still reached by scrolling the list. */
export default function Demo() {
return (
<div className="grid max-w-sm gap-8">
<TabsRoot defaultValue="report">
<TabsList aria-label="Report">
<TabsTab value="report">{LONG}</TabsTab>
<TabsTab value="notes">Notes</TabsTab>
</TabsList>
<TabsPanel value="report">{LONG}.</TabsPanel>
<TabsPanel value="notes">Nobody has written a note yet.</TabsPanel>
</TabsRoot>
<TabsRoot variant="enclosed" defaultValue="report">
<TabsList aria-label="Report, enclosed">
<TabsTab value="report">{LONG}</TabsTab>
<TabsTab value="notes">Notes</TabsTab>
</TabsList>
<TabsPanel value="report">{LONG}.</TabsPanel>
<TabsPanel value="notes">Nobody has written a note yet.</TabsPanel>
</TabsRoot>
</div>
)
}
import { TopClassProvider } from 'topclassui/provider'
import { TabsList, TabsPanel, TabsRoot, TabsTab } from 'topclassui/tabs'
/** Arabic, inside TopClassProvider as the root layout would mount it. The first tab is at the
* right, and the arrow keys follow: the left arrow goes to the next tab, the right one back. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<TabsRoot defaultValue="overview">
<TabsList aria-label="المشروع">
<TabsTab value="overview">نظرة عامة</TabsTab>
<TabsTab value="activity">النشاط</TabsTab>
<TabsTab value="settings">الإعدادات</TabsTab>
</TabsList>
<TabsPanel value="overview">ارتفعت الإيرادات 12% عن الربع الماضي.</TabsPanel>
<TabsPanel value="activity">عدّل ثلاثة أشخاص هذا المشروع اليوم.</TabsPanel>
<TabsPanel value="settings">لا يعيد تسمية المشروع أو يحذفه إلا مالكه.</TabsPanel>
</TabsRoot>
</div>
</TopClassProvider>
)
}
Props
TabsRoot
TabsList
TabsPanel
TabsTab
No props of its own: it takes the attributes of the element it renders.
Keyboard and accessibility
TabsListneedsaria-labeloraria-labelledby: TypeScript refuses it without one.- A
disabledtab cannot be opened, but the arrow keys still stop on it, so a screen reader hears that it is there. - The panel is a tab stop of its own. Pass
tabIndex={-1}to aTabsPanelwhose first thing is a button or a field.
Right to left
The first tab sits at the right. With TopClassProvider direction="rtl" the left arrow goes to the next tab; without a provider the keys stay left to right.
Server and client
Renders on the server; the interactive part is a client component that ships only when it is used.
Files: index.tsx, client.tsx.
On the chip: split - size not measured yet.