# Pricing comparison table

> A feature matrix comparing two to five plans across grouped rows. Every cell is included, not included, limited with a note, or a written value, and the highlighted plan runs as one band from header to last row.

- ID: `cmp_pricing_comparison_table_01`
- Slug: `pricing-comparison-table-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `pricing`
- Detail page: https://pagesugar.com/components/pricing-comparison-table-01?variant=blue
- Preview: https://pagesugar.com/preview/pricing-comparison-table-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-28b5048cafd5984adc3a0270515de48386b22fe38eb0c33a1f9604da25a57e2e` |
| `blue` | Blue accent | no | `sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/pricing-comparison-table-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational only: renders the plans and feature rows you pass, in the order of plans, with ordinary links for each plan's action. It does not compute eligibility or recommendations, switch billing periods or currencies, or check that feature claims match your product. No runtime packages beyond Svelte and Tailwind CSS, no client JavaScript required.

Required props: `plans`, `groups`, `title`

```svelte
<!-- Illustrative content: replace example plans, claims and links before publishing. -->
<script lang="ts">
	import PricingComparisonTable, {
		type ComparisonGroup,
		type ComparisonPlan
	} from '$lib/components/pricing-comparison-table-01/PricingComparisonTable.svelte';

	const plans: ComparisonPlan[] = [
		{ id: 'starter', label: 'Starter', price: '$0', cta: { label: 'Start free', href: '/signup?plan=starter' } },
		{ id: 'team', label: 'Team', price: '$12', period: 'per seat per month', cta: { label: 'Try Team', href: '/signup?plan=team' }, highlighted: true }
	];

	const groups: ComparisonGroup[] = [
		{
			label: 'Planning',
			rows: [
				{ feature: 'Timelines', values: { starter: false, team: true } },
				{ feature: 'Version history', values: { starter: '30 days', team: '1 year' } },
				{ feature: 'Guest seats', values: { starter: false, team: { limited: 'Up to 10 guests' } } }
			]
		}
	];
</script>

<PricingComparisonTable title="Compare plans" {plans} {groups} />
```

Limitations:

- Display only: prices are strings and are never parsed, totalled or converted.
- No billing-period or currency switch; render a second table or swap the data yourself.
- Below the lg breakpoint the table scrolls sideways inside its region. For phones, a stacked presentation of the same data (pattern C088) reads better than a wide table.
- The sticky plan row works from lg, where the table is not inside a scroll container. If your page has a fixed header, set --pricing-comparison-table-sticky-top to its height.
- Cells with no value for a plan render the notListed label, not the excluded mark. With no plans or no rows, only the heading and description render.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared.

## Usage guide

### Data shape

Copy `PricingComparisonTable.svelte` into `src/lib/components/pricing-comparison-table-01/`. The types are exported from its module script:

```ts
interface ComparisonPlan {
	id: string; // key used in every row's values
	label: string;
	price?: string; // display only
	period?: string;
	cta?: { label: string; href: string };
	highlighted?: boolean;
}

type ComparisonValue = boolean | string | { limited: string };

interface ComparisonGroup {
	label: string;
	rows: {
		feature: string;
		description?: string;
		values: Record<string, ComparisonValue | null | undefined>;
	}[];
}
```

The same `plans` and `groups` can feed a stacked mobile comparison or a free-versus-paid table later, so keep one copy of the data and pass it to each.

### What each cell shows

| Value                            | Renders                                  | Screen readers hear    |
| -------------------------------- | ---------------------------------------- | ---------------------- |
| `true`                           | A check in the accent                    | `labels.included`      |
| `false`                          | A short muted dash                       | `labels.excluded`      |
| `{ limited: 'Up to 10 guests' }` | A half-filled ring and the note          | `labels.limited: note` |
| `'10 GB'`                        | The string, in tabular numerals          | The string             |
| missing, `null` or `undefined`   | `labels.notListed` as visible muted text | The same text          |

A missing value is never shown as excluded: "Not listed" means you have not said, the dash means the plan does not include it.

Columns always follow the order of `plans`, whatever order the keys have in each row. Groups with no rows are skipped, and when only one group remains its label row is hidden.

### Layout

- From `lg` the table fits its container (fixed layout, the feature column at 30%, plans sharing the rest) and, with `stickyHeader`, the plan row sticks to the top of the viewport while the table scrolls past.
- Below `lg` the table keeps a minimum width and scrolls sideways inside a focusable region. The feature column stays in place, gains a hairline edge once plans slide beneath it, and a hint under the table says the table scrolls. The hint only appears while the table actually overflows in browsers with scroll-driven animations; elsewhere it is always shown below `lg`.
- Position `sticky` does not work inside a horizontal scroll container, which is why the sticky plan row only applies from `lg`.

If your site has a fixed header, stop the plan row beneath it:

```svelte
<div style="--pricing-comparison-table-sticky-top: 4rem;">
	<PricingComparisonTable title="Compare plans" {plans} {groups} />
