Simple pricing grid
Compare up to three plans a row and lift the recommended one. Prices, feature lists and buttons line up across cards; each button links to your signup, checkout or contact page.
cmp_pricing_grid_001 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_pricing_grid_001 version 1.1.2 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.1.2
- Digest
Full digest
sha256-0e78b00483eed7aafd03831830355b6b540cad1bb30abf6e411068ec076b1d73
<script module lang="ts">
export interface PricingPlan {
/** Plan name shown as the card heading. */
label: string;
/** Display-only price string, e.g. "$12" or "Custom". Never parsed or charged. */
price: string;
/** Billing period shown after the price, e.g. "per seat per month". */
period?: string;
/** One line on who the plan is for. */
description?: string;
/** Included features; an empty list renders no list. */
features: string[];
cta: { label: string; href: string };
/** Lift this plan with the accent hairline, the featured shadow and a filled button. */
recommended?: boolean;
}
</script>
<script lang="ts">
interface Props {
plans: PricingPlan[];
title: string;
description?: string;
/** Short label set small and uppercase above the title, e.g. "Pricing". */
eyebrow?: string;
headingLevel?: 2 | 3 | 4 | 5;
recommendedLabel?: string;
}
let {
plans,
title,
description,
eyebrow,
headingLevel = 2,
recommendedLabel = 'Recommended'
}: Props = $props();
const uid = $props.id();
const sectionHeading = $derived(`h${headingLevel}`);
const planHeading = $derived(`h${headingLevel + 1}`);
/*
* At most three plans per row. Two or four plans sit in two columns so no row is left with a
* single orphan; one plan keeps a single card's width. Three plans stack below lg rather than
* leave one card alone on a tablet row; five or more pair up from sm, then run three a row.
*/
const columns = $derived(
plans.length === 1
? 'max-w-md'
: plans.length === 2 || plans.length === 4
? 'max-w-md sm:max-w-4xl sm:grid-cols-2'
: plans.length === 3
? 'max-w-md lg:max-w-none lg:grid-cols-3'
: 'max-w-md sm:max-w-4xl sm:grid-cols-2 lg:max-w-none lg:grid-cols-3'
);
</script>
<section
class="pricing-grid 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="mx-auto max-w-2xl text-center">
{#if eyebrow}
<p
class="pricing-grid__tracked mb-2 text-xs/none font-semibold tracking-[0.06em] text-balance text-(--_muted) uppercase"
>
{eyebrow}
</p>
{/if}
<svelte:element
this={sectionHeading}
id="{uid}-title"
class="pricing-grid__tight text-3xl/tight font-semibold tracking-[-0.02em] text-balance break-words text-(--_ink) sm:text-4xl/tight"
>
{title}
</svelte:element>
{#if description}
<p
class="mx-auto mt-4 max-w-xl text-base/relaxed text-pretty text-(--_muted) sm:text-lg/relaxed"
>
{description}
</p>
{/if}
</div>
{#if plans.length > 0}
<ul class={['mx-auto mt-12 grid grid-cols-1 gap-6 sm:mt-16', columns]} role="list">
{#each plans as plan, index (index)}
<!-- Four subgrid rows (header, price, features, action) so prices and buttons line up across a row of cards. -->
<li
class={[
'pricing-grid__plan row-span-4 grid min-w-0 grid-rows-subgrid gap-y-0 rounded-2xl bg-(--_surface) p-6 text-start sm:p-8',
plan.recommended
? 'pricing-grid__plan--featured shadow-(--_shadow-featured)'
: 'ring-1 ring-(--_hairline)'
]}
>
<div class="min-w-0">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<svelte:element
this={planHeading}
id="{uid}-plan-{index}"
class="pricing-grid__tight min-w-0 text-lg/snug font-semibold tracking-[-0.015em] break-words text-(--_ink)"
>
{plan.label}
</svelte:element>
{#if plan.recommended}
<p
class="rounded-full px-2 py-1 text-xs/4 font-medium break-words text-(--_ink) ring-1 ring-(--_hairline) ring-inset"
>
{recommendedLabel}
</p>
{/if}
</div>
{#if plan.description}
<p class="mt-2 text-sm/relaxed text-pretty break-words text-(--_muted)">
{plan.description}
</p>
{/if}
</div>
<p class="mt-6 flex min-w-0 flex-wrap content-start items-baseline gap-x-2 gap-y-1">
<span
class="pricing-grid__tight text-5xl/none font-semibold tracking-[-0.03em] break-words text-(--_ink) tabular-nums"
>{plan.price}</span
>
{#if plan.period}
<span class="min-w-0 text-sm/snug text-pretty break-words text-(--_muted)"
>{plan.period}</span
>
{/if}
</p>
<div class="min-w-0">
{#if plan.features.length > 0}
<ul class="mt-8 space-y-3 text-sm/relaxed text-(--_muted)" role="list">
{#each plan.features as feature, featureIndex (featureIndex)}
<li class="flex gap-3">
<svg
class="h-lh w-4 shrink-0 text-(--_accent)"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M3.5 8.5l3 3 6-7"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="min-w-0 break-words">{feature}</span>
</li>
{/each}
</ul>
{/if}
</div>
<a
href={plan.cta.href}
aria-describedby="{uid}-plan-{index}"
class={[
'pricing-grid__cta mt-8 flex min-h-11 items-center justify-center self-end rounded-lg px-4 py-2 text-center text-sm/5 font-medium break-words focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent)',
'transition-[color,background-color,box-shadow,translate] duration-150 ease-[cubic-bezier(0.2,0,0,1)] active:translate-y-px active:duration-80 motion-reduce:transition-[color,background-color,box-shadow] motion-reduce:active:translate-y-0',
plan.recommended
? 'pricing-grid__cta--primary bg-(--_accent) text-(--_on-accent) hover:bg-(--_primary-hover)'
: 'pricing-grid__cta--secondary text-(--_ink) ring-1 ring-(--_control-border) ring-inset hover:bg-(--_secondary-hover)'
]}
>
{plan.cta.label}
</a>
</li>
{/each}
</ul>
{/if}
</div>
</section>
<style>
.pricing-grid {
--_accent: var(--pricing-grid-accent, #18181b);
--_on-accent: var(--pricing-grid-on-accent, #ffffff);
--_ink: var(--pricing-grid-ink, #18181b);
--_muted: var(--pricing-grid-muted, #52525b);
--_hairline: var(--pricing-grid-hairline, #e4e4e7);
--_control-border: var(--pricing-grid-control-border, #d4d4d8);
--_surface: var(--pricing-grid-surface, #ffffff);
/*
* Formulas, read by utilities in the markup. The one elevation: the recommended plan, lit
* from above. Its 1px accent hairline is the first layer of the same shadow, so the
* elevation never paints over the boundary. Button hover shifts the tone one step towards
* the text colour instead of fading the button.
*/
--_shadow-featured:
0 0 0 1px var(--_accent), 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 40px rgb(0 0 0 / 0.08);
--_primary-hover: color-mix(in oklab, var(--_accent) 86%, var(--_on-accent));
--_secondary-hover: color-mix(in oklab, var(--_ink) 5%, transparent);
}
/*
* :lang() has no utility. Arabic-script text is cursive; negative tracking pulls its joins
* apart, so it sets at 0.
*/
.pricing-grid:is(:lang(ar), :lang(fa), :lang(ur))
:is(.pricing-grid__tight, .pricing-grid__tracked) {
letter-spacing: 0;
}
</style>
Usage#
On this pagePresentational only: every label, price and feature is displayed exactly as supplied by your data, and each plan button is an ordinary link. No additional runtime packages beyond Svelte and Tailwind CSS and no backend. Point each link at your own checkout, signup or contact flow.
- Suggested location
src/lib/components/pricing-grid-01
Limitations
- Does not create subscriptions, calculate totals, apply tax or switch currencies; prices are display strings.
- No billing-period toggle; render a second grid or edit the source if you need one.
- At most three plans a row. Four plans are laid out two by two; three stack below the lg breakpoint; with five, the last row holds two cards aligned to the start.
- With an empty plans array the heading and description still render and the plan list is omitted; render your own message or hide the section if that is not what you want.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.
Example
<!-- Illustrative content: replace example claims, prices and links before publishing. -->
<script lang="ts">
import PricingGrid, { type PricingPlan } from '$lib/components/pricing-grid-01/PricingGrid.svelte';
const plans: PricingPlan[] = [
{
label: 'Starter',
price: '$9',
period: 'per month',
features: ['One project', 'Email support'],
cta: { label: 'Choose Starter', href: '/signup?plan=starter' }
},
{
label: 'Team',
price: '$29',
period: 'per month',
features: ['Unlimited projects', 'Shared workspaces'],
cta: { label: 'Choose Team', href: '/signup?plan=team' },
recommended: true
}
];
</script>
<PricingGrid eyebrow="Pricing" title="Plans and pricing" {plans} />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.
Connecting the plan links#
Copy PricingGrid.svelte into src/lib/components/pricing-grid-01/. Replace the example plans, prices, features and destinations with your real offer. Each cta.href is an ordinary link; the grid does not create a subscription or process a payment.
PricingPlan is exported from the component’s module script. Its shape is { label, price, period?, description?, features, cta: { label, href }, recommended? }.
Layout#
The grid never holds more than three plans a row:
| Plans | Layout |
|---|---|
| 0 | The heading and description render; the plan list is omitted. |
| 1 | One card, max-w-md, centred. |
| 2 or 4 | Two columns from sm, so four plans sit two by two rather than three and one. |
| 3 | Three columns from lg; stacked below it, so no tablet row holds one card. |
| 5, 6, … | Two columns from sm, three from lg. Five plans leave two on the last row. |
Below those breakpoints the cards stack at max-w-md. Each card is a four-row subgrid (header, price, features, button), so across one row of cards the prices start at the same height and the buttons share a baseline, even when descriptions or feature lists differ in length.
Retoning#
Seven variables, set on any ancestor, cover every colour the component draws:
| Variable | Default | Draws |
|---|---|---|
--pricing-grid-accent |
#18181b |
Recommended button, recommended hairline, checks, focus |
--pricing-grid-on-accent |
#ffffff |
Text on the recommended button |
--pricing-grid-ink |
#18181b |
Title, plan names, prices, badge text |
--pricing-grid-muted |
#52525b |
Description, periods, features, eyebrow |
--pricing-grid-hairline |
#e4e4e7 |
Card and badge outlines |
--pricing-grid-control-border |
#d4d4d8 |
Secondary button outline |
--pricing-grid-surface |
#ffffff |
Card fill |
A brand accent only needs the first two:
<div style="--pricing-grid-accent: #1d4ed8; --pricing-grid-on-accent: #ffffff;">
<PricingGrid title="Plans and pricing" {plans} />
</div>A worked retone for a dark page:
<div
class="bg-zinc-950"
style="
--pricing-grid-surface: #18181b;
--pricing-grid-ink: #fafafa;
--pricing-grid-muted: #a1a1aa;
--pricing-grid-hairline: rgb(255 255 255 / 0.1);
--pricing-grid-control-border: rgb(255 255 255 / 0.2);
--pricing-grid-accent: #fafafa;
--pricing-grid-on-accent: #18181b;
"
>
<PricingGrid title="Plans and pricing" {plans} />
</div>The featured shadow is black, so on a dark page it disappears and the accent hairline carries the recommended plan on its own. That is intended; add an inset top highlight to --_shadow-featured if you want more.
White on-accent text measures about 17.7:1 on the neutral accent (#18181b) and 6.7:1 on the blue accent (#1d4ed8), using the WCAG relative-luminance formula. These figures describe those pairs only. Re-check contrast after any colour changes.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
plans | PricingPlan[] | Yes | None | Plans in display order. Each has label, price (display string), optional period and description, features (string[], may be empty), cta { label, href } and an optional recommended flag. |
title | string | Yes | None | Section heading text. |
description | string | No | None | Optional introductory paragraph under the heading. |
eyebrow | string | No | None | Optional short label set small and uppercase above the heading, such as "Pricing". |
headingLevel | 2 | 3 | 4 | 5 | No | 2 | Level of the section heading; plan names use the next level down. |
recommendedLabel | string | No | 'Recommended' | Badge text shown on plans with recommended: true. |
Customization#
On this pageChange content through props, retone the section through seven CSS variables (accent, on-accent, ink, muted, hairline, control border and card surface), and edit Tailwind classes in the source for spacing, radius or layout.
- Accent: set
--pricing-grid-accentand--pricing-grid-on-accenttogether. The accent fills the recommended button and draws the recommended card's hairline, the check marks and the focus ring; keep the on-accent text at 4.5:1 or better against it. The button's hover shade is mixed from the pair, so it follows. - Text:
--pricing-grid-inksets the title, plan names and prices;--pricing-grid-mutedsets the description, periods and features. Keep muted at 4.5:1 against the card surface. - Boundaries:
--pricing-grid-hairlineoutlines ordinary cards and the badge;--pricing-grid-control-borderoutlines the secondary buttons. - Surface:
--pricing-grid-surfaceis the card fill. The section itself has no background; set one on its parent. - Dark page retone: surface
#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(all--pricing-grid-*). The black featured shadow disappears on dark; the accent hairline carries the emphasis.usage.mdhas the snippet. - Links: replace each
cta.hrefwith your real checkout, signup or contact route and makecta.labelspecific (e.g. "Choose Team"). - Headings: set
headingLevelso the section heading fits the page outline; plan names automatically use the next level. - Layout: the columns value at the top of the script decides the grid: one plan keeps
max-w-md; two or four use two columns from sm; three use three columns from lg; five or more use two from sm and three from lg. Change those classes to alter the column count. - Recommended plan: mark at most one plan recommended so the emphasis stays meaningful.
Public CSS variables
| Variable | Token |
|---|---|
--pricing-grid-accent | accent |
--pricing-grid-on-accent | onAccent |
--pricing-grid-ink | ink |
--pricing-grid-muted | muted |
--pricing-grid-hairline | hairline |
--pricing-grid-control-border | controlBorder |
--pricing-grid-surface | surface |
Accessibility#
On this page- Plans are a semantic list; each plan name is a heading one level below the section heading, which is set with
headingLevel. - Each plan link is described by its plan heading via
aria-describedby, so repeated labels such as "Get started" remain distinguishable. - The recommended plan is identified by visible badge text, not by colour or elevation alone.
- White (
#ffffff) on-accent text measures about 17.7:1 against the neutral accent (#18181b) and about 6.7:1 against the blue accent (#1d4ed8); the hover shades stay above 11:1 and 4.8:1. Muted text (#52525b) measures 7.7:1 on white. Ratios use the WCAG relative-luminance formula. - Links show a two-pixel focus outline in the accent colour, offset by two pixels, on :focus-visible only. The pressed state moves the button one pixel and is removed under prefers-reduced-motion.
- Buttons are at least 44 px tall at every width, so they clear the coarse-pointer target size.
- Checkmark icons are decorative and hidden from assistive technology.
- The layout uses logical properties and text-start, so it mirrors under dir="rtl"; negative tracking is removed for Arabic, Persian and Urdu.
- Element IDs come from
$props.id(), so multiple grids on one page stay unique and hydrate deterministically.
Known limitations
- Contrast ratios are computed for the shipped palettes only; any changed token must be re-checked (4.5:1 for text, 3:1 for the focus ring against the surface).
- The secondary button's outline (
#d4d4d8) is a decorative boundary under 3:1; the button is identified by its label text.
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.1.2 (Published) Current release · 16 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.