Tabs
A row of tabs over one panel — or, with an href on every item, a row of links that navigate. Same skin, two semantics, and the component picks by looking at the items. The selected tab is marked by one element that slides across the row: it is measured from the active tab's own box, so it lands on it to the pixel whatever the labels say.
- Stable
- React 19
- APG tabs + navigation
- One-element indicator
- Five sizes
hidden attribute.The segmented control: a sunken track, a flat thumb, and one panel below.
Tabs is data-driven. You pass an array of items; there are no compound components to import and nothing to keep in sync.
Code
<Tabslabel="Settings"items={[ { value: 'general', label: 'General', content: <General /> }, { value: 'appearance', label: 'Appearance', content: <Appearance /> }, { value: 'privacy', label: 'Privacy', content: <Privacy /> },]}/><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:124px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Settings"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div> <div class="wayra-tabs-panels"> <div class="wayra-tabs-panel" role="tabpanel" tabindex="0">The General panel.</div> </div></div>Selection is uncontrolled by default and starts on the first enabled item.
Pass defaultValue to start elsewhere, or value + onChange to own it:
const [tab, setTab] = useState('appearance');
<Tabs label="Settings" value={tab} onChange={setTab} items={items} />onChange fires for a pointer and for the keyboard alike, with the value of
the tab that was chosen.
A value can outlive its tab — the tab is removed, or the items arrive after
the first render. Uncontrolled, the component owns the selection, so it falls
back to the first enabled item, the same start as a bar with no
defaultValue; a defaultValue whose item arrives late still wins once it
does. Controlled, value is your word: one that names no item selects
nothing, so no tab is marked, no panel shows and the indicator hides until a
value matches again. Nothing fires onChange on your behalf.
Two semantics, one skin
Section titled “Two semantics, one skin”The items decide what the component is, and the difference is not cosmetic.
| Widget mode | Link mode | |
|---|---|---|
| Trigger | no item has href |
every item has href |
| Root | <div> |
<nav aria-label> |
| Items | <button role="tab"> |
<a href> |
| Marked with | aria-selected |
aria-current="page" |
| Panels | one per item, hidden |
none — the page is the panel |
| Tab order | one stop, arrows move inside | one stop per link |
Use widget mode when the panels are part of the same view: settings groups,
a detail pane, anything where leaving would lose state. Use link mode when
each tab is a URL — then middle-click, Cmd-click, “open in new tab” and the
back button all work, because they are ordinary links. A click with Cmd, Ctrl,
Shift or Alt, or with the middle button, opens the link somewhere else, so the
bar keeps its selection and onChange does not fire.
Link mode: a labelled landmark, three anchors, no tablist and no panels.
Code
<Tabslabel="Account"variant="underline"value={pathname}items={[ { value: '/profile', label: 'Profile', href: '/profile' }, { value: '/billing', label: 'Billing', href: '/billing' }, { value: '/team', label: 'Team', href: '/team' },]}/><nav class="wayra-tabs" data-variant="underline" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:0px;--wayra-tabs-w:92px;--wayra-tabs-h:40px"><div class="wayra-tabs-list"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <a class="wayra-tabs-tab" href="#profile" aria-current="page" data-value="profile" style="inline-size:92px">Profile</a> <a class="wayra-tabs-tab" href="#billing" data-value="billing" style="inline-size:92px">Billing</a> <a class="wayra-tabs-tab" href="#team" data-value="team" style="inline-size:92px">Team</a></div></nav>Mixing the two is a mistake, not a mode: half the row would switch panels and
half would leave the page, under one role="tablist" promising the first. The
component throws with a message naming the count.
Variants
Section titled “Variants”Three skins, all drawing the same one-element indicator.
Code
<Tabs variant="segmented" … /><Tabs variant="underline" … /><Tabs variant="pills" … /><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:124px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Segmented"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div><div class="wayra-tabs" data-variant="underline" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:106.8px;--wayra-tabs-y:4px;--wayra-tabs-w:96px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Underline"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="general" class="wayra-tabs-tab" style="inline-size:96px">General</button> <button type="button" role="tab" aria-selected="true" data-value="appearance" class="wayra-tabs-tab" style="inline-size:96px">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab" style="inline-size:96px">Privacy</button> </div></div><div class="wayra-tabs" data-variant="pills" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:208px;--wayra-tabs-y:4px;--wayra-tabs-w:96px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Pills"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="general" class="wayra-tabs-tab" style="inline-size:96px">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab" style="inline-size:96px">Appearance</button> <button type="button" role="tab" aria-selected="true" data-value="privacy" class="wayra-tabs-tab" style="inline-size:96px">Privacy</button> </div></div>segmented(default) is the segmented control: a sunken track, equal-width tabs, and a flat thumb drawn by its border in the accent colour.underlineis the document tab: a hairline under the row and a 2px bar in the accent colour. It is the one variant whose selected label takes the accent too — it sits on the page, where a role is guaranteed to clear 4.5:1.pillsare free-standing rounded tabs over an accent wash with a mixed border. The label stays ink here on purpose: accent text on its own wash measures 4.08:1 for the default primary, below the floor.
color moves the accent through the usual data-color pipeline, so the
underline bar, the pill wash and every focus ring follow it.
All three answer a press the same way. While the pointer is down on a tab that
is not selected, it takes one press step of ink over whatever it sits on, the
page or the segmented track, and its label goes to full ink, as it does on
hover. The selected tab and a disabled one do not react.
Surfaces
Section titled “Surfaces”surface is orthogonal to variant: any skin over any background. It shapes
the panel box (and, for attached, the whole component), and it is inert in
link mode, which has no panels.
Code
<Tabs surface="attached" variant="underline" … /><Tabs surface="card" … /><Tabs surface="inset" variant="pills" … /><div class="wayra-tabs" data-variant="underline" data-color="primary" data-size="md" data-surface="attached" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:6.800000000000001px;--wayra-tabs-y:6.800000000000001px;--wayra-tabs-w:96px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Attached"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab" style="inline-size:96px">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab" style="inline-size:96px">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab" style="inline-size:96px">Privacy</button> </div> <div class="wayra-tabs-panels"> <div class="wayra-tabs-panel" role="tabpanel" tabindex="0">Bar and panel in one frame, with a single shared line.</div> </div></div><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="card" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:124px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Card"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div> <div class="wayra-tabs-panels"> <div class="wayra-tabs-panel" role="tabpanel" tabindex="0">A detached card, set apart by its hairline.</div> </div></div><div class="wayra-tabs" data-variant="pills" data-color="primary" data-size="md" data-surface="inset" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:96px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Inset"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab" style="inline-size:96px">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab" style="inline-size:96px">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab" style="inline-size:96px">Privacy</button> </div> <div class="wayra-tabs-panels"> <div class="wayra-tabs-panel" role="tabpanel" tabindex="0">A sunken well, for dense content.</div> </div></div>surface |
What it draws |
|---|---|
plain |
Nothing. The panel sits on the page. |
card |
A flat hairline box on the page surface. |
inset |
A sunken well with an inset shadow. |
attached |
One frame around bar and panel; in vertical mode the shared line moves to the side. |
Every box here is flat: card is separated from the page by its hairline alone,
with no lit edge and no drop shadow, and nothing is ever a blur. The library
withdrew translucency in August 2026 and the relief that replaced it in
September; a component in the flow does not float.
Five steps, and the bar’s height is the field height of each one, so a tab row lines up with the Button and the Input next to it.
The thumb geometry in these static previews is written by hand, so only the md row is placed exactly.
Code
<Tabs size="xs" … /><Tabs size="sm" … /><Tabs size="md" … /><Tabs size="lg" … /><Tabs size="xl" … /><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="xs" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:102.66666666666667px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Size xs"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="sm" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:102.66666666666667px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Size sm"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:102.66666666666667px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Size md"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="lg" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:102.66666666666667px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Size lg"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="xl" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:102.66666666666667px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Size xl"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="appearance" class="wayra-tabs-tab">Appearance</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="privacy" class="wayra-tabs-tab">Privacy</button> </div></div>Orientation
Section titled “Orientation”orientation="vertical" stacks the bar beside the panel — the settings-pane
sidebar — and moves the arrow keys to Up and Down. The tablist says which axis
it is on, so a screen reader announces the right one.
Right to left, the column moves to the right of its panel, and the hairline and
the underline bar move with it to the side that faces the panel. A dir
attribute on the bar or on any ancestor is what flips them, so it works below
Chrome 120, where :dir() does not exist; there, an LTR island inside an RTL
page is the one case it misreads.
Code
<Tabs orientation="vertical" surface="attached" … /><div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="attached" data-orientation="vertical" data-measured="true" style="inline-size:420px;--wayra-tabs-x:6.8px;--wayra-tabs-y:6.8px;--wayra-tabs-w:164px;--wayra-tabs-h:40px"><div class="wayra-tabs-list" role="tablist" aria-orientation="vertical" aria-label="Vertical settings"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="network" class="wayra-tabs-tab">Network</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="sound" class="wayra-tabs-tab">Sound</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="display" class="wayra-tabs-tab">Display</button></div><div class="wayra-tabs-panels"> <div class="wayra-tabs-panel" role="tabpanel" tabindex="0">The Network panel.</div></div></div>Keyboard
Section titled “Keyboard”The bar is one tab stop: Tab enters at the selected tab and Tab leaves the row. That is the APG tabs pattern, and it is why the arrows have work to do.
| Key | What it does |
|---|---|
| Tab | Enters the bar at the selected tab (the first enabled one, if the selected tab is disabled), then moves on to the panel |
| → / ← | The tab to the right / left, horizontal bar: next / previous, and previous / next right to left |
| ↓ / ↑ | Next / previous tab, vertical bar |
| Home / End | First / last enabled tab |
| Enter / Space | Activates the focused tab under activation="manual" |
The row wraps at both ends and steps over disabled tabs in both directions.
The horizontal arrows follow what the eye sees, as the APG asks: under
dir="rtl" the row starts on the right, so → steps back through
the source order. The direction is the computed one, read at the key press, so
a dir on any ancestor counts and an LTR island keeps its own arrows. Home and
End still mean the first and the last tab.
Link mode has none of this: those are links, and the browser’s own tab order is what the APG navigation pattern asks for.
Automatic or manual activation
Section titled “Automatic or manual activation”activation="automatic" (the default) selects a tab as soon as the arrows
reach it — right when showing a panel costs nothing. activation="manual"
moves focus only and waits for Enter or Space, which is
what you want when a panel fetches something on the way in.
<Tabs activation="manual" label="Reports" items={items} />Panels stay mounted
Section titled “Panels stay mounted”Every panel renders; the ones behind carry the hidden attribute. That keeps a
half-filled form, a scroll position and a running video alive across a switch,
and it is what makes aria-controls point at something real.
Lazy panels are a non-goal. If a panel is expensive, do the laziness in
your own component — content is a ReactNode, so {tab === 'reports' && <Reports />} is yours to write, and onChange tells you when to start.
An item with no content renders a tab that controls nothing: no panel, and no
aria-controls pointing at an id that does not exist.
Where a panel comes from
Section titled “Where a panel comes from”The panel that appears fades in while it slides 0.35rem from the side the
selection moved toward, so the content follows the indicator: from the inline
end when the new tab comes later in the row, from the start when it comes
earlier, and along the block axis in a vertical bar. Right to left the inline
sides swap, from a dir on the bar or on any ancestor, below Chrome 120 too.
The component compares the indexes after each change and writes the result on
the root as data-direction="forward" or "backward". A wrap counts by where
it lands: ← on the first tab selects the last one, the indicator
crosses the row toward the end, and the panel comes from the end too. The
first panel has no direction, so it only fades, and so does hand-written markup
that leaves the attribute off. Under prefers-reduced-motion every panel only
fades, in 160ms.
The indicator
Section titled “The indicator”One element moves; nothing is repainted per tab. The component reads the active
tab’s offsetLeft, offsetTop, offsetWidth and offsetHeight in an effect
and writes them onto the root as four custom properties:
<div class="wayra-tabs" data-measured="true" style="--wayra-tabs-x: 4px; --wayra-tabs-y: 4px; --wayra-tabs-w: 124px; --wayra-tabs-h: 40px">data-measured is what reveals it. Until the first measurement the indicator is
an invisible 0×0 box with no transition, so it lands in place instead of sliding
out of the corner — and server-rendered markup ships the right aria-selected
and hidden state with no indicator at all.
A single ResizeObserver watches the list and every tab, because a count
inside one label changing width moves every tab after it. Where there is no
ResizeObserver the component still renders and still places the indicator on
every switch; it just stops following a tab that resizes on its own.
Composing a label
Section titled “Composing a label”There is no icon prop and no badge prop. label takes a ReactNode and the
tab is already a flex row, so a count is a Badge written inside it:
Code
items={[{ value: 'all', label: <><InboxIcon aria-hidden />All</> },{ value: 'unread', label: <>Unread<Badge size="xs" color="neutral">12</Badge></> },{ value: 'flagged', label: <>Flagged<Badge size="xs" color="warning">3</Badge></> },]}<div class="wayra-tabs" data-variant="underline" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:384px;--wayra-tabs-x:4px;--wayra-tabs-y:0px;--wayra-tabs-w:60px;--wayra-tabs-h:40px"><div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Inbox"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="all" class="wayra-tabs-tab" style="inline-size:60px">All</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="unread" class="wayra-tabs-tab">Unread<span class="wayra-badge" data-variant="soft" data-color="neutral" data-size="xs" data-shape="rounded">12</span></button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="flagged" class="wayra-tabs-tab">Flagged<span class="wayra-badge" data-variant="soft" data-color="warning" data-size="xs" data-shape="rounded">3</span></button></div></div>The count becomes part of the tab’s accessible name — “Unread 12” — which is
usually what you want. An icon needs aria-hidden so it adds nothing to it.
Overflow
Section titled “Overflow”Too many tabs slide instead of squeezing. The list is its own scroll container with the scrollbar hidden on both engines and inline scroll snapping, so the row overflows inside itself and the page never gains a horizontal scrollbar. Vertical bars turn it off.
An arrow, Home or End that lands on a tab the row cuts scrolls the list, and only the list, until that tab sits in the middle — where the snapping would have taken it anyway. The browser alone would not: Chrome scrolls a focused element into view only when none of it shows, and leaves a half-visible tab cut.
A scroll container clips everything past its padding edge, the focus ring
included, so the list keeps the ring’s reach as padding on every side — at
least 4px, and more where segmented and attached already pad further. A
negative block margin hands that room back to the layout, so the row is no
taller; along the row, the first and the last tab sit that far in from the
edges, and the list’s scroll-padding keeps the same room when a focused tab
is scrolled into view.
Seven pills in a 20rem box. Drag or shift-scroll to see the rest.
Code
<div class="wayra-tabs" data-variant="pills" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal" data-measured="true" style="inline-size:320px;--wayra-tabs-x:4px;--wayra-tabs-y:4px;--wayra-tabs-w:88px;--wayra-tabs-h:40px;"> <div class="wayra-tabs-list" role="tablist" aria-orientation="horizontal" aria-label="Cities"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="quito" class="wayra-tabs-tab" style="inline-size:88px">Quito</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="guayaquil" class="wayra-tabs-tab" style="inline-size:88px">Guayaquil</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="cuenca" class="wayra-tabs-tab" style="inline-size:88px">Cuenca</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="ambato" class="wayra-tabs-tab" style="inline-size:88px">Ambato</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="loja" class="wayra-tabs-tab" style="inline-size:88px">Loja</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="manta" class="wayra-tabs-tab" style="inline-size:88px">Manta</button> <button type="button" role="tab" aria-selected="false" tabindex="-1" data-value="ibarra" class="wayra-tabs-tab" style="inline-size:88px">Ibarra</button> </div></div>Styling contract
Section titled “Styling contract”The class plus the data-* attributes are public API, and the stylesheet is
written against them — every preview on this page is hand-authored HTML:
<div class="wayra-tabs" data-variant="segmented" data-color="primary" data-size="md" data-surface="plain" data-orientation="horizontal"> <div class="wayra-tabs-list" role="tablist" aria-label="Settings"> <span class="wayra-tabs-indicator" aria-hidden="true"></span> <button type="button" role="tab" aria-selected="true" data-value="general" class="wayra-tabs-tab">General</button> </div></div>data-value on each tab is part of the contract: it is how the component finds
the row it has to measure, and it survives unstyled. The knobs:
| Property | Default | What it does |
|---|---|---|
--wayra-tabs-height |
field height of the size step | The bar’s height, and the base for its padding and radius. |
--wayra-tabs-radius |
field radius of the step, capped at half the height | The corner of a tab and of the thumb. |
--wayra-tabs-gap |
calc(var(--wayra-size-field) * 0.5) |
Space between tabs. |
--wayra-tabs-panel-gap |
calc(var(--wayra-size-field) * 4) |
Bar to panel, and column to panel when vertical. |
--wayra-tabs-panel-padding |
calc(var(--wayra-tabs-height) * 0.55) |
Inside card, inset and attached. |
--wayra-tabs-x / -y / -w / -h |
0px |
The active tab’s box. Written by the component. |
API reference
Section titled “API reference”TabsProps extends ComponentProps<"nav"> minus onChange, so id, style,
ref, className and every data-* land on the root — a <div> in widget
mode, a <nav> in link mode.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
readonly TabsItem[] |
— | The tabs, in order. Required. |
variant |
"segmented" | "underline" | "pills" |
"segmented" |
The skin. |
color |
WayraColor |
"primary" |
Semantic role, as data-color. |
size |
WayraSize |
"md" |
Size step; the bar takes that field height. |
surface |
"plain" | "card" | "inset" | "attached" |
"plain" |
The panel box. Inert in link mode. |
orientation |
"horizontal" | "vertical" |
"horizontal" |
Axis of the bar and of the arrow keys. |
activation |
"automatic" | "manual" |
"automatic" |
Whether the arrows select or only move focus. |
value |
string |
— | Selected tab, controlled. A value that names no item selects nothing. |
defaultValue |
string |
first enabled item | Selected tab on the first render. Falls back to the first enabled item once it names none. |
label |
string |
— | Names the tablist, or the <nav>. Always set it. |
onChange |
(value: string) => void |
— | Fires with the chosen value, pointer or keyboard; not for a link opened elsewhere. |
unstyled |
boolean |
false |
Drops the wayra-tabs* classes, keeps every attribute. |
TabsItem
Section titled “TabsItem”| Field | Type | Description |
|---|---|---|
value |
string |
Identity. What value, defaultValue and onChange speak. |
label |
ReactNode |
What the tab shows. Compose an icon or a Badge here. |
content |
ReactNode |
The panel. Widget mode only; without it the tab controls nothing. |
href |
string |
Turns the whole bar into navigation. All items or none. |
disabled |
boolean |
Native disabled on a tab; aria-disabled on a link. |
Accessibility
Section titled “Accessibility”- The group carries a name.
labelbecomesaria-labelon the tablist, or on the<nav>in link mode. An unnamed tablist is announced as nothing, and two unnamed landmarks on a page is an axelandmark-uniqueviolation. - One tab stop, then the panel. The selected tab is the only stop in the
row; the panel takes
tabindex="0"so the next Tab press reaches it, which is also what makes a scrollable panel reachable. A disabled button takes no focus, so when the selected tab is disabled the stop moves to the first enabled tab, and the selection stays where it is. - Every tab states its state.
aria-selectedon all of them,aria-controlson the ones that have a panel,aria-labelledbyback from the panel to its tab. - Disabled is the native attribute. A disabled tab is
<button disabled>: the browser takes it out of the tab order and refuses the click, and the arrows step over it. Nopointer-eventstrick, so it is not a control that looks alive and does nothing. In link mode an anchor takes nodisabled, so it isaria-disabledinstead — it keeps its place in the tab order, says it is unavailable, and goes nowhere. - The indicator is
aria-hidden. It is one decorative element; the state it draws is already inaria-selected. - The focus ring is not cut by the row. A tab wears the library’s shared ring, and the scrolling list keeps its reach as padding (see Overflow), so the ring shows whole at either end of the row.
- Contrast. Resting labels are
--_wayra-color-mutedonbase-100: 5.64:1 light, 6.15:1 dark. A selected label isbase-content— 17.9:1 — except underunderline, where it is the role colour on the page, which the colour contract holds at 4.5:1 or better. A pressed label isbase-contenton the press step, 9.81:1 at the lowest (the default theme in dark, on thesegmentedtrack). It turns ink on a touch press too, where nothing hovers, because the muted gray measures 4.03:1 there (terralight). Disabled tabs fade to 0.4, which WCAG 1.4.3 exempts, and to 0.65 underprefers-contrast: more. - Forced colors. The indicator is a background, so it is dropped and the
selected tab draws a
Highlightoutline instead; disabled tabs takeGrayText. The press wash is forced to Canvas, so a press draws nothing. - Reduced motion. The panel fades in place over 160ms, with no slide. The
indicator’s travel and the label’s colour change go to
0s. Nothing moves, everything still lands.
Do and don’t
Section titled “Do and don’t”Do
A label on every bar. It is the tablist's accessible name, and the only thing that tells two bars apart.
Don't
No name. A screen reader announces “tab list” and nothing else, and a second bar on the page is indistinguishable from this one.
Do
Panels of one view, in widget mode: switching keeps the scroll position and the half-filled form behind it.
Don't
The same bar for three URLs. Give those items an href instead and they become real links — Cmd-click, the back button and “open in new tab” come back with them.