</div>
```

### Retoning

| Variable                                    | Default   | Draws                                                 |
| ------------------------------------------- | --------- | ----------------------------------------------------- |
| `--pricing-comparison-table-accent`         | `#18181b` | Highlighted link, checks, limited marks, label, focus |
| `--pricing-comparison-table-on-accent`      | `#ffffff` | Text on the highlighted link                          |
| `--pricing-comparison-table-ink`            | `#18181b` | Title, plan names, prices, features, values           |
| `--pricing-comparison-table-muted`          | `#52525b` | Description, periods, notes, dashes, "Not listed"     |
| `--pricing-comparison-table-hairline`       | `#e4e4e7` | Row rules and the band outline                        |
| `--pricing-comparison-table-control-border` | `#d4d4d8` | Secondary link outline                                |
| `--pricing-comparison-table-surface`        | `#ffffff` | Sticky header and held feature column; match the page |
| `--pricing-comparison-table-band`           | `#fafafa` | The highlighted column                                |
| `--pricing-comparison-table-sticky-top`     | `0px`     | Offset for the sticky plan row                        |

A worked retone for a dark page:

```svelte
<div
	class="bg-zinc-950"
	style="
		--pricing-comparison-table-surface: #09090b;
		--pricing-comparison-table-band: #18181b;
		--pricing-comparison-table-ink: #fafafa;
		--pricing-comparison-table-muted: #a1a1aa;
		--pricing-comparison-table-hairline: rgb(255 255 255 / 0.1);
		--pricing-comparison-table-control-border: rgb(255 255 255 / 0.2);
		--pricing-comparison-table-accent: #fafafa;
		--pricing-comparison-table-on-accent: #18181b;
	"
>
	<PricingComparisonTable title="Compare plans" {plans} {groups} />
</div>
```

Contrast figures in the sidecar describe the shipped palettes only. Re-check after changing any colour.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `plans` | `ComparisonPlan[]` | yes |  | Plan columns in display order: { id, label, price?, period?, cta?: { label, href }, highlighted? }. Column order always follows this array, whatever the key order in each row's values. |
| `groups` | `ComparisonGroup[]` | yes |  | Feature rows grouped under a label: { label, rows: { feature, description?, values } \[\] }. values is keyed by plan id; each value is true, false, a string, or { limited: string }. Group labels are hidden when there is only one group; empty groups are skipped. |
| `title` | `string` | yes |  | Section heading text. |
| `description` | `string` | no |  | Optional introductory paragraph under the heading. |
| `eyebrow` | `string` | no |  | Optional short label set small and uppercase above the heading. |
| `headingLevel` | 2 \| 3 \| 4 \| 5 | no | `2` | Level of the section heading. |
| `labels` | `Partial<ComparisonLabels>` | no | `{ included: 'Included', excluded: 'Not included', limited: 'Limited', notListed: 'Not listed', scrollHint: 'Scroll sideways to compare every plan' }` | Text equivalents for the marks, the missing-value text and the scroll hint. Pass only the keys you want to change. |
| `stickyHeader` | `boolean` | no | `true` | Keep the plan-name row in view while the page scrolls past the table, from the lg breakpoint. Prices and links scroll with the table. |
| `highlightLabel` | `string` | no | `'Recommended'` | Text shown above the highlighted plan's name, so the highlight is named in text and not only by the band. Keep it factual: 'Recommended' or 'Best for growing teams', not a popularity claim you cannot support. Below lg it also leads the scroll hint, paired with the plan name, because the highlighted column may start off screen. |
| `caption` | `string` | no |  | Visually hidden table caption, which also names the scroll region. Defaults to the title. |
| `footnotes` | `string[]` | no | `[]` | Notes set small under the table: one note as a paragraph, several as a numbered list. |

