Tabs
Tab set with roving focus and an ink underline active state.
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@elirobinson/react/components/organisms/Tabs';Styles: @elirobinson/react/styles/organisms/Tabs.css — already included when you import @elirobinson/react/styles.css.
Youth Football Fundamentals covers eight sessions of ball control, positioning, and small-sided games for ages 8-12.
Show code
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';
export default function Basic() {
return (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="curriculum">Curriculum</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<p>
Youth Football Fundamentals covers eight sessions of ball control, positioning, and
small-sided games for ages 8-12.
</p>
</TabsContent>
<TabsContent value="curriculum">
<p>Session plans, warm-up drills, and printable session cards — one PDF per week.</p>
</TabsContent>
<TabsContent value="reviews">
<p>
"My under-10s squad picked up the passing drills in one session." — Coach Priya
Nair
</p>
</TabsContent>
</Tabs>
);
}When to use it
Use Tabs to switch between views that share the same context — different cuts of one
dataset, sections of one guide, steps in a self-contained flow. Inactive panels don't just
hide: TabsContent returns null when it isn't the active tab, so anything with local
state inside it unmounts and resets when you navigate away and back.
Reach for Accordion instead when the sections aren't mutually exclusive — a reader might
want two open at once. Reach for a real page or route once the content is substantial
enough that someone would want to bookmark or share a link straight to one section.
Controlled
Pass value and onValueChange when something outside the tablist needs to read or drive
the active tab — here, external Back/Next buttons step through the same tabs.
defaultValue is still required by the type even in controlled mode; pass the same initial
value you gave your own state.
Guide: Basketball Conditioning — digital PDF, delivered by email.
Show code
import { useState } from 'react';
import { Button } from '@elirobinson/react/components/atoms/Button';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';
const STEPS = ['details', 'shipping', 'confirm'] as const;
type Step = (typeof STEPS)[number];
export default function Controlled() {
const [step, setStep] = useState<Step>('details');
const index = STEPS.indexOf(step);
return (
<div className="demo-col">
<Tabs defaultValue="details" value={step} onValueChange={(value) => setStep(value as Step)}>
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="shipping">Shipping</TabsTrigger>
<TabsTrigger value="confirm">Confirm</TabsTrigger>
</TabsList>
<TabsContent value="details">
<p>Guide: Basketball Conditioning — digital PDF, delivered by email.</p>
</TabsContent>
<TabsContent value="shipping">
<p>Nothing to ship — digital guides land in your inbox within a minute.</p>
</TabsContent>
<TabsContent value="confirm">
<p>Review your order, then check out below.</p>
</TabsContent>
</Tabs>
<div className="demo-row">
<Button
variant="secondary"
disabled={index === 0}
onClick={() => setStep(STEPS[index - 1])}
>
Back
</Button>
<Button
variant="primary"
disabled={index === STEPS.length - 1}
onClick={() => setStep(STEPS[index + 1])}
>
Next
</Button>
</div>
</div>
);
}Disabled tab
A disabled TabsTrigger can't take focus, so arrow-key navigation skips over it — and if
it happens to be the active tab, the tablist's single tab stop moves to the first
focusable trigger instead.
Youth Football Fundamentals — 8 sessions, ages 8-12.
Show code
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@elirobinson/react/components/organisms/Tabs';
export default function Disabled() {
return (
<Tabs defaultValue="football">
<TabsList>
<TabsTrigger value="football">Football</TabsTrigger>
<TabsTrigger value="basketball" disabled>
Basketball (sold out)
</TabsTrigger>
<TabsTrigger value="rugby">Rugby</TabsTrigger>
</TabsList>
<TabsContent value="football">
<p>Youth Football Fundamentals — 8 sessions, ages 8-12.</p>
</TabsContent>
<TabsContent value="basketball">
<p>Basketball Conditioning is fully booked for this term.</p>
</TabsContent>
<TabsContent value="rugby">
<p>Rugby Contact Basics — 6 sessions, ages 10-14.</p>
</TabsContent>
</Tabs>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | number | readonly string[] | — | — |
onValueChange | ((value: string) => void) | — | — |
value | string | — | — |
Also accepts all HTMLAttributes<HTMLDivElement> props.
TabsList
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | number | readonly string[] | — | — |
Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'role'> props.
TabsTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | — |
defaultValue | string | number | readonly string[] | — | — |
onClick | MouseEventHandler<HTMLButtonElement> | — | — |
Also accepts all Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'id' | 'role' | 'onClick' | 'aria-selected' | 'aria-controls' | 'tabIndex'> props.
TabsContent
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | — |
defaultValue | string | number | readonly string[] | — | — |
Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'role' | 'aria-labelledby'> props.
Accessibility
Implements the WAI-ARIA tabs pattern: role="tablist" on TabsList, role="tab" on each
TabsTrigger (with aria-selected and aria-controls), role="tabpanel" on
TabsContent (with aria-labelledby pointing back at its tab).
- Roving tabindex: exactly one trigger is in the page's tab order at a time — normally
the active one — everything else carries
tabIndex={-1}. - Keyboard:
Tabmoves focus into and out of the tablist.ArrowRight/ArrowDownmove to the next trigger,ArrowLeft/ArrowUpto the previous, both wrapping past the ends.Homejumps to the first trigger,Endto the last. Arrow keys only move focus — they do not select.EnterandSpaceactivate whichever trigger currently has focus. - Disabled triggers are skipped entirely by arrow navigation and can never hold the tab stop; if the active trigger is disabled, ownership falls back to the first focusable one.
- Two independent
Tabsinstances on the same page keep separate tablists — arrow navigation never crosses from one into the other.
Do
- Match TabsTrigger and TabsContent value props exactly — that pairing wires aria-controls and aria-labelledby together.
- Use value and onValueChange when something outside the tablist needs to read or drive the active tab.
- Keep trigger labels to a word or two — TabsList wraps once labels get long.
- Give a disabled tab a reason in its own label ("Basketball (sold out)") since arrow navigation will silently skip past it.
Don't
- Expect a custom onClick's preventDefault() to block the tab switch — TabsTrigger always calls setActiveTab after your handler runs.
- Disable every trigger in a TabsList — with nothing focusable left, the whole tablist drops out of the tab order.
- Keep unsaved form input inside a TabsContent panel and expect it to survive a trip to another tab — panels unmount, they do not hide.
- Test the keyboard contract with clicks only — the real contract is roving focus (arrows, Home, End) plus Enter/Space on the focused trigger.