Feature tabs
A tabbed feature section: an introduction, an underline tab row, and a panel with the feature copy beside a framed screenshot or product surface, or a points list when there is no media.
cmp_feature_tabs_001 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_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
This component needs all 3 files. Download the ZIP
<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>
Usage#
On this pageSupply 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
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
<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:
FeatureTabs.svelte
parts/FeaturePanel.svelte
types.tsCopying 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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
features | FeatureTab[] | Yes | None | Tabs in display order: { value, label, title, description, points?, media? }. value must be unique; media is a snippet shown in a frame beside the copy. |
title | string | Yes | None | Section heading text; also labels the tab list. |
eyebrow | string | No | None | Optional short label above the heading, set small, uppercase and muted. |
description | string | No | None | Optional introductory paragraph under the heading. |
headingLevel | 2 | 3 | 4 | 5 | No | 2 | Level of the section heading; panel titles use the next level down. |
value | string | No | features[0].value | Selected 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) => void | No | None | Called 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 pageChange 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-accentfor the active underline, the focus ring and the point marks, and--feature-tabs-on-accentfor the tick drawn on it. The default is near-black, so the section renders monochrome until you choose a colour. - Text:
--feature-tabs-inkis titles, active tabs and points;--feature-tabs-mutedis 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.tsto match. - Activation: Bits UI activates tabs on focus; add
activationMode="manual" to Tabs.Root if media is expensive to render. - Headings: set
headingLevelto fit the page outline; panel titles use the next level.
Public CSS variables
| Variable | Token |
|---|---|
--feature-tabs-accent | accent |
--feature-tabs-on-accent | onAccent |
--feature-tabs-ink | ink |
--feature-tabs-muted | muted |
--feature-tabs-hairline | hairline |
--feature-tabs-frame | frame |
--feature-tabs-surface | surface |
Dependencies and services#
On this page| Package | Range | Resolved at build time | Purpose |
|---|---|---|---|
bits-ui | ^2.0.0 | 2.19.3 | Headless 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-controlsandaria-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-hiddenwhen the panel copy already says what it shows. - Tab and panel IDs,
aria-controls,aria-labelledbyand 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.