Skip to content

Before you use this component

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

Neutral palette · 8.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_feature_tabs_001 · version 1.1.3 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_feature_tabs_001 version 1.1.3 with variant "neutral", 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
Neutral
Version
1.1.3
Digest
Full digest
sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d
FeatureTabs.svelte Svelte · 8.9 KB Raw
<script lang="ts">
	import { Tabs } from 'bits-ui';
	import FeaturePanel from './parts/FeaturePanel.svelte';
	import type { FeatureTab, HeadingLevel } from './types';

	interface Props {
		features: FeatureTab[];
		title: string;
		eyebrow?: string;
		description?: string;
		headingLevel?: HeadingLevel;
		value?: string;
		onValueChange?: (value: string) => void;
	}

	let {
		features,
		title,
		eyebrow,
		description,
		headingLevel = 2,
		value = $bindable(),
		onValueChange
	}: Props = $props();

	const uid = $props.id();
	const sectionHeading = $derived(`h${headingLevel}`);
	const panelHeadingLevel = $derived((headingLevel + 1) as 3 | 4 | 5 | 6);

	/*
	 * The tab actually shown. A value that matches no feature (missing, mistyped, or the
	 * selected feature was removed) falls back to the first feature, so exactly one tab is
	 * always selected and in the Tab order. An empty list selects nothing.
	 */
	const selected = $derived(
		features.some((feature) => feature.value === value) ? value! : (features[0]?.value ?? '')
	);

	/*
	 * Keep a bound value in step with what is shown. This is a correction, not a user
	 * choice, so onValueChange does not fire for it.
	 */
	$effect(() => {
		if (value !== selected) value = selected;
	});

	/*
	 * The tab row scrolls when its labels are wider than the section. Keep the selected tab
	 * in view, and mark which edges have more tabs behind them so the row can fade there.
	 */
	let list = $state<HTMLElement | null>(null);
	let overflowStart = $state(false);
	let overflowEnd = $state(false);

	function measure() {
		if (!list) return;
		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;
	}

	$effect(() => {
		if (!list) return;
		const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
		observer?.observe(list);
		list.addEventListener('scroll', measure, { passive: true });
		measure();
		return () => {
			observer?.disconnect();
			list?.removeEventListener('scroll', measure);
		};
	});

	$effect(() => {
		const index = features.findIndex((feature) => feature.value === selected);
		const tab = list?.querySelector<HTMLElement>(`#${CSS.escape(`${uid}-tab-${index}`)}`);
		if (!list || !tab) return;
		// Scroll the row only, never the page, and by the smallest distance that shows the tab.
		const row = list.getBoundingClientRect();
		const box = tab.getBoundingClientRect();
		if (box.left < row.left) list.scrollBy({ left: box.left - row.left - 48 });
		else if (box.right > row.right) list.scrollBy({ left: box.right - row.right + 48 });
		measure();
	});

	function select(next: string) {
		if (next === selected) return;
		value = next;
		onValueChange?.(next);
	}
</script>

<section
	class="feature-tabs bg-(--_surface) px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
	aria-labelledby="{uid}-title"
