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
parts/FeaturePanel.svelte Svelte · 2.2 KB Raw
<script lang="ts">
	import type { FeatureTab } from '../types';

	interface Props {
		feature: FeatureTab;
		headingLevel: 3 | 4 | 5 | 6;
	}

	let { feature, headingLevel }: Props = $props();

	const heading = $derived(`h${headingLevel}`);
	const hasPoints = $derived(Boolean(feature.points && feature.points.length > 0));
</script>

{#snippet pointList(extra: string)}
	<ul role="list" class={['border-t border-(--_hairline) text-sm leading-6 text-(--_ink)', extra]}>
		{#each feature.points ?? [] as point, index (index)}
			<li class="flex items-start gap-3 border-b border-(--_hairline) py-3">
				<!-- The mark sits in a box one line tall, so it centres on the first line without an offset. -->
				<span class="flex h-6 shrink-0 items-center" aria-hidden="true">
					<span
						class="grid size-4 place-items-center rounded-full bg-(--_accent) text-(--_on-accent)"
					>
						<svg class="size-3" viewBox="0 0 12 12" fill="none">
							<path
								d="M3 6.25l2 2 4-4.5"
								stroke="currentColor"
								stroke-width="1.5"
								stroke-linecap="round"
								stroke-linejoin="round"
							/>
						</svg>
					</span>
				</span>
				<span class="min-w-0 break-words">{point}</span>
			</li>
		{/each}
	</ul>
{/snippet}

<div class="grid grid-cols-1 items-start gap-12 lg:grid-cols-12">
	<div class={['min-w-0', feature.media ? 'lg:col-span-5 lg:pt-6' : 'lg:col-span-5']}>
		<svelte:element
			this={heading}
			class="text-xl leading-[1.3] font-semibold tracking-[-0.015em] text-balance break-words text-(--_ink) rtl:tracking-normal"
		>
			{feature.title}
		</svelte:element>
		<p class="mt-4 max-w-prose text-base leading-6 text-pretty break-words text-(--_muted)">
			{feature.description}
		</p>
		{#if feature.media && hasPoints}
			{@render pointList('mt-6')}
		{/if}
	</div>
	{#if feature.media}
		<!-- A framed area for the tab's media: a raised hairline frame whose inner radius shares its centre. -->
		<div
			class="feature-tabs__frame min-w-0 rounded-2xl border border-(--_hairline) bg-(--_frame) p-2 shadow-(--_shadow-frame) lg:col-span-7"
		>
			<div class="overflow-hidden rounded-lg">
				{@render feature.media()}
			</div>
		</div>
	{:else if hasPoints}
		{@render pointList('min-w-0 lg:col-span-6 lg:col-start-7')}
	{/if}
</div>

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.