# 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.

- ID: `cmp_content_tabs_01`
- Slug: `content-tabs-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: control
- Primary category: `overlays`
- Detail page: https://pagesugar.com/components/content-tabs-01
- Preview: https://pagesugar.com/preview/content-tabs-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4` |
| `blue` | Blue | no | `sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light, dark
- Mode selection: Dark values apply when an ancestor has the class dark (for example \<html class="dark"\>). Without that class the light values apply regardless of the system setting.
- Suggested directory: `src/lib/components/content-tabs-01`

## Dependencies

- `bits-ui` `^2.0.0` (resolved at build time: `2.19.3`): Headless Tabs primitive: tablist, tab and tabpanel roles, roving focus, arrow keys that follow orientation and reading direction, and manual activation.

## Services

No external services required.

## Usage

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.

Required props: `tabs`, `ariaLabel`

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

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.

## Usage guide

### Adding the files

Install the declared Bits UI dependency and copy both files into `src/lib/components/content-tabs-01/`, keeping these relative paths:

```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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `tabs` | `ContentTab[]` | yes |  | 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 |  | 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 |  | 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

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).

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--content-tabs-accent` |
| `onAccent` | `--content-tabs-on-accent` |
| `ink` | `--content-tabs-ink` |
| `muted` | `--content-tabs-muted` |
| `hairline` | `--content-tabs-hairline` |
| `track` | `--content-tabs-track` |
| `raised` | `--content-tabs-raised` |

## Accessibility

- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `ContentTabs.svelte`
- Suggested directory: `src/lib/components/content-tabs-01`
- Files: 2
- Artifact digest: `sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `ContentTabs.svelte`

Role: entry · 17448 bytes · SHA-256 `d6ef806af9dcf16dae7e879d81b13fd813e00651704505a5f6ad316b6735fda7`

```svelte
<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, #18181b);
		--_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, #fafafa);
		--_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>
```

#### `types.ts`

Role: types · 936 bytes · SHA-256 `2eaeeb8140cfe6ea414cdec65a564d45410f2e3f0bcd0c9a1d68148b3a498c55`

```ts
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';
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4`
- Entry: `ContentTabs.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/neutral/sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/neutral/sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4/bundle.zip (9769 bytes, sha256 `bc9119a1061fc55a379cbe07c7253292409130fc12506cfff9f0738244d4439e`)

Files:

- `ContentTabs.svelte` (entry, 17448 bytes): https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/neutral/sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4/source/ContentTabs.svelte
- `types.ts` (types, 936 bytes): https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/neutral/sha256-743422528c7623dacab61d23a103b2cff5ac15f7d895bb89e67fef691e535da4/source/types.ts

### Blue (`blue`)

- Artifact digest: `sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b`
- Entry: `ContentTabs.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/blue/sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/blue/sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b/bundle.zip (9773 bytes, sha256 `c78b43f511a22a0adca39d38955a929f593849776a78faeb0a7686943de4e6e8`)

Files:

- `ContentTabs.svelte` (entry, 17448 bytes): https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/blue/sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b/source/ContentTabs.svelte
- `types.ts` (types, 936 bytes): https://pagesugar.com/artifacts/cmp_content_tabs_01/1.0.0/blue/sha256-2992dccde0f75c6d4831b3aa8569f9bdcd5bd8d00fe93dac1dbe85f19cccef5b/source/types.ts
