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
Built from
PriceSegmented controlButtonIconBadgeSelect
Billing period

Starter

$8 per month

Team

$20 per month
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.

Billing period
Save 20%

Free

For trying things out on a side project.

$0 per monthFree for up to 3 people
Get started
  • Included, Projects3
  • Included, File storage2 GB
  • Not included, Guest access
  • Not included, Audit log

Team

Most popular

For small teams shipping together every week.

$12 per seat per monthBilled yearly, $144 per seat
Start free trial
  • Included, ProjectsUnlimited
  • Included, File storage100 GB
  • Included, Guest access
  • Not included, Audit log

Business

For companies that need controls and reporting.

$24 per seat per monthBilled yearly, $288 per seat
Talk to sales
  • 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.

Abrechnungszeitraum
20 % sparen

Basis

10 € per MonatMonatliche Abrechnung
  • Enthalten, Nutzer1
  • Enthalten, Rechnungen pro Monat20
  • Nicht enthalten, DATEV-Export

Plus

Beliebt
20 € per MonatMonatliche Abrechnung
  • Enthalten, Nutzer3
  • Enthalten, Rechnungen pro Monat200
  • Enthalten, DATEV-Export

Pro

40 € per MonatMonatliche Abrechnung
  • Enthalten, Nutzer10
  • Enthalten, Rechnungen pro MonatUnbegrenzt
  • Enthalten, DATEV-Export

Enterprise

IndividuellAb 50 Nutzern
Vertrieb kontaktieren
  • 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.

Billing period
Save 20%
FeatureFree$0 per monthTeamMost popular$15 per seat per monthBilled monthlyBusiness$30 per seat per monthBilled monthly
Collaboration
Members3UnlimitedUnlimited
Guest accessNot includedIncludedIncluded
Shared templatesNot includedIncludedIncluded
Security
Two-factor sign-inIncludedIncludedIncluded
Single sign-onNot includedNot includedIncluded
Audit log retentionNot included30 days1 year
Support
Help centre and communityIncludedIncludedIncluded
Email supportNot includedNext working day4 hours
Named account managerNot includedNot includedIncluded
Get startedStart trialContact sales
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.

Billing period
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

PropTypeDefaultWhat 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 }[]noneThe plans, in order. A null price shows labels.custom; a string feature value counts as included and is shown.
billing"monthly" | "yearly"noneControlled billing period. Pair it with onBillingChange.
defaultBilling"monthly" | "yearly""monthly"Billing period on first render, when uncontrolled.
onBillingChange(billing: "monthly" | "yearly") => voidnoneCalled when the toggle changes.
yearlyNotestringnoneShort text beside the toggle, such as "Save 20%".
currencystring"USD"ISO 4217 currency code.
localestring"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> }[] }[]noneMatrix 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.
headingLevel2 | 3 | 43Heading level of each plan name in the cards.
onPlanSelect(planId: string) => voidnoneCalled when a CTA that has no href is pressed.
labelsPartial<{ billing; monthly; yearly; month; billedMonthly; billedYearly; per; custom; included; notIncluded; feature; showPlan }>noneEvery word the part shows or reads out, for other languages. All strings.
...restComponentProps<"div">noneEvery native div attribute. Set --_sticky-top in style to clear a fixed page header.

Anatomy

root.eq-pricing-tableThe container-query box. Variant, size and plan count are data attributes.
billing.eq-pricing-table-billingThe segmented control and the yearly note.
note.eq-pricing-table-noteA Badge beside the toggle.
plans.eq-pricing-table-plansThe grid the cards share.
plan.eq-pricing-table-planAn <article> named by its heading; a subgrid of five rows.
header.eq-pricing-table-header
name.eq-pricing-table-nameA heading in cards, plain text in the matrix header.
badge.eq-pricing-table-badgeThe highlight label, a solid Badge.
description.eq-pricing-table-description
price.eq-pricing-table-priceWraps Price, or the custom label, and the billed line.
custom.eq-pricing-table-customShown 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-ctaA Button; a Button with href, a real link, when cta.href is set.
features.eq-pricing-table-features
feature.eq-pricing-table-featuredata-included when the plan has it.
feature label.eq-pricing-table-feature-label
mark.eq-pricing-table-markA check or a muted dash Icon, with hidden words beside it.
value.eq-pricing-table-valueA feature value such as "10 GB".
hidden text.eq-pricing-table-hidden"Included" or "Not included" for screen readers.
bar.eq-pricing-table-barMatrix only: the toggle and the narrow plan chooser.
pick.eq-pricing-table-pickA Select shown only when the matrix is too narrow for every column.
matrix.eq-pricing-table-matrixThe comparison <table>.
corner.eq-pricing-table-corner
head.eq-pricing-table-headA sticky plan column header.
group.eq-pricing-table-groupA <tbody> per group.
group label.eq-pricing-table-group-labelA th with scope rowgroup.
row.eq-pricing-table-row
row label.eq-pricing-table-row-labelA th with scope row.
cell.eq-pricing-table-cell

Keyboard

TabMoves to the billing toggle, then through each plan's button or link.
Arrow keysSwitch between monthly and yearly inside the toggle.
Arrow keysIn 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index