Tabs
StableSwitch between views of the same thing. A lit pill or a hairline glides to the selected tab; arrow keys move between tabs.
Tabs follows the WAI-ARIA tabs pattern with automatic activation: the arrow keys move and select at once, and only the selected tab is in the Tab order. Two looks: pill (a lit pill on a sunken strip) and line (a hairline under the selected tab). It works controlled or uncontrolled, keeps hidden panels mounted so their state survives, and has an actions slot at the end of the row. The home pages have their own rounder marketing Tabs, shown last.
Pill
import { useState } from 'react'import { Icon } from '@rchursin/product/icons/Icon'import { SampleBadge } from '@rchursin/product/shell/layout'import { Switch, Tabs } from '@rchursin/product/ui'
/** * The pill variant: a lit pill glides to the selected tab. ← → move and select, Home / End * jump. Two panels hold only text, so focusablePanels puts every panel in the Tab order. */const facts = [ ['Status', 'In progress'], ['Due', 'Oct 24'], ['Tasks', '12 of 18 done'],]
const activity = [ { icon: 'status-progress', text: 'Status changed to In progress', when: '2 hours ago' }, { icon: 'plus', text: 'Three tasks added from the launch checklist', when: 'Yesterday' }, { icon: 'calendar', text: 'Due date set to Oct 24', when: 'Oct 9' },] as constUsage
import { Tabs } from '@rchursin/product/ui'<Tabs label="Project" tabs={[ { value: 'overview', label: 'Overview', content: <Overview /> }, { value: 'activity', label: 'Activity', content: <Activity /> }, ]}/>Examples
Line
A hairline under the selected tab, small size, an action at the end of the row and a disabled tab the arrow keys skip.
import { Icon } from '@rchursin/product/icons/Icon'import { IconButton, Tabs } from '@rchursin/product/ui'
/** * The line variant: a hairline under the selected tab, for page-level sections. `actions` sit at * the end of the row. A disabled tab is skipped by the arrow keys. */const commits = [ { sha: '3f9c2a1', message: 'Add the line variant to Tabs' }, { sha: 'b71e0d4', message: 'Glide the marker on resize' }, { sha: '0c58e92', message: 'Skip disabled tabs with the arrow keys' },]
const checks = [ { icon: 'check', label: 'Typecheck', result: 'Passed' }, { icon: 'check', label: 'Accessibility', result: 'Passed, 0 issues' }, { icon: 'alert', label: 'Visual diff', result: '2 changes to review' },] as const
export default function TabsLine() {Controlled
value and onChange keep the selection in your state, so a Next button outside the tabs can move it.
import { useState } from 'react'import { Button, Tabs } from '@rchursin/product/ui'
/** * Controlled: `value` and `onChange` keep the selected tab in your state, so another control can * move it. The Next button sits outside the panels and is never disabled, so focus stays on it. */const steps = [ { value: 'files', label: '1. Files', body: 'Copy brand, components, fonts, home, product and styles, plus index.css and product.css, into src/rchursin/. Then import @rchursin/product.css once in your app entry.' }, { value: 'alias', label: '2. Alias', body: 'Point @rchursin at the folder in vite.config.ts and in the tsconfig paths, so examples paste as they are.' }, { value: 'providers', label: '3. Providers', body: 'Wrap the app once in AnnouncerProvider and ToastProvider. Add the class dark to <html> for dark mode.' },] as const
type Step = (typeof steps)[number]['value']
export default function TabsControlled() { const [step, setStep] = useState<Step>('files') const at = steps.findIndex((s) => s.value === step) const last = at === steps.length - 1
Marketing tabs
The home pages’ own Tabs (home/Tabs.tsx): a rounder, taller pill that fills the width on a phone. Simpler API: a name and content per tab.
import { Tabs } from '@rchursin/home/Tabs'
/** * The marketing Tabs from the home pages: a rounder, larger pill that takes the full width on a * phone. Tabs are { name, content }; panels are always in the Tab order. */export default function TabsMarketing() { return ( <div className="w-full max-w-lg"> <Tabs label="Ways to use the Library" tabs={[ { name: 'Browse', content: ( <p className="text-[14px]/6 text-pretty text-fg-2">Every part has a live preview, the code that runs it, its props, and notes on keyboard and screen readers.</p> ), }, { name: 'Copy', content: <p className="text-[14px]/6 text-pretty text-fg-2">Each example is a real file that imports only from @rchursin/…, so it pastes into your app as it is.</p>, }, { name: 'Build', content: <p className="text-[14px]/6 text-pretty text-fg-2">This website is built with the same parts. Each page lists the components it uses.</p>, }, ]} /> </div> )}API
Tabs props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Accessible name of the tab list, e.g. “Project”. |
tabsrequired | TabItem<V>[] | – | The tabs, in order. See TabItem below. |
value | V | – | The selected tab’s value, when you control it. Pair it with onChange. |
defaultValue | V | first tab | The tab selected at first, when uncontrolled. |
onChange | (value: V) => void | – | Called with the new value when a tab is chosen by click or arrow key. |
variant | 'pill' | 'line' | 'pill' | pill: a lit pill on a sunken strip. line: a hairline under the selected tab, with a rule under the row. |
size | 'sm' | 'md' | 'md' | Tab height 28 or 32 pixels. |
actions | ReactNode | – | Shown at the end of the tab row, e.g. a copy button or a sample badge. |
className | string | – | Classes on the outer wrapper. |
listClassName | string | – | Classes on the row that holds the tab list and actions. |
panelClassName | string | – | Classes on every panel, e.g. a top margin. |
keepMounted | boolean | true | Keep hidden panels in the DOM (with the hidden attribute) so their state survives switching. false renders only the selected panel. |
focusablePanels | boolean | false | Give every panel tabIndex 0 and a focus ring. Turn it on when a panel may have nothing focusable inside, so keyboard users can reach its text. |
TabItem
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | V extends string | – | Unique key; also used in the tab and panel ids. |
labelrequired | ReactNode | – | The tab’s visible name. |
icon | IconName | – | An icon before the label. |
contentrequired | ReactNode | – | The panel. |
disabled | boolean | – | Greys the tab out (45% opacity), removes it from clicks and skips it with the arrow keys. |
Marketing Tabs props (home/Tabs)
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Accessible name of the tab list. |
tabsrequired | { name: string; content: ReactNode }[] | – | Each tab’s name and panel. Uncontrolled: the first tab starts selected. |
className | string | – | Classes on the outer wrapper. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves into the tab list, landing on the selected tab. The next Tab goes to the row’s actions, then to the panel (or into it). |
| Right arrowLeft arrow | Selects the next or previous tab and moves focus to it, wrapping at the ends. Disabled tabs are skipped. |
| HomeEnd | Selects the first or last tab. |
- Roles tablist, tab and tabpanel, with aria-selected on tabs and aria-controls / aria-labelledby linking each tab to its panel.
- Roving tabindex: only the selected tab has tabIndex 0, so the whole list is one Tab stop.
- Selection follows focus (automatic activation). Keep panels cheap to show; for panels that load slowly, consider a manual pattern.
- Hidden panels use the hidden attribute, so screen readers and Tab skip them.
- The selected tab is told apart by shape (the lit pill or the hairline) and by full-strength text, not by colour.
- The marketing Tabs (home/Tabs) works the same way, except that its panel is always in the Tab order and it has no disabled tabs.
Guidelines
Do
- Use tabs for views of the same thing (a project’s overview, activity, settings).
- Keep labels to one or two words so the row fits on a phone.
- Turn on focusablePanels when a panel holds only text.
Don’t
- Don’t use tabs for steps people must do in order without showing progress; use a stepper or a form.
- Don’t use tabs to go to other pages; use links.
- Don’t hide something people must compare side by side behind two tabs.
In use
Used on this site
Related
- Segmented controlA radio group drawn as one strip, with a lit pill that glides to the choice and arrow keys that move and choose.
- Link & routerA small router with no dependencies: Link changes page without a reload, useRouter says where you are, in history or hash mode.
- Code blockHighlighted code in five grey roles, with line numbers, picked-out lines, a Wrap toggle, Copy, and long files folded.
Source
The component’s own files, exactly as they run on this site.
/** * Tabs (WAI-ARIA tabs pattern). ← → move between tabs and select them, Home / End jump to the * ends, and only the selected tab is in the Tab order. A lit pill (or a hairline, in the `line` * variant) glides to the selected tab. * * <Tabs label="Example" tabs={[ * { value: 'preview', label: 'Preview', content: <Demo /> }, * { value: 'code', label: 'Code', content: <CodeBlock code={src} /> }, * ]} /> * * Controlled with `value` + `onChange`, or uncontrolled with `defaultValue`. * `actions` sit at the right end of the tab row (a copy button, a "View source" link). */import { useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"import { cx } from "../../components/cx"import { Icon, type IconName } from "../icons/Icon"
export interface TabItem<V extends string> { value: V label: ReactNode