## Customization

Change content through props, retone the section through CSS variables (accent, on-accent, ink, muted, hairline, control border, surface, band and sticky offset), and edit Tailwind classes in the source for spacing or column widths.

- Accent: set --pricing-comparison-table-accent and --pricing-comparison-table-on-accent together. The accent fills the highlighted plan's link and draws the check and limited marks, the highlight label and the focus ring.
- Text: --pricing-comparison-table-ink sets the title, plan names, prices, feature names and values; --pricing-comparison-table-muted sets the description, periods, feature notes, the not-included mark, 'Not listed' and the footnotes.
- Boundaries: --pricing-comparison-table-hairline draws the row rules and the band's outline; --pricing-comparison-table-control-border outlines the secondary links.
- Surfaces: --pricing-comparison-table-surface fills the sticky header and the held feature column, so set it to your page background. --pricing-comparison-table-band fills the highlighted column.
- Dark page retone: surface #09090b, band #18181b, ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.1), control-border rgb(255 255 255 / 0.2), accent #fafafa, on-accent #18181b. usage.md has the snippet.
- Sticky header: if your site has a fixed header, set --pricing-comparison-table-sticky-top to its height (for example 4rem) so the plan row stops beneath it. Pass stickyHeader={false} to turn it off.
- Links: replace each cta.href with your real signup, checkout or contact route. Keep labels short; plan columns are narrow on tablets.
- Labels: pass labels to rename the marks for your trade, for example { included: 'Covered', excluded: 'Not covered' } for a clinic.
- Columns: the feature column is w-34, sm:w-48 and lg:w-\[30%\] on the first col element; plan columns share the rest equally, at least 9rem each on phones and 10rem from sm.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--pricing-comparison-table-accent` |
| `onAccent` | `--pricing-comparison-table-on-accent` |
| `ink` | `--pricing-comparison-table-ink` |
| `muted` | `--pricing-comparison-table-muted` |
| `hairline` | `--pricing-comparison-table-hairline` |
| `controlBorder` | `--pricing-comparison-table-control-border` |
| `surface` | `--pricing-comparison-table-surface` |
| `band` | `--pricing-comparison-table-band` |
| `stickyTop` | `--pricing-comparison-table-sticky-top` |

## Accessibility

- A native table with a visually hidden caption, a column header per plan (scope=col) holding the plan name and, for the highlighted plan, the highlight label as visually hidden text, a row header per feature (scope=row) and one tbody per group whose label is a rowgroup header.
- Every mark carries text: checks read 'Included', dashes 'Not included', limited cells 'Limited: \<note\>', and missing values show 'Not listed' as visible text. All four strings can be replaced through labels.
- The highlighted plan is named in text by highlightLabel as well as by the band and the filled link.
- The table sits in a region named by the caption with tabindex=0, so keyboard users can focus it and scroll it with the arrow keys when it overflows. It shows a two-pixel accent outline on :focus-visible.
- Plan links are described by their plan name through aria-describedby, and are at least 44 px tall. Their pressed state moves one pixel and is removed under prefers-reduced-motion.
- The scroll hint is decorative (aria-hidden); the region itself is focusable and named.
- White on-accent text measures about 17.7:1 on the neutral accent and 6.7:1 on the blue accent; muted text (#52525b) measures about 7.3:1 on the band fill (#fafafa). Ratios use the WCAG relative-luminance formula.
- The layout uses logical properties, so the held column, band edges and hint arrow mirror under dir="rtl"; letter-spacing resets to 0 in right-to-left text.
- Element IDs come from $props.id(), so several tables on one page stay unique.

Known limitations:

- Contrast ratios are for the shipped palettes only; re-check after changing any token.
- The secondary link outline (#d4d4d8) is a decorative boundary under 3:1; the link is identified by its label.

## License

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

## Source

- Palette: Blue accent (`blue`)
- Entry: `PricingComparisonTable.svelte`
- Suggested directory: `src/lib/components/pricing-comparison-table-01`
- Files: 1
- Artifact digest: `sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249`

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

#### `PricingComparisonTable.svelte`

Role: entry · 19011 bytes · SHA-256 `9ab1b4a6b66dbf21fb2301a15be695270624e2e342330fabaa7d68b7449df6e8`

```svelte
<script module lang="ts">
	/** One plan column, in display order. */
	export interface ComparisonPlan {
		/** Key used in every row's `values`. */
		id: string;
		/** Plan name shown at the top of the column. */
		label: string;
		/** Display-only price string, e.g. "$12" or "Custom". Never parsed or charged. */
		price?: string;
		/** Shown under the price, e.g. "per seat per month". */
		period?: string;
		/** An ordinary link to your signup, checkout or contact page. */
		cta?: { label: string; href: string };
		/** Hold this column in the highlight band, name it with `highlightLabel` and fill its link. */
		highlighted?: boolean;
	}

	/**
	 * One cell: `true` included, `false` not included, `{ limited }` included with a limit, or a
	 * string shown as written ("10 GB"). A plan with no value renders "Not listed".
	 */
	export type ComparisonValue = boolean | string | { limited: string };

	export interface ComparisonRow {
		feature: string;
		/** One short line under the feature name. Not a tooltip. */
		description?: string;
		/** Keyed by plan id; the column order always follows `plans`. */
		values: Record<string, ComparisonValue | null | undefined>;
	}

	export interface ComparisonGroup {
		/** Group heading. Hidden when there is only one group. */
		label: string;
		rows: ComparisonRow[];
	}

	/** Text equivalents for the marks, read by screen readers and shown where there is no mark. */
	export interface ComparisonLabels {
		included: string;
		excluded: string;
		limited: string;
		notListed: string;
		scrollHint: string;
	}
