Content tabs
Tabs that switch between related panels: an underline row or a segmented pill track, horizontal or as a vertical rail, with optional icons and count badges, overflow scrolling and manual activation.
cmp_content_tabs_01 Before you use this component
- Install
bits-ui@^2.0.0. - Requires client-side JavaScript to work.
Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_content_tabs_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue
- Version
- 1.0.0
- Digest
Full digest
sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b
This component needs all 2 files. Download the ZIP
<script lang="ts">
import { untrack } from 'svelte';
import { Tabs } from 'bits-ui';
import type {
ContentTab,
ContentTabsActivationMode,
ContentTabsOrientation,
ContentTabsVariant
} from './types';
interface Props {
/** Tabs in display order. Each id must be unique. */
tabs: ContentTab[];
/** Accessible name for the tab list, such as "Card details". */
ariaLabel: string;
/** Selected tab id. Bindable. */
value?: string;
/** Called with the new id when the visitor selects a different tab. */
onValueChange?: (id: string) => void;
/** Row of tabs above the panel, or a rail beside it. A rail becomes a row in narrow containers. */
orientation?: ContentTabsOrientation;
/** Select on focus (automatic), or only on click, Enter or Space (manual). */
activationMode?: ContentTabsActivationMode;
/** An accent bar on a hairline, or a recessed track with a raised selected segment. */
variant?: ContentTabsVariant;
}
let {
tabs,
ariaLabel,
value = $bindable(),
onValueChange,
orientation = 'horizontal',
activationMode = 'automatic',
variant = 'underline'
}: Props = $props();
const uid = $props.id();
const vertical = $derived(orientation === 'vertical');
const pills = $derived(variant === 'pills');
/*
* The tab actually shown. A value that names no enabled tab (missing, mistyped, disabled,
* or removed) falls back to the first enabled tab. When every tab is disabled, none is.
*/
const selected = $derived.by(() => {
const enabled = tabs.filter((tab) => !tab.disabled);
if (enabled.some((tab) => tab.id === value)) return value!;
return enabled[0]?.id ?? '';
});
/* Keep a bound value in step with what is shown. A correction, not a choice: no onValueChange. */
$effect(() => {
if (value !== selected) value = selected;
});
/* Panels settle in only after a visitor switches, never on first paint. */
let switched = $state(false);
function select(next: string) {
if (next === selected) return;
switched = true;
value = next;
onValueChange?.(next);
}
let root = $state<HTMLElement | null>(null);
let list = $state<HTMLElement | null>(null);
let panels = $state<HTMLElement | null>(null);
/*
* A vertical rail turns into a scrolling row when its container is narrower than 42rem. The
* arrow keys follow what is on screen, so the list reads its own layout rather than a
* breakpoint the component would have to repeat.
*/
let stacked = $state(false);
const keyOrientation = $derived(vertical && !stacked ? 'vertical' : 'horizontal');
/* The row scrolls when its tabs are wider than the container; mark the edges with more behind them. */
let overflowStart = $state(false);
let overflowEnd = $state(false);
function measure() {
if (!list) return;
stacked = vertical && getComputedStyle(list).flexDirection !== 'column';
const hidden = list.scrollWidth - list.clientWidth;
// scrollLeft runs from 0 towards negative values in right-to-left layouts.
const travelled = Math.abs(list.scrollLeft);
overflowStart = hidden > 1 && travelled > 1;
overflowEnd = hidden > 1 && travelled < hidden - 1;
}
/* Bring the selected tab into view, scrolling the row only (never the page) by the least distance. */
function reveal() {
const index = tabs.findIndex((tab) => tab.id === selected);
const tab = list?.querySelector<HTMLElement>(`[data-tab-index="${index}"]`);
if (!list || !tab || list.scrollWidth <= list.clientWidth) return;
const row = list.getBoundingClientRect();
const box = tab.getBoundingClientRect();
if (box.left < row.left) list.scrollBy({ left: box.left - row.left - 32 });
else if (box.right > row.right) list.scrollBy({ left: box.right - row.right + 32 });
}
/*
* Re-measure when the container, the row or any tab changes size (a rail folding into a row,
* a label or badge changing), and keep the selected tab in view through it.
*/
$effect(() => {
void tabs.length;
if (!list || !root) return;
const row = list;
const settle = () => {
measure();
reveal();
measure();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(settle);
observer?.observe(root);
observer?.observe(row);
row.querySelectorAll('[role="tab"]').forEach((tab) => observer?.observe(tab));
row.addEventListener('scroll', measure, { passive: true });
settle();
return () => {
observer?.disconnect();
row.removeEventListener('scroll', measure);
};
});
$effect(() => {
void selected;
reveal();
measure();
});
/*
* A panel keeps its own Tab stop unless its first meaningful content is a control (the APG
* rule), so keyboard users can reach and scroll a panel that opens with text. Server markup
* makes every panel focusable. The check reruns when a panel's content changes.
*/
const candidates =
'a[href],button,input,select,textarea,summary,iframe,[contenteditable="true"],[tabindex]';
function tabbable(el: HTMLElement) {
return (
el.matches(candidates) &&
el.tabIndex >= 0 &&
!(el as HTMLButtonElement).disabled &&
!(el instanceof HTMLInputElement && el.type === 'hidden') &&
!el.closest('[hidden]') &&
!el.parentElement?.closest('[inert]:not([role="tabpanel"])')
);
}
function opensWithControl(panel: Element) {
const walker = document.createTreeWalker(panel, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node instanceof HTMLElement && tabbable(node)) return true;
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) return false;
}
return false;
}
let interactive = $state<boolean[]>([]);
$effect(() => {
void tabs.length;
if (!panels) return;
const container = panels;
const check = () => {
const next = Array.from(container.children, opensWithControl);
const current = untrack(() => interactive);
if (next.length !== current.length || next.some((item, index) => item !== current[index]))
interactive = next;
};
check();
if (typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(check);
observer.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: [
'disabled',
'href',
'hidden',
'type',
'contenteditable',
'inert',
'tabindex'
]
});
return () => observer.disconnect();
});
const tabClass = $derived([
'content-tabs__tab group relative inline-flex shrink-0 cursor-pointer items-center gap-2 text-sm leading-5 font-medium whitespace-nowrap text-[var(--_muted)] transition-[color,background-color,box-shadow,transform] duration-150 ease-[cubic-bezier(.2,0,0,1)] active:duration-[80ms] disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-[var(--_ink)] enabled:hover:text-[var(--_ink)]',
pills
? 'h-9 rounded-lg px-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--_accent)] enabled:hover:bg-[var(--_hover)] enabled:active:scale-[.98] data-[state=active]:bg-[var(--_raised)] data-[state=active]:shadow-[var(--_shadow-raised)] motion-reduce:enabled:active:scale-100 pointer-coarse:h-11'
: // The ring is drawn on the label (below), so it never covers the bar on the hairline.
'h-11 rounded-lg px-2 focus-visible:outline-none',
vertical &&
'@2xl:h-auto @2xl:min-h-10 @2xl:w-full @2xl:justify-start @2xl:py-2 @2xl:text-start @2xl:whitespace-normal pointer-coarse:@2xl:min-h-11',
vertical && !pills && '@2xl:ps-3'
]);
const labelClass = $derived([
'inline-flex min-w-0 items-center gap-2 rounded-lg',
!pills &&
'outline-offset-4 group-focus-visible:outline-2 group-focus-visible:outline-[var(--_accent)]',
vertical && '@2xl:flex-1'
]);
</script>
{#if tabs.length > 0}
<div
bind:this={root}
class="content-tabs @container w-full min-w-0"
data-variant={variant}
data-switched={switched ? '' : undefined}
data-layout={orientation}
>
<Tabs.Root
value={selected}
onValueChange={select}
orientation={keyOrientation}
{activationMode}
loop
class={[
'flex flex-col gap-6',
vertical && '@2xl:grid @2xl:grid-cols-[13rem_minmax(0,1fr)] @2xl:items-start @2xl:gap-10'
]}
>
<!-- In a wide rail the first label lifts (8px, or 12px past the pill track's inset), so it sits level with the panel's first line. -->
<div
class={[
'content-tabs__rail relative min-w-0',
vertical && (pills ? '@2xl:-mt-3' : '@2xl:-mt-2')
]}
>
<Tabs.List
bind:ref={list}
aria-label={ariaLabel}
data-overflow-start={overflowStart ? '' : undefined}
data-overflow-end={overflowEnd ? '' : undefined}
class={[
'content-tabs__list flex overflow-x-auto',
pills ? 'w-fit max-w-full gap-1 rounded-xl bg-[var(--_track)] p-1' : '-mx-2 gap-2',
vertical && '@2xl:mx-0 @2xl:w-auto @2xl:flex-col @2xl:gap-1 @2xl:overflow-visible'
]}
>
{#each tabs as tab, index (tab.id)}
<Tabs.Trigger value={tab.id} disabled={tab.disabled} id="{uid}-tab-{index}">
{#snippet child({ props })}
<!-- Explicit ids, relationships and roving tabindex, so server markup is complete before hydration. -->
<button
{...props}
class={tabClass}
data-tab-index={index}
aria-controls="{uid}-panel-{index}"
tabindex={tab.id === selected ? 0 : -1}
aria-label={tab.badge && tab.badgeLabel
? `${tab.label}, ${tab.badgeLabel}`
: undefined}
>
<span class={labelClass}>
{#if tab.icon}
<span
class="flex size-4 shrink-0 items-center justify-center"
aria-hidden="true"
>
{@render tab.icon()}
</span>
{/if}
<span class="min-w-0 break-words">{tab.label}</span>
{#if tab.badge}
<span
class={[
'inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[var(--_badge)] px-1 text-xs leading-none font-medium text-[var(--_muted)] tabular-nums transition-[color,background-color] duration-150 ease-[cubic-bezier(.2,0,0,1)] group-data-[state=active]:bg-[var(--_accent)] group-data-[state=active]:text-[var(--_on-accent)]',
vertical && '@2xl:ms-auto'
]}>{tab.badge}</span
>
{/if}
</span>
</button>
{/snippet}
</Tabs.Trigger>
{/each}
</Tabs.List>
</div>
<!-- Every panel shares one grid cell, so switching tabs never changes the height or moves the page below. -->
<div bind:this={panels} class="content-tabs__panels grid min-w-0">
{#each tabs as tab, index (tab.id)}
<Tabs.Content value={tab.id} id="{uid}-panel-{index}">
{#snippet child({ props })}
<div
{...props}
role="tabpanel"
hidden={undefined}
inert={tab.id === selected ? undefined : true}
aria-labelledby="{uid}-tab-{index}"
tabindex={interactive[index] ? undefined : 0}
class="content-tabs__panel min-w-0 rounded-lg outline-offset-4 focus-visible:outline-2 focus-visible:outline-[var(--_accent)]"
>
{@render tab.content?.()}
</div>
{/snippet}
</Tabs.Content>
{/each}
</div>
</Tabs.Root>
</div>
{/if}
<style>
/* Public tokens: set --content-tabs-* on the component or any ancestor to retone it. */
.content-tabs {
--_accent: var(--content-tabs-accent, #2563eb);
--_on-accent: var(--content-tabs-on-accent, #ffffff);
--_ink: var(--content-tabs-ink, #18181b);
--_muted: var(--content-tabs-muted, #52525b);
--_hairline: var(--content-tabs-hairline, rgb(0 0 0 / 0.1));
--_track: var(--content-tabs-track, #f4f4f5);
--_raised: var(--content-tabs-raised, #ffffff);
/* Hover, badge and bar tints are mixed from the ink, so every retone gets them. */
--_hover: color-mix(in oklab, var(--_ink) 6%, transparent);
--_badge: color-mix(in oklab, var(--_ink) 7%, transparent);
--_bar-hover: color-mix(in oklab, var(--_ink) 20%, transparent);
--_bar-pressed: color-mix(in oklab, var(--_ink) 40%, transparent);
/* The selected segment is the one raised surface, lit from above. */
--_shadow-raised:
0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 2px rgb(0 0 0 / 0.06), 0 2px 6px -1px rgb(0 0 0 / 0.06);
}
:global(.dark) .content-tabs {
--_accent: var(--content-tabs-accent, #60a5fa);
--_on-accent: var(--content-tabs-on-accent, #09090b);
--_ink: var(--content-tabs-ink, #fafafa);
--_muted: var(--content-tabs-muted, #a1a1aa);
--_hairline: var(--content-tabs-hairline, rgb(255 255 255 / 0.1));
--_track: var(--content-tabs-track, #18181b);
--_raised: var(--content-tabs-raised, #27272a);
/* Black shadows vanish on dark; the segment is lifted by a light ring and an inset top edge. */
--_shadow-raised: 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.08);
}
/* The row scrolls without a scrollbar; an edge with more tabs behind it fades. */
.content-tabs :global(.content-tabs__list) {
--_fade-start: #000;
--_fade-end: #000;
scrollbar-width: none;
mask-image: linear-gradient(
to right,
var(--_fade-start),
#000 1.5rem,
#000 calc(100% - 1.5rem),
var(--_fade-end)
);
}
.content-tabs :global(.content-tabs__list::-webkit-scrollbar) {
display: none;
}
.content-tabs :global(.content-tabs__list:dir(rtl)) {
mask-image: linear-gradient(
to left,
var(--_fade-start),
#000 1.5rem,
#000 calc(100% - 1.5rem),
var(--_fade-end)
);
}
.content-tabs :global(.content-tabs__list[data-overflow-start]) {
--_fade-start: transparent;
}
.content-tabs :global(.content-tabs__list[data-overflow-end]) {
--_fade-end: transparent;
}
/*
* Underline: one hairline runs under the row, and the selected tab's 2 px accent bar rests
* on it. Bars are borders, so forced-colours mode keeps them.
*/
.content-tabs[data-variant='underline'] .content-tabs__rail::before {
content: '';
position: absolute;
inset-inline: 0;
bottom: 0;
height: 1px;
background: var(--_hairline);
pointer-events: none;
}
.content-tabs[data-variant='underline'] :global(.content-tabs__tab)::after {
content: '';
position: absolute;
inset-inline: 0.5rem;
bottom: 0;
border: 0 solid transparent;
border-bottom-width: 2px;
transition: border-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.content-tabs[data-variant='underline']
:global(.content-tabs__tab:enabled:hover:not([data-state='active']))::after {
border-color: var(--_bar-hover);
}
.content-tabs[data-variant='underline']
:global(.content-tabs__tab:enabled:active:not([data-state='active']))::after {
border-color: var(--_bar-pressed);
transition-duration: 80ms;
}
.content-tabs[data-variant='underline'] :global(.content-tabs__tab[data-state='active'])::after {
border-color: var(--_accent);
}
/*
* Pills: the raised segment carries a short accent mark as well, so the selected tab clears
* 3:1 against the track and does not rely on a faint surface change alone.
*/
.content-tabs[data-variant='pills'] :global(.content-tabs__tab)::after {
content: '';
position: absolute;
inset-inline: 0.75rem;
bottom: 0.25rem;
border: 0 solid transparent;
border-bottom-width: 2px;
border-radius: 9999px;
transition: border-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.content-tabs[data-variant='pills'] :global(.content-tabs__tab[data-state='active'])::after {
border-color: var(--_accent);
}
/* In a wide container the vertical rail stands beside the panel, its hairline and bar on the start edge. */
@container (min-width: 42rem) {
.content-tabs[data-layout='vertical'][data-variant='underline'] .content-tabs__rail::before {
inset-block: 0;
inset-inline: 0 auto;
width: 1px;
height: auto;
}
.content-tabs[data-layout='vertical'][data-variant='underline']
:global(.content-tabs__tab)::after {
inset-block: 0.5rem;
inset-inline: 0 auto;
border-bottom-width: 0;
border-inline-start-width: 2px;
}
.content-tabs[data-layout='vertical'][data-variant='pills'] :global(.content-tabs__tab)::after {
inset-block: 0.5rem;
inset-inline: 0.25rem auto;
border-bottom-width: 0;
border-inline-start-width: 2px;
}
.content-tabs[data-layout='vertical'] :global(.content-tabs__list) {
mask-image: none;
}
}
/*
* Inactive panels keep their place in the shared cell but are invisible, unfocusable and hidden
* from assistive technology. visibility, not the hidden attribute, which would collapse them.
*/
.content-tabs :global(.content-tabs__panels > .content-tabs__panel) {
grid-area: 1 / 1;
}
.content-tabs :global(.content-tabs__panels > .content-tabs__panel[data-state='inactive']),
.content-tabs :global(.content-tabs__panels > .content-tabs__panel[data-state='inactive'] *) {
visibility: hidden !important;
}
/* A newly selected panel settles in; with reduced motion it only fades. */
.content-tabs[data-switched] :global(.content-tabs__panel[data-state='active']) {
animation: content-tabs-enter 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes content-tabs-enter {
from {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.content-tabs[data-switched] :global(.content-tabs__panel[data-state='active']) {
animation-name: content-tabs-fade;
}
}
@keyframes content-tabs-fade {
from {
opacity: 0;
}
}
</style>
Usage#
On this pagePass tabs as data, each with a content snippet for its panel. The selected tab is local state; bind value or pass onValueChange to follow it. It is not page navigation (use links for route changes), and it does not fetch or lazy-load panel content: every panel is rendered, and only the selected one is visible.
- Suggested location
src/lib/components/content-tabs-01
Limitations
- Install bits-ui (npm install bits-ui@^2) before using the component.
- Every tab id must be unique; it is the key and the selected value.
- Switching tabs needs JavaScript; before hydration the selected panel shows and the others are hidden.
- All panels are rendered and share one grid cell, so the component is as tall as its tallest panel. Keep panels of similar length, or expect space under the short ones.
- A disabled tab cannot be focused, so say why it is disabled in its badge or nearby copy.
- There are no scroll buttons on an overflowing row; it scrolls by touch, trackpad or the arrow keys.
Example
<script lang="ts">
import ContentTabs from '$lib/components/content-tabs-01/ContentTabs.svelte';
let selected = $state('overview');
</script>
{#snippet overview()}
<p>Filter the timeline by team, owner or label.</p>
{/snippet}
{#snippet activity()}
<p>Moved from Backlog to Doing on 7 Oct.</p>
{/snippet}
{#snippet files()}
<p>filter-spec.md, 12 KB</p>
{/snippet}
<ContentTabs
ariaLabel="Card details"
bind:value={selected}
tabs={[
{ id: 'overview', label: 'Overview', content: overview },
{ id: 'activity', label: 'Activity', badge: '12', badgeLabel: '12 updates', content: activity },
{ id: 'files', label: 'Files', badge: '4', badgeLabel: '4 files', content: files }
]}
/>Adding the files#
Install the declared Bits UI dependency and copy both files into src/lib/components/content-tabs-01/, keeping these relative paths:
ContentTabs.svelte
types.tsImport ContentTab from types.ts when you build the tabs array outside the markup.
Panels#
Each tab's content is a snippet, and it can hold anything: text, a form, a table, an image. Every panel is rendered on the server and in the browser, and all of them share one grid cell, so the component is as tall as its tallest panel and switching tabs never moves what is below it. Only the selected panel is visible or reachable; the others are hidden from assistive technology.
A panel whose first content is text gets tabindex="0", so a keyboard user can Tab from the tab list into it and scroll it. A panel that opens with a link, button or field is left out of the Tab order, and that control takes focus instead. One that opens with text keeps its own stop even when controls follow.
Tabs switch views of related content on one page. For route changes, use links (the primary-nav-01 component), not tabs.
Selection#
value is the selected id. When it is missing or names a disabled or unknown tab, the first enabled tab is shown and a bound value is corrected to match. That correction does not call onValueChange, which only reports choices the visitor made.
activationMode="manual" keeps the panel in place while the arrow keys move focus; Enter, Space or a click selects. Use it when drawing a panel is expensive, such as a chart.
Orientation#
orientation="vertical" sets the tabs in a 13rem rail beside the panel. In a container narrower than 42rem the rail becomes a row above the panel that scrolls sideways, and the arrow keys switch from Up and Down to Left and Right with it. The component measures its own container, not the viewport, so it behaves the same in a sidebar or a full-width page.
Badges and icons#
badge is short visible text after the label. On the selected tab it takes the accent. Add badgeLabel so a screen reader hears "Activity, 12 updates" rather than "Activity 12". icon is a 16 px snippet before the label; draw it with currentColor.
Retoning#
Set the --content-tabs-* variables on the component or any ancestor. The sidecar's customisation guide has a worked retone for a cream page. Dark values apply under a .dark ancestor.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
tabs | ContentTab[] | Yes | None | Tabs in display order: { id, label, content, icon?, badge?, badgeLabel?, disabled? }. content is the panel snippet; icon is an optional 16 px snippet; badgeLabel is read in place of the badge. An empty list renders nothing. |
ariaLabel | string | Yes | None | Accessible name for the tab list, such as "Card details". |
value | string | No | first enabled tab | Selected tab id. Bindable. When it names no enabled tab, the first enabled tab is selected and a bound value is corrected without calling onValueChange. When every tab is disabled, no tab is selected and value is an empty string. |
onValueChange | (id: string) => void | No | None | Called with the new id when the visitor selects a different tab. |
orientation | 'horizontal' | 'vertical' | No | 'horizontal' | A row above the panel, or a rail beside it. The rail becomes a scrolling row when its container is narrower than 42rem, and the arrow keys follow whichever is on screen. |
activationMode | 'automatic' | 'manual' | No | 'automatic' | automatic selects a tab when it receives focus; manual moves focus with the arrow keys and selects on click, Enter or Space. Use manual when a panel is expensive to draw. |
variant | 'underline' | 'pills' | No | 'underline' | underline marks the selected tab with an accent bar on a hairline; pills sets the tabs in a recessed track with the selected one raised and marked by a short accent line. |
Customization#
On this pageChange tabs and panels through the tabs prop and retone through seven --content-tabs-* variables. The accent marks the selected tab (the bar, or the badge on the selected segment) and the focus ring; everything else is ink, muted text, hairlines and the pill track.
- Content: add, remove or reorder entries in tabs, keeping ids unique and stable. Keep labels to one or two words; long labels scroll the row on phones, and wrap in a vertical rail.
- Badges: badge is short visible text, a count or a word such as "New". Give
badgeLabel("12 updates") so a screen reader hears what the number means. - Icons: pass a 16 px inline SVG with stroke="
currentColor" as icon; it takes the tab's text colour. - Accent: set
--content-tabs-accentfor the selected bar or mark, the selected badge and the focus ring, and--content-tabs-on-accentfor the badge text on it. The default is near-black, so the component is monochrome until you choose a colour. - Text:
--content-tabs-inkis the selected label;--content-tabs-mutedis resting labels and badges. Keep both at 4.5:1 on your page. - Pills:
--content-tabs-trackis the recessed track and--content-tabs-raisedthe selected segment, which also carries a short accent mark so the selection reads without relying on the faint surface change. A vertical rail keeps the track, running the full height of the rail. - Worked retone for a cream page:
--content-tabs-ink:#292524;--content-tabs-muted:#57534e;--content-tabs-hairline: rgb(41 37 36 / 0.12);--content-tabs-track:#efe9df;--content-tabs-raised:#fffdf8;--content-tabs-accent:#9a3412;--content-tabs-on-accent:#ffffff. - Layout: the vertical rail is 13rem wide with 40px to the panel; change @
2xl:grid-cols-[13rem_minmax(0,1fr)] in the entry. The rail turns into a row below the @2xl container width (42rem).
Public CSS variables
| Variable | Token |
|---|---|
--content-tabs-accent | accent |
--content-tabs-on-accent | onAccent |
--content-tabs-ink | ink |
--content-tabs-muted | muted |
--content-tabs-hairline | hairline |
--content-tabs-track | track |
--content-tabs-raised | raised |
Dependencies and services#
On this page| Package | Range | Resolved at build time | Purpose |
|---|---|---|---|
bits-ui | ^2.0.0 | 2.19.3 | Headless Tabs primitive: tablist, tab and tabpanel roles, roving focus, arrow keys that follow orientation and reading direction, and manual activation. |
Install with (shown for reference, run it yourself)
npm install bits-ui@^2.0.0 Accessibility#
On this page- Implements the WAI-ARIA APG tabs pattern through Bits UI: tablist, tab and tabpanel roles,
aria-selected,aria-controls,aria-labelledbyandaria-orientation. - Left and Right move between tabs in a row, Up and Down in a vertical rail, following reading direction under dir="rtl"; Home and End jump to the ends and focus loops. When a vertical rail is shown as a row in a narrow container, the arrow keys follow the row.
- In manual mode the arrow keys move focus only; Enter, Space or a click selects. Exactly one tab is in the Tab order, the selected one.
- The tab list is named by
ariaLabel. Supply one that says what the tabs choose between. - A badge is part of its tab's name. With
badgeLabel, the tab is named "label,badgeLabel" (for example "Activity, 12 updates"), sobadgeLabelmust carry the count itself. - A panel keeps its own Tab stop unless its first meaningful content is a control, rechecked when the content changes, so keyboard users can reach and scroll panels that open with text; focus shows as a ring in the accent.
- Inactive panels stay in the layout but are inert and invisible, including any descendant that sets its own visibility, so they are neither focusable nor read.
- The selected tab is marked by ink text and a bar or a raised segment, not by colour alone. Disabled tabs are skipped by the keyboard and cannot be selected.
- Panel changes settle in over 200 ms after the first switch; under prefers-reduced-motion they only fade.
Known limitations
- A disabled tab is not focusable, so a screen-reader user in focus mode does not hear it; say why it is disabled in text outside the tab list if that matters.
- An overflowing row has no scroll buttons; tabs past the edge are reached by scrolling or the arrow keys, and the faded edge signals them.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.