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
import type { Snippet } from 'svelte';
export interface ContentTab {
/** Stable, unique identifier; also the selected value. */
id: string;
/** Short text on the tab. */
label: string;
/** The panel shown while this tab is selected. */
content: Snippet;
/** Optional 16 px icon before the label, such as an inline SVG. It is hidden from assistive technology. */
icon?: Snippet;
/** Optional short count or tag after the label, such as "12" or "New". */
badge?: string;
/**
* What the badge means, read in place of it, such as "12 updates". Without it the badge
* text itself is part of the tab's accessible name.
*/
badgeLabel?: string;
/** A disabled tab stays visible but cannot be focused or selected. */
disabled?: boolean;
}
export type ContentTabsOrientation = 'horizontal' | 'vertical';
export type ContentTabsActivationMode = 'automatic' | 'manual';
export type ContentTabsVariant = 'underline' | 'pills';
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.