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

- ID: `cmp_feature_tabs_001`
- Slug: `feature-tabs-01`
- Version: `1.1.3` (current)
- Status: published
- Published: 2026-09-16T15:41:02Z
- Updated: 2026-10-02
- Available versions: `1.1.3`
- Kind: section
- Primary category: `features`
- Detail page: https://pagesugar.com/components/feature-tabs-01?variant=neutral
- Preview: https://pagesugar.com/preview/feature-tabs-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/feature-tabs-01`

## Dependencies

- `bits-ui` `^2.0.0` (resolved at build time: `2.19.3`): Headless Tabs primitive providing tab/tablist/tabpanel roles, roving focus and arrow-key navigation.

## Services

No external services required.

## Usage

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.

Required props: `features`, `title`

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

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.

## Usage guide

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

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `features` | `FeatureTab[]` | yes |  | 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 |  | Section heading text; also labels the tab list. |
| `eyebrow` | `string` | no |  | Optional short label above the heading, set small, uppercase and muted. |
| `description` | `string` | no |  | 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 |  | 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

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--feature-tabs-accent` |
| `onAccent` | `--feature-tabs-on-accent` |
| `ink` | `--feature-tabs-ink` |
| `muted` | `--feature-tabs-muted` |
| `hairline` | `--feature-tabs-hairline` |
| `frame` | `--feature-tabs-frame` |
| `surface` | `--feature-tabs-surface` |

## Accessibility

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

## License

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

## Source

- Palette: Neutral (`neutral`)
- Entry: `FeatureTabs.svelte`
- Suggested directory: `src/lib/components/feature-tabs-01`
- Files: 3
- Artifact digest: `sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d`

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

#### `FeatureTabs.svelte`

Role: entry · 9097 bytes · SHA-256 `de6798db2dc538d2725b8359fad9b684dd2f84992fc31f0bf9e25c99c858c0e6`

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

#### `parts/FeaturePanel.svelte`

Role: component · 2270 bytes · SHA-256 `359f68f548641479551462da05e859a7115f7f30479a6d188bd2e85da51d8aed`

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

#### `types.ts`

Role: types · 720 bytes · SHA-256 `f654c071816703457738c7a970a8eaf4fe6e5d8ed2cd3c261fb4d1d613013338`

```ts
import type { Snippet } from 'svelte';

export type HeadingLevel = 2 | 3 | 4 | 5;

export interface FeatureTab {
	/** Stable, unique identifier for the tab; also used as the selected value. */
	value: string;
	/** Short text shown on the tab trigger. */
	label: string;
	/** Heading shown inside the panel. */
	title: string;
	/** Plain-text paragraph shown inside the panel. */
	description: string;
	/** Optional supporting points. Beside the copy without media, under it with media. */
	points?: string[];
	/**
	 * Optional media for this tab: a screenshot, video or product surface. It fills a framed
	 * area in the panel's wider column. Give images real alt text, or mark decorative ones.
	 */
	media?: Snippet;
}
```

## Artifacts

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

- Artifact digest: `sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d`
- Entry: `FeatureTabs.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_feature_tabs_001/1.1.3/neutral/sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_feature_tabs_001/1.1.3/neutral/sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d/bundle.zip (8721 bytes, sha256 `2a46851afa68d94399d70e69b2a3141ccc7cdbcad3154118b6298c1bc8e08d00`)

Files:

- `FeatureTabs.svelte` (entry, 9097 bytes): https://pagesugar.com/artifacts/cmp_feature_tabs_001/1.1.3/neutral/sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d/source/FeatureTabs.svelte
- `parts/FeaturePanel.svelte` (component, 2270 bytes): https://pagesugar.com/artifacts/cmp_feature_tabs_001/1.1.3/neutral/sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d/source/parts/FeaturePanel.svelte
- `types.ts` (types, 720 bytes): https://pagesugar.com/artifacts/cmp_feature_tabs_001/1.1.3/neutral/sha256-b180d42c0d1f2781dbae03f728bf587d3acc39e856c5be45130b6e1c47e0b15d/source/types.ts
