Skip to content

Before you use this component

  • Install bits-ui@^2.0.0.
  • Requires client-side JavaScript to work.
Download ZIP

Blue palette · 9.5 KB ZIP File receipt View as Markdown View code

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.
cmp_content_tabs_01 · version 1.0.0 · Blue palette
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

types.ts TypeScript · 936 B Raw
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';

Pass 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
Required props
tabsariaLabel

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

Svelte
<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:

Plain text
ContentTabs.svelte
types.ts

Import 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
NameTypeRequiredDefaultDescription
tabsContentTab[]YesNoneTabs 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.
ariaLabelstringYesNoneAccessible name for the tab list, such as "Card details".
valuestringNofirst enabled tabSelected 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) => voidNoNoneCalled 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 page

Change 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-accent for the selected bar or mark, the selected badge and the focus ring, and --content-tabs-on-accent for the badge text on it. The default is near-black, so the component is monochrome until you choose a colour.
  • Text: --content-tabs-ink is the selected label; --content-tabs-muted is resting labels and badges. Keep both at 4.5:1 on your page.
  • Pills: --content-tabs-track is the recessed track and --content-tabs-raised the 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

VariableToken
--content-tabs-accentaccent
--content-tabs-on-accentonAccent
--content-tabs-inkink
--content-tabs-mutedmuted
--content-tabs-hairlinehairline
--content-tabs-tracktrack
--content-tabs-raisedraised

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
bits-ui^2.0.02.19.3Headless 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-labelledby and aria-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"), so badgeLabel must 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.