>
	<div class="mx-auto max-w-6xl">
		<div class="max-w-2xl">
			{#if eyebrow}
				<p
					class="text-xs leading-none font-medium tracking-[0.06em] text-balance text-(--_muted) uppercase rtl:tracking-normal"
				>
					{eyebrow}
				</p>
			{/if}
			<svelte:element
				this={sectionHeading}
				id="{uid}-title"
				class={[
					'text-3xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance break-words text-(--_ink) sm:text-4xl rtl:tracking-normal',
					eyebrow && 'mt-2'
				]}
			>
				{title}
			</svelte:element>
			{#if description}
				<!-- Arabic and Hebrew set more characters per line, so the measure tightens to stay near 70. -->
				<p
					class="mt-4 max-w-xl text-base leading-6 text-pretty text-(--_muted) sm:text-lg sm:leading-7 rtl:max-w-[30rem]"
				>
					{description}
				</p>
			{/if}
		</div>

		{#if features.length > 0}
			<Tabs.Root value={selected} onValueChange={select} class="mt-12">
				<Tabs.List
					bind:ref={list}
					aria-labelledby="{uid}-title"
					data-overflow-start={overflowStart ? '' : undefined}
					data-overflow-end={overflowEnd ? '' : undefined}
					class="feature-tabs__list -mx-4 flex gap-4 overflow-x-auto ps-2 pe-6 sm:-mx-2 sm:ps-0 sm:pe-0"
				>
					{#each features as feature, index (feature.value)}
						<!-- The label's colour eases over 150 ms and the press over 80 ms; under reduced motion the press does not move. -->
						<Tabs.Trigger
							value={feature.value}
							id="{uid}-tab-{index}"
							class="feature-tabs__tab relative min-h-11 shrink-0 cursor-pointer rounded-md px-2 text-sm font-medium whitespace-nowrap text-(--_muted) transition-[color,translate] duration-[150ms,80ms] ease-(--_ease) after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-transparent after:transition-[background-color] after:duration-150 after:ease-(--_ease) hover:text-(--_ink) hover:after:bg-(--_indicator-hover) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--_accent) active:translate-y-px data-[state=active]:text-(--_ink) data-[state=active]:after:bg-(--_accent) motion-reduce:active:translate-none sm:min-h-12"
						>
							{#snippet child({ props })}
								<!-- Explicit relationships and roving tabindex, so server-rendered markup is complete before hydration. -->
								<button
									{...props}
									aria-controls="{uid}-panel-{index}"
									tabindex={feature.value === selected ? 0 : -1}
								>
									{feature.label}
								</button>
							{/snippet}
						</Tabs.Trigger>
					{/each}
				</Tabs.List>
				<!-- Every panel shares one grid cell, so the section keeps the height of its tallest panel and switching tabs never moves the page below. -->
				<div class="feature-tabs__panels mt-12 grid">
					{#each features as feature, index (feature.value)}
						<Tabs.Content
							value={feature.value}
							id="{uid}-panel-{index}"
							class="feature-tabs__panel rounded-md focus-visible:outline-2 focus-visible:outline-offset-8 focus-visible:outline-(--_accent)"
						>
							{#snippet child({ props })}
								<div {...props} aria-labelledby="{uid}-tab-{index}">
									<FeaturePanel {feature} headingLevel={panelHeadingLevel} />
								</div>
							{/snippet}
						</Tabs.Content>
					{/each}
				</div>
			</Tabs.Root>
		{/if}
	</div>
</section>

<style>
	/* Public tokens: set --feature-tabs-* on this section or any ancestor to retone it. */
	.feature-tabs {
		--_accent: var(--feature-tabs-accent, #18181b);
		--_on-accent: var(--feature-tabs-on-accent, #ffffff);
		--_ink: var(--feature-tabs-ink, #18181b);
		--_muted: var(--feature-tabs-muted, #52525b);
		--_hairline: var(--feature-tabs-hairline, rgb(0 0 0 / 0.1));
		--_frame: var(--feature-tabs-frame, #fafafa);
		--_surface: var(--feature-tabs-surface, transparent);
		--_indicator-hover: color-mix(in srgb, var(--_ink) 25%, transparent);
		/* The one elevation in the section: the framed media, lit from above. */
		--_shadow-frame: 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 40px rgb(0 0 0 / 0.08);
		/* State-change easing for the tabs. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/* The hairline under the tabs is drawn inside the scroller, so the active underline sits on it. */
	.feature-tabs :global(.feature-tabs__list) {
		box-shadow: inset 0 -1px 0 var(--_hairline);
		scrollbar-width: none;
	}
	.feature-tabs :global(.feature-tabs__list::-webkit-scrollbar) {
		display: none;
	}
	/* When tabs run past an edge, that edge fades, so a clipped label reads as more to scroll. */
	.feature-tabs :global(.feature-tabs__list) {
		--_fade-start: #000;
		--_fade-end: #000;
		mask-image: linear-gradient(
			to right,
			var(--_fade-start),
			#000 1.5rem,
			#000 calc(100% - 1.5rem),
			var(--_fade-end)
		);
	}
	.feature-tabs :global(.feature-tabs__list:dir(rtl)) {
		mask-image: linear-gradient(
			to left,
			var(--_fade-start),
			#000 1.5rem,
			#000 calc(100% - 1.5rem),
			var(--_fade-end)
		);
	}
	.feature-tabs :global(.feature-tabs__list[data-overflow-start]) {
		--_fade-start: transparent;
	}
	.feature-tabs :global(.feature-tabs__list[data-overflow-end]) {
		--_fade-end: transparent;
	}

	/* Inactive panels keep their place in the shared cell but are invisible, unfocusable and hidden from assistive technology. */
	.feature-tabs :global(.feature-tabs__panels > .feature-tabs__panel) {
		grid-area: 1 / 1;
		min-width: 0;
	}
	.feature-tabs :global(.feature-tabs__panels > .feature-tabs__panel[hidden]) {
		display: block;
		visibility: hidden;
	}

	/* A newly selected panel settles in; with reduced motion it only fades. */
	.feature-tabs :global(.feature-tabs__panel[data-state='active']) {
		animation: feature-tabs-enter 220ms cubic-bezier(0.16, 1, 0.3, 1);
	}
	@keyframes feature-tabs-enter {
		from {
			opacity: 0;
			transform: translateY(4px);
		}
	}
	@media (prefers-reduced-motion: reduce) {
		.feature-tabs :global(.feature-tabs__panel[data-state='active']) {
			animation-name: feature-tabs-fade;
		}
	}
	@keyframes feature-tabs-fade {
		from {
			opacity: 0;
		}
	}
</style>

Supply feature tabs as data, with an optional media snippet per tab. The selected tab is local state; bind value or pass onValueChange to observe or control it. An unknown value selects the first feature. Requires the bits-ui package; no backend, no data fetching, and it does not load or lazy-load media for you.

Suggested location
src/lib/components/feature-tabs-01
Required props
featurestitle

Limitations

  • Install bits-ui (npm install bits-ui@^2) before using the component.
  • Every feature value must be unique; it is used as the tab key.
  • Tab switching needs JavaScript; before hydration only the initially selected panel is visible.
  • Media is whatever the snippet renders. The component frames and clips it but does not size, lazy-load or pause it; give images width, height and alt text, and pause video on inactive tabs yourself.
  • All panels share one grid cell, so the section is as tall as its tallest panel; a much longer panel leaves space under the shorter ones.

Example

Svelte
<script lang="ts">
	import FeatureTabs from '$lib/components/feature-tabs-01/FeatureTabs.svelte';
	import type { FeatureTab } from '$lib/components/feature-tabs-01/types';

	let selected = $state('boards');
</script>

{#snippet boardShot()}
	<img src="/screens/board.png" alt="A board with Backlog, Doing, Review and Done columns" width="1200" height="800" class="block h-auto w-full" />
{/snippet}

<FeatureTabs
	eyebrow="Halcyon 3"
	title="Plan, track and share work in one place"
	description="Boards, timelines and guest access all read from the same cards."
	features={[
		{
			value: 'boards',
			label: 'Boards',
			title: 'Every project on one board',
			description: 'Cards carry an owner, a due date and a status.',
			points: ['Your own columns', 'Board, list or calendar views'],
			media: boardShot
		},
		{
			value: 'timelines',
			label: 'Timelines',
			title: 'Every deadline on one calendar',
			description: 'Drag a bar to move a date.',
			points: ['Week, month or quarter zoom']
		}
	] satisfies FeatureTab[]}
	bind:value={selected}
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Adding the complete file set#

Install the declared Bits UI dependency and copy all three files into src/lib/components/feature-tabs-01/, keeping these relative paths:

Plain text
FeatureTabs.svelte
parts/FeaturePanel.svelte
types.ts

Copying the entry component alone is not enough. The quick-start example shows how to import FeatureTab, give a tab media and bind the selected value.

Media#

Each feature can carry a media snippet. It renders in the panel's wider column inside a raised hairline frame: 16px outer corners, 8px of padding and 8px inner corners, with the section's one shadow. Anything works inside it: a screenshot with width, height and alt, a muted looping video, or a product surface drawn in markup. The frame clips its content, so a full-bleed image needs only class="block h-auto w-full".

A feature without media shows its points in that column instead. A feature with both shows the points under its description.

Selection#

value is the selected feature. If it is missing, or names no feature (a typo, or the selected feature was removed from the list), the first feature is shown and a bound value is corrected to match. That correction does not call onValueChange, which only reports choices the visitor made. With an empty list the section renders its introduction and nothing else.

Retoning#

Set the --feature-tabs-* variables on the section or any ancestor. The customisation guide in the sidecar has a full dark-page retone.

Extending a panel#

For richer panel content, edit parts/FeaturePanel.svelte and extend FeatureTab in types.ts together. Keep each tab value unique and check that headings fit the surrounding page. For manual activation, add activationMode="manual" to Tabs.Root; verify keyboard behaviour after the change.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
featuresFeatureTab[]YesNoneTabs in display order: { value, label, title, description, points?, media? }. value must be unique; media is a snippet shown in a frame beside the copy.
titlestringYesNoneSection heading text; also labels the tab list.
eyebrowstringNoNoneOptional short label above the heading, set small, uppercase and muted.
descriptionstringNoNoneOptional introductory paragraph under the heading.
headingLevel2 | 3 | 4 | 5No2Level of the section heading; panel titles use the next level down.
valuestringNofeatures[0].valueSelected tab value. Bindable with bind:value. When it is missing or names no feature (including after the selected feature is removed), the first feature is selected and a bound value is updated to match; onValueChange is not called for that correction. With no features it is an empty string.
onValueChange(value: string) => voidNoNoneCalled with the new value when the user selects a different tab. Not called when the component corrects an unknown value. Local UI callback only.

Customization#

On this page

Change tabs and media through the features prop, and retone the section through seven --feature-tabs-* CSS variables. The accent marks the active tab, the focus ring and the point marks; everything else is text, hairlines, the media frame and the section background.

  • Content: add, remove or reorder entries in features; keep each value unique and stable. Keep labels to one or two words so the row fits without scrolling on phones.
  • Media: give a feature a media snippet (an img, a video or a product surface in markup). It fills a framed area in the wider column and is clipped to 8px corners. Features without media show their points beside the copy instead.
  • Accent: set --feature-tabs-accent for the active underline, the focus ring and the point marks, and --feature-tabs-on-accent for the tick drawn on it. The default is near-black, so the section renders monochrome until you choose a colour.
  • Text: --feature-tabs-ink is titles, active tabs and points; --feature-tabs-muted is the eyebrow, descriptions and resting tabs. Keep both at 4.5:1 against the surface.
  • Worked retone for a dark page: --feature-tabs-surface: #09090b; --feature-tabs-ink: #fafafa; --feature-tabs-muted: #a1a1aa; --feature-tabs-hairline: rgb(255 255 255 / 0.1); --feature-tabs-frame: #18181b; --feature-tabs-accent: #fafafa; --feature-tabs-on-accent: #09090b. The frame keeps its shadow; on a dark page it reads as a hairline edge.
  • Panel layout: edit parts/FeaturePanel.svelte to change the 5/7 split, or add a link under the description; extend FeatureTab in types.ts to match.
  • Activation: Bits UI activates tabs on focus; add activationMode="manual" to Tabs.Root if media is expensive to render.
  • Headings: set headingLevel to fit the page outline; panel titles use the next level.

Public CSS variables

VariableToken
--feature-tabs-accentaccent
--feature-tabs-on-accentonAccent
--feature-tabs-inkink
--feature-tabs-mutedmuted
--feature-tabs-hairlinehairline
--feature-tabs-frameframe
--feature-tabs-surfacesurface

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
bits-ui^2.0.02.19.3Headless Tabs primitive providing tab/tablist/tabpanel roles, roving focus and arrow-key navigation.

Install with (shown for reference, run it yourself)

npm install bits-ui@^2.0.0

Accessibility#

On this page
  • Implements the WAI-ARIA tabs pattern through Bits UI: tablist, tab and tabpanel roles with aria-selected, aria-controls and aria-labelledby.
  • Arrow keys move between tabs (looping) and activate them, Home and End jump to the first and last tab, and exactly one tab is in the Tab order, including when value is unknown. Arrow keys follow the reading direction under dir="rtl".
  • The tab list is labelled by the section heading; the selected tab is marked by ink text and an underline, not colour alone.
  • Panels are focusable so keyboard users reach panel content directly after the tab list; focus shows as a ring in the accent colour.
  • Media is the consumer's responsibility: give images meaningful alt text, or mark a decorative surface aria-hidden when the panel copy already says what it shows.
  • Tab and panel IDs, aria-controls, aria-labelledby and the roving tabindex are set explicitly from $props.id() and the selected value, so server-rendered markup is complete before hydration and multiple instances never collide.
  • Panel switches settle in over 220ms; under prefers-reduced-motion they only fade.

Known limitations

  • On narrow screens the tab row scrolls horizontally; the edges with more tabs behind them fade, the selected tab is scrolled into view, and there are no scroll buttons.

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.1.3 (Published) Current release · 16 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.