Skip to content

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
The General panel. Every panel stays mounted; the ones behind carry the 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.

The General panel.
Code
<Tabs
label="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.

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
<Tabs
label="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.

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.
  • underline is 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.
  • pills are 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.

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.

Bar and panel in one frame, with a single shared line.
A detached card, set apart by its hairline.
A sunken well, for dense content.
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="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.

The Network panel.
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>

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.

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} />

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.

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.

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.

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.

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>

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.

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.
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.
  • The group carries a name. label becomes aria-label on 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 axe landmark-unique violation.
  • 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-selected on all of them, aria-controls on the ones that have a panel, aria-labelledby back 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. No pointer-events trick, so it is not a control that looks alive and does nothing. In link mode an anchor takes no disabled, so it is aria-disabled instead — 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 in aria-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-muted on base-100: 5.64:1 light, 6.15:1 dark. A selected label is base-content — 17.9:1 — except under underline, where it is the role colour on the page, which the colour contract holds at 4.5:1 or better. A pressed label is base-content on the press step, 9.81:1 at the lowest (the default theme in dark, on the segmented track). It turns ink on a touch press too, where nothing hovers, because the muted gray measures 4.03:1 there (terra light). Disabled tabs fade to 0.4, which WCAG 1.4.3 exempts, and to 0.65 under prefers-contrast: more.
  • Forced colors. The indicator is a background, so it is dropped and the selected tab draws a Highlight outline instead; disabled tabs take GrayText. 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

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.