Navigation
Tabs
One strip, several panels.
Usage
import { Tabs } from '@misoto22/design'Notes
Accessible tabs with a moving marker and optional horizontal drag gestures.
Anatomy
| Element | Description |
|---|---|
| Rootrequired | Tabs — Radix’s root, re-exported. It draws nothing and owns everything: value or defaultValue, and activationMode. With neither value nor defaultValue no tab is selected and no panel is mounted. |
| Tab striprequired | TabsList — the role="tablist" row, sitting on one hairline rule and scrolling on its own axis with scroll-slim. It has no accessible name unless you give it one. |
| Tabrequired | TabsTrigger, at the md control height with its label on one line. Every tab carries the 2px active marker, transparent until it is the selected one; the marker is pulled onto the strip’s own border with -mb-px so the two share a line rather than stacking into a 3px edge. |
| Panel | TabsContent, paired to its tab by matching value. Mounted only while it is the selected one, and marked data-m22-animated so its entrance is dropped for a reader who asked for less motion. |
Best practices
Do
- Give the root a defaultValue or a value: with neither, nothing matches, every panel stays unmounted, and the page renders a strip above an empty space with nothing to say what is missing.
- Match each trigger’s value to a panel’s value exactly — the pairing is string equality, and a typo is not an error but a tab that opens onto nothing.
- Pass activationMode="manual" when a panel fetches or renders something expensive: the default is automatic, so ← and → select as they move and arrowing across four tabs starts four loads before the reader has stopped.
- Give TabsList an aria-label on a page with more than one set: Radix names the tablist after nothing, and two unnamed tablists are two “tab list”s a reader cannot tell apart.
Don’t
- An unselected panel is unmounted, not hidden — find-in-page cannot reach its text, a print takes only the panel that was open, and a half-filled form in another tab has lost what was typed into it by the time the reader comes back.
- The selected tab lives in React state, not in the URL: a reader who reloads or shares the page lands on the first panel, so anything worth linking to needs value lifted into a query parameter.
- Tabs are not a way to fit more in: the strip scrolls silently, and a sixth tab past the fold looks exactly like a page that only has five.
Examples
default
One strip, three panels, paired by string equality — a typo in a value is not an error but a tab that opens onto nothing. Give the root a defaultValue or a value: with neither, nothing matches, every panel stays unmounted, and the page renders a strip above an empty space with nothing to say what is missing. The whole strip is one tab stop; left and right move between tabs, Home and End jump to the ends. The selected tab lives in React state and not in the URL, so anything worth linking to needs value lifted into a query parameter.
manual activation
activationMode manual, and a strip with a name. The default is automatic, so the arrow keys select as they move and arrowing across four tabs starts four loads before the reader has stopped — manual waits for Enter or Space, which is what a panel that fetches or renders something expensive needs. TabsList is named after nothing by default, so a page carrying more than one set gives each an aria-label: two unnamed tab lists are two tab lists a reader cannot choose between.
18,402 visits over the last 30 days, up 6% on the month before.
what gets hidden
Three ways to hide the same sentence, and the same consequence in all three: what is closed is unmounted rather than hidden, so find-in-page cannot reach it, a print takes only what happened to be open, and none of the three writes the open one into the URL. Choose by what the reader is doing. Tabs are mutually exclusive views of one subject and cost the reader whatever was typed into the panel they leave. An Accordion is a set that can coordinate — type multiple lets two rows be read against each other, and every row adds an h3 to the outline. A Collapsible is one disclosure with nothing to coordinate with, and it adds no heading at all. Anything that has to be searchable or printable belongs in the page, uncovered.
Tabs — one of several views
Billed at $18 a seat each month.
Accordion — a set of answers
Collapsible — detail on one thing
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
TabsList
The tab strip.
Scrolls horizontally rather than wrapping. A wrapped second row of tabs moves every tab below it when the strip grows, and the reader loses the one they were about to click.
Takes no props of its own.
TabsTrigger
One tab.
The active marker is a 2px ink rule pulled onto the strip's own border with -mb-px, so the two occupy the same line instead of stacking into a 3px edge. 44px tall, because a tab is a pointer target like any other.
Takes no props of its own.
TabsContent
The panel paired to a TabsTrigger by matching value.
Takes no props of its own.
Keyboard
| Key | Does |
|---|---|
| Tab | Moves into the strip, and out of it — the whole strip is one stop. |
| ←→ | Moves between tabs and switches the panel with them. |
| HomeEnd | Jumps to the first or last tab. |
Accessibility
- The strip scrolls rather than wrapping: a wrapped second row moves every tab below it and the reader loses the one they were about to click.
- 44px tall, because a tab is a pointer target like any other.