</script>

<script lang="ts">
	interface Props {
		plans: ComparisonPlan[];
		groups: ComparisonGroup[];
		title: string;
		description?: string;
		/** Short label set small and uppercase above the title, e.g. "Compare plans". */
		eyebrow?: string;
		headingLevel?: 2 | 3 | 4 | 5;
		/** Replace any of the mark labels, e.g. { included: 'Covered' }. */
		labels?: Partial<ComparisonLabels>;
		/** Keep the plan row in view while the page scrolls (from the lg breakpoint). */
		stickyHeader?: boolean;
		/** Text shown above the highlighted plan's name. */
		highlightLabel?: string;
		/** Table caption for assistive technology; defaults to the title. Visually hidden. */
		caption?: string;
		/** Notes set small under the table, in order. */
		footnotes?: string[];
	}

	let {
		plans,
		groups,
		title,
		description,
		eyebrow,
		headingLevel = 2,
		labels = {},
		stickyHeader = true,
		highlightLabel = 'Recommended',
		caption,
		footnotes = []
	}: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);

	/* Each label falls back on its own, so { included: undefined } never leaves a mark without text. */
	const text = $derived<ComparisonLabels>({
		included: labels.included ?? 'Included',
		excluded: labels.excluded ?? 'Not included',
		limited: labels.limited ?? 'Limited',
		notListed: labels.notListed ?? 'Not listed',
		scrollHint: labels.scrollHint ?? 'Scroll sideways to compare every plan'
	});

	const visibleGroups = $derived(groups.filter((group) => group.rows.length > 0));
	const showGroupLabels = $derived(visibleGroups.length > 1);
	const highlighted = $derived(plans.find((plan) => plan.highlighted));
	const anyHighlighted = $derived(highlighted !== undefined);
	const hasTable = $derived(plans.length > 0 && visibleGroups.length > 0);

	const lastGroup = $derived(visibleGroups.length - 1);

	/** Normalise a cell so the template reads one kind at a time. */
	function cell(row: ComparisonRow, plan: ComparisonPlan) {
		const value = Object.hasOwn(row.values, plan.id) ? row.values[plan.id] : undefined;
		if (value === true) return { kind: 'included' } as const;
		if (value === false) return { kind: 'excluded' } as const;
		if (typeof value === 'string') return { kind: 'text', value } as const;
		if (value && typeof value === 'object')
			return { kind: 'limited', value: value.limited } as const;
		return { kind: 'missing' } as const;
	}

	/*
	 * The strong move: the highlighted plan is one continuous raised column, from its header to its
	 * last row, instead of a badge on one cell. Every cell in that column carries the band; the first
	 * and last close it with the panel radius.
	 */
	function band(plan: ComparisonPlan, edge: 'top' | 'middle' | 'bottom') {
		if (!plan.highlighted) return '';
		return edge === 'top' ? 'band band-top' : edge === 'bottom' ? 'band band-bottom' : 'band';
	}
