Commerce
Pricing table
Sets two to four subscription plans side by side, or in a comparison table, with a monthly or yearly toggle.
- Built on
CSS container queriesCSS subgridposition: sticky<table>Intl.NumberFormat- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.7 kB
Starter
Team
Try its options
Loading its options.
Code
import { PricingTable } from "@/components/equip/pricing-table";
export default function Demo() {
return (
<PricingTable
size="s"
defaultBilling="yearly"
style={{ width: 224 }}
plans={[
{ id: "starter", name: "Starter", price: { monthly: 10, yearly: 96 }, features: [], cta: { label: "Start" } },
{
id: "team",
name: "Team",
price: { monthly: 25, yearly: 240 },
features: [],
cta: { label: "Start trial" },
highlighted: true,
},
]}
/>
);
}Your versionchange its options in Look, This part
Examples
Three plans with a yearly toggle
Yearly prices show as the monthly equivalent, with the yearly total on the line beneath.
Free
For trying things out on a side project.
- Included, Projects3
- Included, File storage2 GB
- Not included, Guest access
- Not included, Audit log
Team
Most popularFor small teams shipping together every week.
- Included, ProjectsUnlimited
- Included, File storage100 GB
- Included, Guest access
- Not included, Audit log
Business
For companies that need controls and reporting.
- Included, ProjectsUnlimited
- Included, File storage1 TB
- Included, Guest access
- Included, Audit log
Code
import { PricingTable } from "@/components/equip/pricing-table";
export default function Example() {
return (
<PricingTable
defaultBilling="yearly"
yearlyNote="Save 20%"
plans={[
{
id: "free",
name: "Free",
description: "For trying things out on a side project.",
price: { monthly: 0, yearly: 0 },
priceNote: "Free for up to 3 people",
features: [
{ label: "Projects", included: "3" },
{ label: "File storage", included: "2 GB" },
{ label: "Guest access", included: false },
{ label: "Audit log", included: false },
],
cta: { label: "Get started", href: "#signup" },
},
{
id: "team",
name: "Team",
description: "For small teams shipping together every week.",
price: { monthly: 15, yearly: 144 },
per: "seat",
features: [
{ label: "Projects", included: "Unlimited" },
{ label: "File storage", included: "100 GB" },
{ label: "Guest access", included: true },
{ label: "Audit log", included: false },
],
cta: { label: "Start free trial", href: "#trial" },
highlighted: true,
highlightLabel: "Most popular",
},
{
id: "business",
name: "Business",
description: "For companies that need controls and reporting.",
price: { monthly: 30, yearly: 288 },
per: "seat",
features: [
{ label: "Projects", included: "Unlimited" },
{ label: "File storage", included: "1 TB" },
{ label: "Guest access", included: true },
{ label: "Audit log", included: true },
],
cta: { label: "Talk to sales", href: "#sales" },
},
]}
/>
);
}Four plans in euros, with custom pricing
Labels take any language; Enterprise has no list price, so it shows a word instead of a figure.
Basis
- Enthalten, Nutzer1
- Enthalten, Rechnungen pro Monat20
- Nicht enthalten, DATEV-Export
Plus
Beliebt- Enthalten, Nutzer3
- Enthalten, Rechnungen pro Monat200
- Enthalten, DATEV-Export
Pro
- Enthalten, Nutzer10
- Enthalten, Rechnungen pro MonatUnbegrenzt
- Enthalten, DATEV-Export
Enterprise
- Enthalten, NutzerUnbegrenzt
- Enthalten, Rechnungen pro MonatUnbegrenzt
- Enthalten, DATEV-Export
Code
import { PricingTable } from "@/components/equip/pricing-table";
export default function Example() {
return (
<PricingTable
currency="EUR"
locale="de-DE"
yearlyNote="20 % sparen"
labels={{
billing: "Abrechnungszeitraum",
monthly: "Monatlich",
yearly: "Jährlich",
month: "Monat",
billedMonthly: "Monatliche Abrechnung",
billedYearly: "Jährliche Abrechnung",
per: "pro",
custom: "Individuell",
included: "Enthalten",
notIncluded: "Nicht enthalten",
}}
plans={[
{
id: "basis",
name: "Basis",
price: { monthly: 10, yearly: 96 },
features: [
{ label: "Nutzer", included: "1" },
{ label: "Rechnungen pro Monat", included: "20" },
{ label: "DATEV-Export", included: false },
],
cta: { label: "Auswählen" },
},
{
id: "plus",
name: "Plus",
price: { monthly: 20, yearly: 192 },
features: [
{ label: "Nutzer", included: "3" },
{ label: "Rechnungen pro Monat", included: "200" },
{ label: "DATEV-Export", included: true },
],
cta: { label: "Auswählen" },
highlighted: true,
highlightLabel: "Beliebt",
},
{
id: "pro",
name: "Pro",
price: { monthly: 40, yearly: 384 },
features: [
{ label: "Nutzer", included: "10" },
{ label: "Rechnungen pro Monat", included: "Unbegrenzt" },
{ label: "DATEV-Export", included: true },
],
cta: { label: "Auswählen" },
},
{
id: "enterprise",
name: "Enterprise",
price: null,
priceNote: "Ab 50 Nutzern",
features: [
{ label: "Nutzer", included: "Unbegrenzt" },
{ label: "Rechnungen pro Monat", included: "Unbegrenzt" },
{ label: "DATEV-Export", included: true },
],
cta: { label: "Vertrieb kontaktieren", href: "#vertrieb" },
},
]}
/>
);
}Comparison matrix with grouped rows
Plan headers stick while the rows scroll; when the columns would get too narrow the table shows one plan at a time, chosen from the select.
Code
import { PricingTable } from "@/components/equip/pricing-table";
export default function Example() {
return (
<PricingTable
variant="matrix"
defaultBilling="monthly"
yearlyNote="Save 20%"
plans={[
{ id: "free", name: "Free", price: { monthly: 0, yearly: 0 }, features: [], cta: { label: "Get started", href: "#signup" } },
{
id: "team",
name: "Team",
price: { monthly: 15, yearly: 144 },
per: "seat",
features: [],
cta: { label: "Start trial", href: "#trial" },
highlighted: true,
highlightLabel: "Most popular",
},
{
id: "business",
name: "Business",
price: { monthly: 30, yearly: 288 },
per: "seat",
features: [],
cta: { label: "Contact sales", href: "#sales" },
},
]}
groups={[
{
label: "Collaboration",
rows: [
{ label: "Members", values: { free: "3", team: "Unlimited", business: "Unlimited" } },
{ label: "Guest access", values: { free: false, team: true, business: true } },
{ label: "Shared templates", values: { free: false, team: true, business: true } },
],
},
{
label: "Security",
rows: [
{ label: "Two-factor sign-in", values: { free: true, team: true, business: true } },
{ label: "Single sign-on", values: { free: false, team: false, business: true } },
{ label: "Audit log retention", values: { free: false, team: "30 days", business: "1 year" } },
],
},
{
label: "Support",
rows: [
{ label: "Help centre and community", values: { free: true, team: true, business: true } },
{ label: "Email support", values: { free: false, team: "Next working day", business: "4 hours" } },
{ label: "Named account manager", values: { free: false, team: false, business: true } },
],
},
]}
/>
);
}Long button labels
A label too long for the card wraps onto a second line and the button grows with it; one line keeps the usual height.
Code
import { PricingTable } from "@/components/equip/pricing-table";
export default function Example() {
return (
<PricingTable
style={{ maxWidth: 520 }}
plans={[
{
id: "clinic",
name: "Clinic",
description: "For a single practice with up to five clinicians.",
price: { monthly: 49, yearly: 470 },
features: [
{ label: "Appointment reminders", included: true },
{ label: "Patient records", included: "5,000" },
],
cta: { label: "Book a 20 minute walkthrough with our onboarding team", href: "#walkthrough" },
},
{
id: "group",
name: "Group",
description: "For practices across several sites.",
price: { monthly: 129, yearly: 1240 },
features: [
{ label: "Appointment reminders", included: true },
{ label: "Patient records", included: "Unlimited" },
],
cta: { label: "Start a 30 day trial for every site in your group", href: "#trial" },
highlighted: true,
highlightLabel: "Most chosen",
},
]}
/>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
plans | { id: string; name: string; description?: string; price: { monthly: number; yearly: number } | null; per?: string; priceNote?: string; features: { label: string; included: boolean | string }[]; cta: { label: string; href?: string }; highlighted?: boolean; highlightLabel?: string }[] | none | The plans, in order. A null price shows labels.custom; a string feature value counts as included and is shown. |
billing | "monthly" | "yearly" | none | Controlled billing period. Pair it with onBillingChange. |
defaultBilling | "monthly" | "yearly" | "monthly" | Billing period on first render, when uncontrolled. |
onBillingChange | (billing: "monthly" | "yearly") => void | none | Called when the toggle changes. |
yearlyNote | string | none | Short text beside the toggle, such as "Save 20%". |
currency | string | "USD" | ISO 4217 currency code. |
locale | string | "en-US" | Formats every amount. |
variant | "cards" | "matrix" | "cards" | Plan cards side by side, or one comparison table. |
groups | { label: string; rows: { label: string; values: Record<string, boolean | string> }[] }[] | none | Matrix rows in labelled groups, keyed by plan id. Without it the matrix lists every plan feature. |
size | "s" | "m" | "m" | s is a compact board of names and prices, without descriptions, features or buttons. |
headingLevel | 2 | 3 | 4 | 3 | Heading level of each plan name in the cards. |
onPlanSelect | (planId: string) => void | none | Called when a CTA that has no href is pressed. |
labels | Partial<{ billing; monthly; yearly; month; billedMonthly; billedYearly; per; custom; included; notIncluded; feature; showPlan }> | none | Every word the part shows or reads out, for other languages. All strings. |
...rest | ComponentProps<"div"> | none | Every native div attribute. Set --_sticky-top in style to clear a fixed page header. |
Anatomy
| root | .eq-pricing-table | The container-query box. Variant, size and plan count are data attributes. |
|---|---|---|
| billing | .eq-pricing-table-billing | The segmented control and the yearly note. |
| note | .eq-pricing-table-note | A Badge beside the toggle. |
| plans | .eq-pricing-table-plans | The grid the cards share. |
| plan | .eq-pricing-table-plan | An <article> named by its heading; a subgrid of five rows. |
| header | .eq-pricing-table-header | |
| name | .eq-pricing-table-name | A heading in cards, plain text in the matrix header. |
| badge | .eq-pricing-table-badge | The highlight label, a solid Badge. |
| description | .eq-pricing-table-description | |
| price | .eq-pricing-table-price | Wraps Price, or the custom label, and the billed line. |
| custom | .eq-pricing-table-custom | Shown in place of a figure when price is null. |
| billed | .eq-pricing-table-billed | "Billed yearly, $144 per seat" or the plan's priceNote. |
| cta | .eq-pricing-table-cta | A Button; a Button with href, a real link, when cta.href is set. |
| features | .eq-pricing-table-features | |
| feature | .eq-pricing-table-feature | data-included when the plan has it. |
| feature label | .eq-pricing-table-feature-label | |
| mark | .eq-pricing-table-mark | A check or a muted dash Icon, with hidden words beside it. |
| value | .eq-pricing-table-value | A feature value such as "10 GB". |
| hidden text | .eq-pricing-table-hidden | "Included" or "Not included" for screen readers. |
| bar | .eq-pricing-table-bar | Matrix only: the toggle and the narrow plan chooser. |
| pick | .eq-pricing-table-pick | A Select shown only when the matrix is too narrow for every column. |
| matrix | .eq-pricing-table-matrix | The comparison <table>. |
| corner | .eq-pricing-table-corner | |
| head | .eq-pricing-table-head | A sticky plan column header. |
| group | .eq-pricing-table-group | A <tbody> per group. |
| group label | .eq-pricing-table-group-label | A th with scope rowgroup. |
| row | .eq-pricing-table-row | |
| row label | .eq-pricing-table-row-label | A th with scope row. |
| cell | .eq-pricing-table-cell |
Keyboard
| Tab | Moves to the billing toggle, then through each plan's button or link. |
|---|---|
| Arrow keys | Switch between monthly and yearly inside the toggle. |
| Arrow keys | In a narrow matrix, choose which plan column the select shows. |
Notes
- Each plan is an <article> labelled by its heading, so screen reader users can jump between plans.
- Included and excluded features differ by icon and by hidden words, never by colour alone.
- Yearly prices show as the monthly equivalent; choose yearly totals that divide by twelve so every column keeps whole amounts.
- Only the highlighted plan's CTA uses the accent; the others are outline buttons.
- Sticky headers need no scrolling box between the table and the page, so the matrix never scrolls sideways; when the container is too narrow for every column (28, 36 or 48rem for two, three or four plans) it shows one plan at a time, chosen from a select.
- Sticky headers sit on --eq-surface, so on a page with a different background the header band shows.
- The billing toggle is a native radio group, so it prerenders and works with arrow keys.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/pricing-table.tsx387 lines
"use client";
import { useId, useState, type ComponentProps } from "react";
import { Badge } from "./badge";
import { Button } from "./button";
import { Icon } from "./icon";
import { Price } from "./price";
import { SegmentedControl } from "./segmented-control";
import { Select } from "./select";
import "./pricing-table.css";
export type Billing = "monthly" | "yearly";
export type PricingFeature = {
label: string;
/** true or false, or a value such as "10 GB", which counts as included. */
included: boolean | string;
};
export type PricingPlan = {
id: string;
name: string;
description?: string;
/** Prices in major units. null is a custom price, shown as labels.custom. */
price: { monthly: number; yearly: number } | null;
/** Unit the price covers, such as "seat". */
per?: string;
/** Replaces the "Billed yearly" line under the price, such as "Free for up to 3 people". */
priceNote?: string;
features: PricingFeature[];
cta: { label: string; href?: string };
highlighted?: boolean;
/** Badge text on the highlighted plan. */
highlightLabel?: string;
};
export type PricingRow = { label: string; values: Record<string, boolean | string> };
export type PricingGroup = { label: string; rows: PricingRow[] };
export type PricingTableLabels = {
billing: string;
monthly: string;
yearly: string;
month: string;
billedMonthly: string;
billedYearly: string;
per: string;
custom: string;
included: string;
notIncluded: string;
feature: string;
showPlan: string;
};
const defaultLabels: PricingTableLabels = {
billing: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
month: "month",
billedMonthly: "Billed monthly",
billedYearly: "Billed yearly",
per: "per",
custom: "Custom",
included: "Included",
notIncluded: "Not included",
feature: "Feature",
showPlan: "Show plan",
};
export type PricingTableProps = Omit<ComponentProps<"div">, "children" | "defaultValue"> & {
plans: PricingPlan[];
/** Controlled billing period. Pair it with onBillingChange. */
billing?: Billing;
defaultBilling?: Billing;
onBillingChange?: (billing: Billing) => void;
/** Short text beside the toggle, such as "Save 20%". */
yearlyNote?: string;
/** ISO 4217 code. */
currency?: string;
locale?: string;
/** Cards side by side, or one comparison table. */
variant?: "cards" | "matrix";
/** Rows for the matrix. Without them the matrix lists every plan feature. */
groups?: PricingGroup[];
/** s is a compact board: names and prices only. */
size?: "s" | "m";
/** Heading level of each plan name in the cards. */
headingLevel?: 2 | 3 | 4;
/** Called with the plan id when a CTA without an href is pressed. */
onPlanSelect?: (planId: string) => void;
/** Words the part shows or reads out, for other languages. */
labels?: Partial<PricingTableLabels>;
};
type Cell = boolean | string | undefined;
export function PricingTable({
plans,
billing,
defaultBilling = "monthly",
onBillingChange,
yearlyNote,
currency = "USD",
locale = "en-US",
variant = "cards",
groups,
size = "m",
headingLevel = 3,
onPlanSelect,
labels: labelsIn,
className,
...rest
}: PricingTableProps) {
const uid = useId();
const [innerBilling, setInnerBilling] = useState<Billing>(defaultBilling);
const period = billing ?? innerBilling;
const highlighted = plans.find((p) => p.highlighted) ?? plans[0];
const [shown, setShown] = useState(highlighted?.id ?? "");
const labels = { ...defaultLabels, ...labelsIn };
const compact = size === "s";
const Heading = `h${headingLevel}` as "h3";
const money = new Intl.NumberFormat(locale, { style: "currency", currency, trailingZeroDisplay: "stripIfInteger" });
const setBilling = (next: string) => {
const b: Billing = next === "yearly" ? "yearly" : "monthly";
if (billing === undefined) setInnerBilling(b);
onBillingChange?.(b);
};
const billedLine = (plan: PricingPlan) => {
if (plan.priceNote) return plan.priceNote;
if (!plan.price) return "";
const total = period === "yearly" ? plan.price.yearly : plan.price.monthly;
if (total === 0) return "";
if (period === "monthly") return labels.billedMonthly;
return `${labels.billedYearly}, ${money.format(total)}${plan.per ? ` ${labels.per} ${plan.per}` : ""}`;
};
const figure = (plan: PricingPlan, priceSize: "s" | "m" | "l") =>
plan.price ? (
<Price
amount={period === "yearly" ? plan.price.yearly / 12 : plan.price.monthly}
currency={currency}
locale={locale}
per={plan.per}
period={labels.month}
size={priceSize}
/>
) : (
<span className="eq-pricing-table-custom" data-size={priceSize}>
{labels.custom}
</span>
);
const cta = (plan: PricingPlan) => {
const accent = plan.highlighted === true;
if (plan.cta.href) {
return (
<Button className="eq-pricing-table-cta" variant={accent ? "accent" : "outline"} href={plan.cta.href}>
{plan.cta.label}
</Button>
);
}
return (
<Button
className="eq-pricing-table-cta"
variant={accent ? "accent" : "outline"}
onClick={() => onPlanSelect?.(plan.id)}
>
{plan.cta.label}
</Button>
);
};
const mark = (value: Cell, separator = "") => {
const on = value !== undefined && value !== false && value !== "";
return (
<>
<Icon className="eq-pricing-table-mark" name={on ? "check" : "minus"} size="s" data-on={on || undefined} />
<span className="eq-pricing-table-hidden">{`${on ? labels.included : labels.notIncluded}${separator}`}</span>
</>
);
};
const hasPrices = plans.some((p) => p.price !== null);
const hasDescriptions = !compact && plans.some((p) => p.description);
const toggle = hasPrices && (
<div className="eq-pricing-table-billing">
<SegmentedControl
legend={labels.billing}
hideLegend
name={`${uid}-billing`}
value={period}
onValueChange={setBilling}
options={[
{ value: "monthly", label: labels.monthly },
{ value: "yearly", label: labels.yearly },
]}
/>
{yearlyNote && !compact && (
<Badge className="eq-pricing-table-note" tone="ok">
{yearlyNote}
</Badge>
)}
</div>
);
const root = {
...rest,
className: ["eq-pricing-table", className].filter(Boolean).join(" "),
"data-variant": variant,
"data-size": size,
"data-count": plans.length,
};
if (variant === "matrix") {
const rows: PricingGroup[] =
groups ??
[
{
label: "",
rows: [...new Set(plans.flatMap((p) => p.features.map((f) => f.label)))].map((label) => ({
label,
values: Object.fromEntries(
plans.map((p) => [p.id, p.features.find((f) => f.label === label)?.included ?? false]),
),
})),
},
];
const off = (plan: PricingPlan) => (plan.id !== shown ? true : undefined);
return (
<div {...root}>
<div className="eq-pricing-table-bar">
{toggle}
<Select
className="eq-pricing-table-pick"
id={`${uid}-plan`}
label={labels.showPlan}
size="s"
value={shown}
onChange={(event) => setShown(event.currentTarget.value)}
>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name}
</option>
))}
</Select>
</div>
<table className="eq-pricing-table-matrix">
<thead>
<tr>
<td className="eq-pricing-table-corner">
<span className="eq-pricing-table-hidden">{labels.feature}</span>
</td>
{plans.map((plan) => (
<th
key={plan.id}
scope="col"
className="eq-pricing-table-head"
data-highlighted={plan.highlighted || undefined}
data-off={off(plan)}
>
<span className="eq-pricing-table-name">{plan.name}</span>
{plan.highlighted && plan.highlightLabel && (
<Badge className="eq-pricing-table-badge" variant="solid">
{plan.highlightLabel}
</Badge>
)}
<span className="eq-pricing-table-price">{figure(plan, "m")}</span>
<span className="eq-pricing-table-billed">{billedLine(plan)}</span>
</th>
))}
</tr>
</thead>
{rows.map((group, g) => (
<tbody key={g} className="eq-pricing-table-group">
{group.label && (
<tr>
<th scope="rowgroup" colSpan={plans.length + 1} className="eq-pricing-table-group-label">
{group.label}
</th>
</tr>
)}
{group.rows.map((row) => (
<tr key={row.label} className="eq-pricing-table-row">
<th scope="row" className="eq-pricing-table-row-label">
{row.label}
</th>
{plans.map((plan) => {
const value = row.values[plan.id];
return (
<td
key={plan.id}
className="eq-pricing-table-cell"
data-highlighted={plan.highlighted || undefined}
data-off={off(plan)}
>
{typeof value === "string" && value !== "" ? (
<span className="eq-pricing-table-value">{value}</span>
) : (
mark(value)
)}
</td>
);
})}
</tr>
))}
</tbody>
))}
<tfoot>
<tr>
<td className="eq-pricing-table-corner" />
{plans.map((plan) => (
<td
key={plan.id}
className="eq-pricing-table-cell"
data-highlighted={plan.highlighted || undefined}
data-off={off(plan)}
>
{cta(plan)}
</td>
))}
</tr>
</tfoot>
</table>
</div>
);
}
return (
<div {...root}>
{toggle}
<div className="eq-pricing-table-plans">
{plans.map((plan) => {
const headingId = `${uid}-${plan.id}`;
return (
<article
key={plan.id}
className="eq-pricing-table-plan"
aria-labelledby={headingId}
data-highlighted={plan.highlighted || undefined}
data-rows={compact ? 2 : hasDescriptions ? 5 : 4}
>
<div className="eq-pricing-table-header">
<Heading className="eq-pricing-table-name" id={headingId}>
{plan.name}
</Heading>
{plan.highlighted && plan.highlightLabel && !compact && (
<Badge className="eq-pricing-table-badge" variant="solid">
{plan.highlightLabel}
</Badge>
)}
</div>
{hasDescriptions && <p className="eq-pricing-table-description">{plan.description}</p>}
<div className="eq-pricing-table-price">
{figure(plan, compact ? "m" : "l")}
{!compact && <span className="eq-pricing-table-billed">{billedLine(plan)}</span>}
</div>
{!compact && cta(plan)}
{!compact && (
<ul className="eq-pricing-table-features">
{plan.features.map((feature) => (
<li
key={feature.label}
className="eq-pricing-table-feature"
data-included={feature.included !== false && feature.included !== "" ? true : undefined}
>
{mark(feature.included, ", ")}
<span className="eq-pricing-table-feature-label">{feature.label}</span>
{typeof feature.included === "string" && (
<span className="eq-pricing-table-value">{feature.included}</span>
)}
</li>
))}
</ul>
)}
</article>
);
})}
</div>
</div>
);
}components/equip/pricing-table.css397 lines
@layer equip {
.eq-pricing-table {
container-type: inline-size;
box-sizing: border-box;
width: 100%;
display: grid;
gap: var(--eq-space-5);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
.eq-pricing-table[data-size="s"] {
gap: var(--eq-space-2);
}
/* ---- Billing toggle ---- */
.eq-pricing-table-billing {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-pricing-table-note {
flex: none;
}
/* ---- Cards ---- */
.eq-pricing-table-plans {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-4);
}
@container (width >= 30rem) {
.eq-pricing-table:is([data-count="2"], [data-count="4"]) .eq-pricing-table-plans {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (width >= 40rem) {
.eq-pricing-table[data-count="3"] .eq-pricing-table-plans {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container (width >= 56rem) {
.eq-pricing-table[data-count="4"] .eq-pricing-table-plans {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Compact boards sit side by side at any width they fit. */
.eq-pricing-table[data-size="s"] .eq-pricing-table-plans {
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: var(--eq-space-2);
}
/* Each card spans five rows of the shared grid, so names, prices, buttons and lists line up
across cards however long a description runs. */
.eq-pricing-table-plan {
box-sizing: border-box;
display: grid;
grid-row: span 5;
grid-template-rows: auto;
grid-template-rows: subgrid;
row-gap: var(--eq-space-4);
align-content: start;
min-width: 0;
padding: var(--eq-space-5);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
transition:
border-color var(--eq-dur) var(--eq-ease),
box-shadow var(--eq-dur) var(--eq-ease);
}
/* Fewer rows when no plan has a description, so no empty row keeps a gap. */
.eq-pricing-table-plan[data-rows="4"] {
grid-row: span 4;
}
.eq-pricing-table-plan[data-rows="2"] {
grid-row: span 2;
}
.eq-pricing-table-plan[data-highlighted] {
border-color: var(--eq-solid);
background: var(--eq-raised);
box-shadow: inset 0 0 0 1px var(--eq-solid);
}
.eq-pricing-table[data-size="s"] .eq-pricing-table-plan {
row-gap: var(--eq-space-1);
padding: var(--eq-space-2);
border-radius: var(--eq-radius-m);
}
.eq-pricing-table-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-pricing-table-name {
margin: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-l) / 1.3 var(--eq-font);
}
.eq-pricing-table[data-size="s"] .eq-pricing-table-name {
font-size: var(--eq-text-s);
}
.eq-pricing-table-description {
margin: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
line-height: 1.45;
}
.eq-pricing-table-price {
display: grid;
align-content: start;
justify-items: start;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-pricing-table-custom {
font-weight: var(--eq-weight-strong);
font-size: calc(var(--eq-text-l) * 2.25);
line-height: 1;
letter-spacing: -0.02em;
}
.eq-pricing-table-custom[data-size="m"] {
font-size: calc(var(--eq-text-l) * 1.5);
}
.eq-pricing-table-billed {
display: block;
/* Held open when empty, so prices and buttons stay level across cards. */
min-height: 16px;
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-xs) / 16px var(--eq-font);
font-variant-numeric: tabular-nums;
}
/* The call to action, a Button or a Button with href, fills the card. */
.eq-pricing-table-cta {
width: 100%;
}
/* A long label wraps inside the card instead of running out of it. One line keeps the
control height; more lines grow the button. Scoped under the root so it outranks the
Button's own nowrap and fixed height. */
.eq-pricing-table .eq-pricing-table-cta {
height: auto;
min-height: var(--eq-h-m);
padding-block: var(--eq-space-1);
line-height: 1.25;
white-space: normal;
text-align: center;
text-wrap: balance;
overflow-wrap: break-word;
}
.eq-pricing-table-features {
display: grid;
align-content: start;
gap: var(--eq-space-2);
margin: 0;
padding: var(--eq-space-4) 0 0;
border-top: 1px solid var(--eq-line);
list-style: none;
}
.eq-pricing-table-feature {
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
align-items: start;
column-gap: var(--eq-space-2);
font-size: var(--eq-text-s);
line-height: 20px;
}
/* A long word (German compounds) breaks rather than running under the value beside it. */
.eq-pricing-table-feature-label {
overflow-wrap: anywhere;
hyphens: auto;
}
.eq-pricing-table-feature:not([data-included]) .eq-pricing-table-feature-label {
color: var(--eq-ink-3);
}
.eq-pricing-table-feature .eq-pricing-table-mark {
margin-top: 2px;
}
.eq-pricing-table-mark {
color: var(--eq-ink);
}
.eq-pricing-table-mark:not([data-on]) {
color: var(--eq-ink-3);
}
.eq-pricing-table-value {
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
text-align: end;
}
.eq-pricing-table-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ---- Matrix ---- */
.eq-pricing-table-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: var(--eq-space-3);
}
.eq-pricing-table-bar .eq-pricing-table-billing {
justify-content: start;
}
/* The plan chooser only appears when the table shows one plan at a time. */
.eq-pricing-table .eq-pricing-table-pick {
display: none;
}
.eq-pricing-table-matrix {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
font-size: var(--eq-text-s);
line-height: 1.4;
}
.eq-pricing-table-matrix :is(th, td) {
box-sizing: border-box;
padding: var(--eq-space-2);
border-bottom: 1px solid var(--eq-line);
font-weight: var(--eq-weight);
text-align: center;
vertical-align: middle;
}
.eq-pricing-table-corner {
width: 28%;
}
/* Plan headers stay in view while the rows scroll under them. Set --_sticky-top to clear a
fixed page header. Sticky needs no scrolling box in between, so the table never sits in one. */
.eq-pricing-table-matrix thead :is(th, td) {
position: sticky;
top: var(--_sticky-top, 0);
z-index: 1;
padding-block: var(--eq-space-3);
border-bottom-color: var(--eq-line-strong);
background: var(--eq-surface);
/* A translucent surface would let the rows read through the header as they pass under it. */
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
vertical-align: bottom;
}
.eq-pricing-table-matrix .eq-pricing-table-head {
text-align: start;
}
.eq-pricing-table-head .eq-pricing-table-name {
display: block;
font-size: var(--eq-text-m);
}
.eq-pricing-table-head .eq-pricing-table-badge {
margin-top: var(--eq-space-1);
}
.eq-pricing-table-head .eq-pricing-table-price {
margin-top: var(--eq-space-2);
}
.eq-pricing-table-matrix .eq-pricing-table-head[data-highlighted] {
background-image: linear-gradient(var(--eq-tint), var(--eq-tint));
box-shadow: inset 0 2px 0 var(--eq-solid);
}
.eq-pricing-table-cell[data-highlighted] {
background: var(--eq-tint);
}
.eq-pricing-table-matrix .eq-pricing-table-group-label {
padding-top: var(--eq-space-5);
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
text-align: start;
}
.eq-pricing-table-matrix .eq-pricing-table-row-label {
color: var(--eq-ink);
text-align: start;
}
.eq-pricing-table-cell .eq-pricing-table-mark {
vertical-align: middle;
}
.eq-pricing-table-matrix tfoot td {
padding-top: var(--eq-space-4);
border-bottom: 0;
}
/* Narrow: one plan column at a time, picked from the select, instead of a scrolling table.
The width at which that starts grows with the plan count, so a column never spills. */
@container (width < 28rem) {
.eq-pricing-table:is([data-count="1"], [data-count="2"]) .eq-pricing-table-matrix [data-off] {
display: none;
}
.eq-pricing-table:is([data-count="1"], [data-count="2"]) .eq-pricing-table-pick {
display: flex;
}
.eq-pricing-table:is([data-count="1"], [data-count="2"]) .eq-pricing-table-corner {
width: 55%;
}
/* The group rows span every plan, so a fixed layout would keep empty columns for the hidden ones. */
.eq-pricing-table:is([data-count="1"], [data-count="2"]) .eq-pricing-table-matrix {
table-layout: auto;
}
}
@container (width < 36rem) {
.eq-pricing-table[data-count="3"] .eq-pricing-table-matrix [data-off] {
display: none;
}
.eq-pricing-table[data-count="3"] .eq-pricing-table-pick {
display: flex;
}
.eq-pricing-table[data-count="3"] .eq-pricing-table-corner {
width: 55%;
}
/* The group rows span every plan, so a fixed layout would keep empty columns for the hidden ones. */
.eq-pricing-table[data-count="3"] .eq-pricing-table-matrix {
table-layout: auto;
}
}
@container (width < 48rem) {
.eq-pricing-table[data-count="4"] .eq-pricing-table-matrix [data-off] {
display: none;
}
.eq-pricing-table[data-count="4"] .eq-pricing-table-pick {
display: flex;
}
.eq-pricing-table[data-count="4"] .eq-pricing-table-corner {
width: 55%;
}
/* The group rows span every plan, so a fixed layout would keep empty columns for the hidden ones. */
.eq-pricing-table[data-count="4"] .eq-pricing-table-matrix {
table-layout: auto;
}
}
}