Skip to content
Download ZIP

Blue accent palette · 9.2 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_pricing_comparison_table_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_pricing_comparison_table_01 version 1.0.0 with variant "blue", 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
Blue accent
Version
1.0.0
Digest
Full digest
sha256-071876dd9092d1982db966bdc3db9ba4d831dc9034b8dad9f3c1c086b7c0c249
PricingComparisonTable.svelte Svelte · 18.6 KB Raw
<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>

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.

Suggested location
src/lib/components/pricing-comparison-table-01
Required props
plansgroupstitle

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.

Example

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

Data shape#

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

TypeScript
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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
plansComparisonPlan[]YesNonePlan 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.
groupsComparisonGroup[]YesNoneFeature 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.
titlestringYesNoneSection heading text.
descriptionstringNoNoneOptional introductory paragraph under the heading.
eyebrowstringNoNoneOptional short label set small and uppercase above the heading.
headingLevel2 | 3 | 4 | 5No2Level of the section heading.
labelsPartial<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.
stickyHeaderbooleanNotrueKeep the plan-name row in view while the page scrolls past the table, from the lg breakpoint. Prices and links scroll with the table.
highlightLabelstringNo'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.
captionstringNoNoneVisually hidden table caption, which also names the scroll region. Defaults to the title.
footnotesstring[]No[]Notes set small under the table: one note as a paragraph, several as a numbered list.

Customization#

On this page

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.

Public CSS variables

VariableToken
--pricing-comparison-table-accentaccent
--pricing-comparison-table-on-accentonAccent
--pricing-comparison-table-inkink
--pricing-comparison-table-mutedmuted
--pricing-comparison-table-hairlinehairline
--pricing-comparison-table-control-bordercontrolBorder
--pricing-comparison-table-surfacesurface
--pricing-comparison-table-bandband
--pricing-comparison-table-sticky-topstickyTop

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

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