Tabs
ARIA-correct tabbed content switcher.
Live demo
01Underline (default)
Account settings
Manage your username, email and avatar.
Pill variant
- #421 β Login form rejects valid emails
- #418 β Sidebar collapses on iOS Safari
- #412 β Slow query on /admin/users
Vertical orientation
Getting started
Install via bun add @tfe/svelte, import what you need, ship.
Icons + disabled state
π Welcome back. You have 3 unread messages.
Vertical pill Β· sidebar nav
Combine orientation="vertical" with variant="pill" for a settings-style rail. Arrow keys move β/β; the disabled item is skipped automatically.
Profile
Avatar, display name, public bio. Public to everyone in your workspace.
Disabled tab item
Active tab: free. Try arrow-keying onto the Enterprise tab β the keyboard handler will jump over it because disabled: true removes it from the rotation.
Free
Up to 3 projects, 5 GB storage. Community support.
Implementation
02<script lang="ts">
import Tabs from '$lib/components/Tabs.svelte';
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'specs', label: 'Specs' },
{ id: 'reviews', label: 'Reviews' }
];
let active = $state('overview');
</script>
β
<Tabs {tabs} bind:active>
{#snippet panel(id)}
{#if id === 'overview'}<p>Overview content</p>
{:else if id === 'specs'}<p>Specs content</p>
{:else}<p>Reviews content</p>{/if}
{/snippet}
</Tabs>Tabs implements the WAI-ARIA tablist pattern: each trigger is a button with role="tab", the panels carry role="tabpanel", and a roving tabindex keeps only the active trigger reachable by Tab. Arrow keys (β/β or β/β depending on orientation) move between tabs, Home/End jump to the ends, and Enter activates. Disabled tabs are skipped automatically by the keyboard handler.
Logic explainer
03What Does It Do? (Plain English)
A horizontal or vertical tabbed interface for switching between mutually exclusive content panels. Click a tab β or arrow-key to it β and the corresponding panel renders below or beside the tablist. Implements the full WAI-ARIA tablist pattern: role="tablist", role="tab", role="tabpanel", aria-controls, aria-labelledby, plus roving tabindex so the Tab key visits the active tab as a single stop instead of paging through every option.
Think of it like a folder of paper-clipped sections: each tab on the cover sticks out the side, and tapping a tab brings that section to the top. Only one section is visible at a time, and the cover stays the same shape regardless of which one is active.
How It Works (Pseudo-Code)
state:
tabs[] = [{ id, label, icon?, disabled? }]
active = bindable id of the active tab (defaults to tabs[0].id)
buttons[] = bound DOM refs, one per tab
events:
on tab click(id):
if tabs[id].disabled: return
active = id
on tablist keydown:
next = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
prev = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
if key === next: focusTab(currentIdx + 1) // wraps + skips disabled
if key === prev: focusTab(currentIdx - 1)
if key === 'Home': focusTab(0)
if key === 'End': focusTab(tabs.length - 1)
if key === 'Enter' or ' ':
activate the focused tab
focusTab(idx):
i = idx mod tabs.length (positive)
while tabs[i].disabled and tries < length:
i = (i + 1) mod length // walk past disabled
buttons[i].focus()
render:
<div role="tablist" aria-orientation={orientation} onkeydown={...}>
for each tab:
<button role="tab"
aria-selected={tab.id === active}
aria-controls="panel-{id}"
tabindex={tab.id === active ? 0 : -1}
disabled={tab.disabled}>
icon? + label
</button>
</div>
<div role="tabpanel" id="panel-{active}" aria-labelledby="tab-{active}">
{@render panel(active)}
</div>The tablist + tabpanel are wired with two id pairs: id="tab-{id}" on the button is referenced by aria-labelledby on the panel, and id="panel-{id}" on the panel is referenced by aria-controls on the button. Screen readers use these to announce the relationship β "tab Overview, selected, controls panel Overview content".
The Core Concept: Roving Tabindex
A naΓ―ve tablist gives every tab tabindex="0", putting all of them in the document tab order. The user has to Tab through every tab to escape the tablist β a known accessibility footgun.
The WAI-ARIA pattern is roving tabindex: only the active tab is tabindex="0"; inactive tabs are tabindex="-1". The user's first Tab into the tablist lands on the active tab; the next Tab leaves the tablist entirely. Within the tablist, arrow keys move focus.
<button role="tab" tabindex={tab.id === active ? 0 : -1}>Combined with the keyboard handler:
ArrowRight / ArrowDown β focusTab(currentIdx + 1)
ArrowLeft / ArrowUp β focusTab(currentIdx - 1)
Home β focusTab(0)
End β focusTab(tabs.length - 1)
Enter / Space β activate focused tabThe focusTab helper does two clever things: wrapping (((idx % total) + total) % total handles negative indices for ArrowLeft on the first tab) and skipping disabled tabs (a while-loop walks past disabled entries up to total times to avoid infinite loops if every tab is disabled).
Note that arrow keys move focus, not selection. The user can arrow-key through tabs to read their labels without committing β Enter or Space activates. This matches the WAI-ARIA tabs pattern's "manual activation" mode. (Some tab implementations use "automatic activation" where arrow keys also activate; we chose manual because it's friendlier when tab activation is expensive β fetching the panel's content, for example.)
Two Visual Variants, One Layout
The component supports two visual styles via the variant prop:
underline(default): tabs sit on a thin horizontal border; the active tab gets a 2px coloured under-bar that visually replaces the border at its position.pill: the tablist sits in a rounded grey tray; the active tab gets a white pill background with a subtle drop-shadow.
Both work with both orientations (horizontal and vertical). For vertical underline, the under-bar becomes a side-bar on the right (border-right-color); for vertical pill, the tray flexes column-wise instead of row-wise.
The two variants share the same DOM structure β only CSS differs. Switching variant at runtime is free, no re-mount.
State Flow Diagram
ββββββββββββββββββββββββββββ
β active = tabs[0].id β
β first tab tabindex=0 β
β others tabindex=-1 β
ββββββββββββ¬ββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββββ¬ββββββββββββββββββ
β β β β
click tab ArrowRight / Down Home / End Enter / Space
(mouse / tap) (focus moves only) (focus jumps) (activate focused)
β β β β
β βΌ βΌ β
β focus advances to focus to first/last β
β next enabled tab enabled tab β
β (no activation) (no activation) β
β β
βΌ βΌ
ββββββββββββββββββββββββββββ
β active = newId β
β roving tabindex shifts β
β panel re-renders via β
β {@render panel(active)}β
β bind:active fires β
ββββββββββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
tabs |
TabItem[] |
required | Array of tab items: { id, label, icon?, disabled? }. |
active |
string |
tabs[0].id |
Active tab id. Bindable via bind:active. |
orientation |
'horizontal' | 'vertical' |
'horizontal' |
Layout direction; sets aria-orientation and remaps the arrow-key axis. |
variant |
'underline' | 'pill' |
'underline' |
Visual style: under-bar vs pill on a tray. |
ariaLabel |
string |
'Tabs' |
Accessible name for the tablist. |
class |
string |
'' |
Extra classes on the wrapper. |
panel |
Snippet<[string]> |
β | Snippet that receives the active id and renders the panel. |
Edge Cases
| Situation | Behaviour |
|---|---|
tabs is empty |
Tablist renders empty; no panel content. active defaults to '' because tabs[0]?.id is undefined. |
active doesn't match any tab id |
The panel renders with aria-labelledby="tab-" and no tab is visually selected. Keyboard handler walks from index -1. |
| All tabs disabled | focusTab walks the whole array hitting only disabled tabs, exhausts its tries budget, and silently returns. No focus moves. |
Disabled tab is the current active |
The disabled tab still renders as selected; the panel still renders. Arrow keys skip it on subsequent navigation. Avoid setting active to a disabled id. |
User has prefers-reduced-motion: reduce |
The colour and background transitions on tabs are removed; the panel swap is instant. |
| Panel content is heavy (e.g. data fetch) | The panel snippet runs on every active change; render-side caching is the consumer's responsibility. Common pattern: render a wrapper component per tab id and let it manage its own load. |
| Vertical orientation in a constrained-height container | The tablist becomes a vertical flex column, panel shares the row. Set min-width on the tablist if labels are long; otherwise it'll squeeze. |
| Two Tabs on the same page sharing tab ids | DOM uses id="tab-{id}" and id="panel-{id}" β duplicate ids break aria-labelledby. Use unique ids across the page. |
Dependencies
- Svelte 5.x β
$bindable,$state,$props, snippets (panel). The roving tabindex flips with one ternary on render. - Zero external dependencies. Native
<button>, scoped CSS.
File Structure
src/lib/components/Tabs.svelte # implementation
src/lib/components/Tabs.md # this file (rendered inside ComponentPageShell)
src/lib/components/Tabs.test.ts # vitest unit tests
src/routes/tabs/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
tabs | TabItem[] | β | Required. Each item: id, label, optional icon + disabled. |
active | string | First tab's id | Bindable id of the active tab. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Layout direction; arrow-key axis follows. |
variant | 'underline' | 'pill' | 'underline' | Visual treatment for the active indicator. |
ariaLabel | string | 'Tabs' | Tablist label for assistive tech. |
panel | Snippet<[string]> | β | Snippet receiving the active tab id; render the matching panel content. |