</script>

<section
	class="pricing-comparison-table 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 text-start">
			{#if eyebrow}
				<p
					class="tracked mb-2 text-xs/none font-semibold tracking-[0.06em] text-balance text-[var(--_muted)] uppercase"
				>
					{eyebrow}
				</p>
			{/if}
			<svelte:element
				this={heading}
				id="{uid}-title"
				class="tight text-3xl/tight font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)] sm:text-4xl/tight"
			>
				{title}
			</svelte:element>
			{#if description}
				<p
					class="mt-4 max-w-xl text-base/relaxed text-pretty text-[var(--_muted)] sm:text-lg/relaxed"
				>
					{description}
				</p>
			{/if}
		</div>

		{#if hasTable}
			<!--
				Shown only while the table actually overflows (see .hint), above it so it is read first. On a
				phone the highlighted column may start off screen, so the lead-in names it.
			-->
			<div class="hint mt-12 text-xs/5 lg:hidden" aria-hidden="true">
				{#if highlighted}
					<p class="font-medium break-words text-[var(--_ink)]">
						{`${highlightLabel}: ${highlighted.label}`}
					</p>
				{/if}
				<p class="flex items-center gap-2 text-[var(--_muted)]">
					{text.scrollHint}
					<svg class="arrow h-5 w-4 shrink-0" viewBox="0 0 16 16" fill="none">
						<path
							d="M3.5 8h9m-3.5-3.5L12.5 8 9 11.5"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
				</p>
			</div>
			<!-- A scrollable region must be reachable by keyboard (WCAG 2.1.1), so tabindex stays. -->
			<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
			<!-- Below lg the table scrolls inside this region; from lg it fits the container and the plan row can stick to the page. -->
			<div
				class="scroller relative mt-2 overflow-x-auto rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--_accent)] lg:mt-12 lg:overflow-visible"
				role="region"
				aria-labelledby="{uid}-caption"
				tabindex="0"
				style="--_plans: {plans.length}"
			>
				<table
					class="w-full min-w-[calc(8.5rem_+_var(--_plans)_*_9rem)] table-fixed border-separate border-spacing-0 text-start sm:min-w-[calc(12rem_+_var(--_plans)_*_10rem)] lg:min-w-0"
				>
					<caption id="{uid}-caption" class="sr-only">{caption ?? title}</caption>
					<colgroup>
						<col class="w-34 sm:w-48 lg:w-[30%]" />
						{#each plans as plan (plan.id)}
							<col />
						{/each}
					</colgroup>
					<!--
						The plan header is four rows (highlight label, name, price, link) so each part shares one
						line across columns however long a label or period runs. Only the name row sticks: a
						compact bar that keeps the columns named while the page scrolls.
					-->
					<thead>
						{#if anyHighlighted}
							<tr>
								<td class="corner bg-[var(--_surface)]"></td>
								{#each plans as plan (plan.id)}
									<td class={['px-4 pt-6 align-bottom', band(plan, 'top')]}>
										{#if plan.highlighted}
											<p
												class="tracked text-xs/5 font-semibold tracking-[0.02em] text-balance text-[var(--_accent)]"
												aria-hidden="true"
											>
												{highlightLabel}
											</p>
										{/if}
									</td>
								{/each}
							</tr>
						{/if}
						<tr>
							<td
								class={[
									'corner bg-[var(--_surface)]',
									stickyHeader && 'lg:sticky lg:top-[var(--_sticky-top)] lg:z-10'
								]}
							></td>
							{#each plans as plan, index (plan.id)}
								<th
									scope="col"
									class={[
										'bg-[var(--_surface)] px-4 pb-2 text-start align-bottom font-normal',
										anyHighlighted ? 'pt-2' : 'pt-6',
										stickyHeader && 'lg:sticky lg:top-[var(--_sticky-top)] lg:z-10',
										band(plan, anyHighlighted ? 'middle' : 'top')
									]}
								>
									<span
										id="{uid}-plan-{index}"
										class="tight block text-base/6 font-semibold tracking-[-0.011em] break-words text-[var(--_ink)]"
									>
										{plan.label}
									</span>
									{#if plan.highlighted}
										<span class="sr-only">{`, ${highlightLabel}`}</span>
									{/if}
								</th>
							{/each}
						</tr>
						<tr>
							<td class="corner bg-[var(--_surface)]"></td>
							{#each plans as plan (plan.id)}
								<td class={['px-4 pt-2 align-top', band(plan, 'middle')]}>
									{#if plan.price}
										<p
											class="tight text-3xl/none font-semibold tracking-[-0.02em] whitespace-nowrap text-[var(--_ink)] tabular-nums"
										>
											{plan.price}
										</p>
									{/if}
									{#if plan.period}
										<p
											class={[
												'text-sm/5 text-pretty break-words text-[var(--_muted)]',
												plan.price && 'mt-2'
											]}
										>
											{plan.period}
										</p>
									{/if}
								</td>
							{/each}
						</tr>
						<tr>
							<td class="corner border-b border-[var(--_hairline)] bg-[var(--_surface)]"></td>
							{#each plans as plan, index (plan.id)}
								<td
									class={[
										'border-b border-[var(--_hairline)] px-4 pt-6 pb-6 align-top',
										band(plan, 'middle')
									]}
								>
									{#if plan.cta}
										<a
											href={plan.cta.href}
											aria-describedby="{uid}-plan-{index}"
											class={[
												'cta flex min-h-11 w-full items-center justify-center rounded-lg px-2 py-2 text-center text-sm/5 font-medium break-words focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] lg:max-w-60',
												plan.highlighted
													? 'cta-primary bg-[var(--_accent)] text-[var(--_on-accent)]'
													: 'cta-secondary bg-[var(--_surface)] text-[var(--_ink)] ring-1 ring-[var(--_control-border)] ring-inset'
											]}
										>
											{plan.cta.label}
										</a>
									{/if}
								</td>
							{/each}
						</tr>
					</thead>
					{#each visibleGroups as group, groupIndex (groupIndex)}
						<tbody>
							{#if showGroupLabels}
								<tr>
									<th
										scope="rowgroup"
										class={[
											'sticky-col ps-0 pe-4 pb-4 text-start align-bottom text-sm/5 font-semibold break-words text-[var(--_ink)]',
											groupIndex === 0 ? 'pt-6' : 'pt-12'
										]}
									>
										{group.label}
									</th>
									{#each plans as plan (plan.id)}
										<td class={band(plan, 'middle')}></td>
									{/each}
								</tr>
							{/if}
							{#each group.rows as row, rowIndex (rowIndex)}
								{@const last = groupIndex === lastGroup && rowIndex === group.rows.length - 1}
								{@const ruled = showGroupLabels || groupIndex > 0 || rowIndex > 0}
								<tr>
									<th
										scope="row"
										class={[
											'sticky-col py-4 ps-0 pe-4 text-start align-top font-normal',
											ruled && 'border-t border-[var(--_hairline)]'
										]}
									>
										<span class="block text-sm/5 text-pretty break-words text-[var(--_ink)]">
											{row.feature}
										</span>
										{#if row.description}
											<span
												class="mt-1 block text-sm/5 text-pretty break-words text-[var(--_muted)]"
											>
												{row.description}
											</span>
										{/if}
									</th>
									{#each plans as plan (plan.id)}
										{@const value = cell(row, plan)}
										<td
											class={[
												'px-4 py-4 text-start align-top text-sm/5 break-words',
												ruled && 'border-t border-[var(--_hairline)]',
												band(plan, last ? 'bottom' : 'middle')
											]}
										>
											{#if value.kind === 'included'}
												<!-- Marks use a box that starts at the glyph, so they sit on the text edge in either direction. -->
												<svg
													class="mark h-5 w-3 text-[var(--_accent)]"
													viewBox="0.5 0 12 16"
													fill="none"
													aria-hidden="true"
												>
													<path
														d="M1.5 8.5l3 3 6-7"
														stroke="currentColor"
														stroke-width="1.75"
														stroke-linecap="round"
														stroke-linejoin="round"
													/>
												</svg>
												<span class="sr-only">{text.included}</span>
											{:else if value.kind === 'excluded'}
												<svg
													class="mark h-5 w-2 text-[var(--_muted)]"
													viewBox="0.5 0 8 16"
													fill="none"
													aria-hidden="true"
												>
													<path
														d="M1.5 8h6"
														stroke="currentColor"
														stroke-width="1.5"
														stroke-linecap="round"
													/>
												</svg>
												<span class="sr-only">{text.excluded}</span>
											{:else if value.kind === 'limited'}
												<span class="flex gap-2">
													<!-- Half-filled ring: included, with the limit written beside it. -->
													<svg
														class="mark h-5 w-3 shrink-0 text-[var(--_accent)]"
														viewBox="0.5 0 12 16"
														fill="none"
														aria-hidden="true"
													>
														<circle
															cx="6.25"
															cy="8"
															r="4.75"
															stroke="currentColor"
															stroke-width="1.5"
														/>
														<path d="M6.25 3.25a4.75 4.75 0 0 1 0 9.5z" fill="currentColor" />
													</svg>
													<span class="min-w-0 text-pretty text-[var(--_ink)]">
														<span class="sr-only">{`${text.limited}: `}</span>{value.value}
													</span>
												</span>
											{:else if value.kind === 'text'}
												<span class="text-pretty text-[var(--_ink)] tabular-nums"
													>{value.value}</span
												>
											{:else}
												<span class="text-[var(--_muted)]">{text.notListed}</span>
											{/if}
										</td>
									{/each}
								</tr>
							{/each}
						</tbody>
					{/each}
				</table>
			</div>
		{/if}

		{#if hasTable && footnotes.length === 1}
			<p class="mt-6 max-w-[30em] text-sm/5 text-pretty text-[var(--_muted)]">{footnotes[0]}</p>
		{:else if hasTable && footnotes.length > 1}
			<ol class="mt-6 max-w-[30em] list-decimal ps-4 text-sm/5 text-pretty text-[var(--_muted)]">
				{#each footnotes as note, index (index)}
					<li class="ps-1">{note}</li>
				{/each}
			</ol>
		{/if}
	</div>
</section>

<style>
	/* Public tokens: set --pricing-comparison-table-* on this section or any ancestor to retone it. */
	.pricing-comparison-table {
		--_accent: var(--pricing-comparison-table-accent, #1d4ed8);
		--_on-accent: var(--pricing-comparison-table-on-accent, #ffffff);
		--_ink: var(--pricing-comparison-table-ink, #18181b);
		--_muted: var(--pricing-comparison-table-muted, #52525b);
		--_hairline: var(--pricing-comparison-table-hairline, #e4e4e7);
		--_control-border: var(--pricing-comparison-table-control-border, #d4d4d8);
		--_surface: var(--pricing-comparison-table-surface, #ffffff);
		--_band: var(--pricing-comparison-table-band, #fafafa);
		--_sticky-top: var(--pricing-comparison-table-sticky-top, 0px);
		timeline-scope: --pct-scroll;
	}

	/* The highlight band: a raised fill with a hairline down both sides, closed at top and bottom. */
	.band {
		background-color: var(--_band);
		box-shadow:
			inset 1px 0 0 var(--_hairline),
			inset -1px 0 0 var(--_hairline);
	}
	.band-top {
		border-start-start-radius: 0.75rem;
		border-start-end-radius: 0.75rem;
		box-shadow:
			inset 1px 0 0 var(--_hairline),
			inset -1px 0 0 var(--_hairline),
			inset 0 1px 0 var(--_hairline);
	}
	.band-bottom {
		border-end-start-radius: 0.75rem;
		border-end-end-radius: 0.75rem;
		box-shadow:
			inset 1px 0 0 var(--_hairline),
			inset -1px 0 0 var(--_hairline),
			inset 0 -1px 0 var(--_hairline);
	}

	/*
	 * Below lg the feature column stays put while the plans scroll under it. Its edge hairline
	 * appears only once something has scrolled beneath it, where scroll-driven animation exists.
	 */
	/* Inline-size containment and position: relative keep the wide table, and the visually hidden labels inside it, from widening the page. */
	.scroller {
		contain: inline-size;
		scroll-timeline: --pct-scroll inline;
		overscroll-behavior-x: contain;
	}
	@media (width < 64rem) {
		.sticky-col,
		.corner {
			position: sticky;
			inset-inline-start: 0;
			z-index: 1;
			background-color: var(--_surface);
		}
		.corner {
			z-index: 2;
		}
	}
	/* Without scroll-driven animation the edge is always drawn, so the held column still reads as held. */
	@supports not (animation-timeline: scroll()) {
		@media (width < 64rem) {
			.sticky-col,
			.corner {
				box-shadow: inset -1px 0 0 var(--_hairline);
			}
			.sticky-col:dir(rtl),
			.corner:dir(rtl) {
				box-shadow: inset 1px 0 0 var(--_hairline);
			}
		}
	}
	@supports (animation-timeline: scroll()) {
		@media (width < 64rem) {
			.sticky-col,
			.corner {
				animation: edge linear both;
				animation-timeline: --pct-scroll;
				animation-range: 0 1rem;
			}
			.sticky-col:dir(rtl),
			.corner:dir(rtl) {
				animation-name: edge-rtl;
			}
			/* The hint shows only while the table actually scrolls, and fades as the end arrives. */
			.hint {
				opacity: 0;
				animation: hint linear both;
				animation-timeline: --pct-scroll;
			}
		}
	}
	@keyframes edge {
		from {
			box-shadow: inset -1px 0 0 transparent;
		}
		to {
			box-shadow: inset -1px 0 0 var(--_hairline);
		}
	}
	@keyframes edge-rtl {
		from {
			box-shadow: inset 1px 0 0 transparent;
		}
		to {
			box-shadow: inset 1px 0 0 var(--_hairline);
		}
	}
	@keyframes hint {
		0%,
		85% {
			opacity: 1;
		}
		100% {
			opacity: 0;
		}
	}
	.arrow:dir(rtl) {
		transform: scaleX(-1);
	}

	.cta {
		transition-property: color, background-color, box-shadow, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	.cta:active {
		transform: translateY(1px);
		transition-duration: 80ms;
	}
	/* Hover shifts the tone one step instead of fading the link. */
	.cta-primary:hover {
		background-color: color-mix(in oklab, var(--_accent) 86%, var(--_on-accent));
	}
	.cta-secondary:hover {
		background-color: color-mix(in oklab, var(--_ink) 5%, var(--_surface));
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.pricing-comparison-table :is(.tight, .tracked):dir(rtl) {
		letter-spacing: 0;
	}
	@media (prefers-reduced-motion: reduce) {
		.cta {
			transition-property: color, background-color, box-shadow;
		}
		.cta:active {
			transform: none;
		}
	}
</style>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249`
- Entry: `PricingComparisonTable.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_pricing_comparison_table_01/1.0.0/blue/sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_pricing_comparison_table_01/1.0.0/blue/sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249/bundle.zip (9384 bytes, sha256 `c9e3e4cbe3659a9d18d460093187a8819cbc86546341204314a460d5e255ef48`)

Files:

- `PricingComparisonTable.svelte` (entry, 19011 bytes): https://pagesugar.com/artifacts/cmp_pricing_comparison_table_01/1.0.0/blue/sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249/source/PricingComparisonTable.svelte
