Package comparison
Two or three tiers side by side, each with its own call to action.
Installation
npx shadcn@latest add http://localhost:3000/r/pricing-packages.jsonMKT UI is not published yet, so this points at your local development server. Set NEXT_PUBLIC_REGISTRY_URL to the deployed origin to show the public command.
Also installs: container, section, section-heading, package-card, inclusion-list.
Usage
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/price-display";
import { PricingPackages } from "@/components/pricing-packages";
<PricingPackages
title="Straightforward pricing"
packages={[
{
id: "callout",
name: "Callout",
description: "One visit, one problem.",
price: <PriceDisplay prefix="From" currency="$" amount="149" period="visit" align="center" />,
inclusions: ["First hour on site", { label: "Out-of-hours cover", included: false }],
action: <Button variant="outline">Book a visit</Button>,
},
{
id: "care",
name: "Care plan",
featured: true,
price: <PriceDisplay currency="$" amount="29" period="per month" align="center" />,
inclusions: ["Everything in Callout", "Annual system service"],
action: <Button>Join the plan</Button>,
note: "Cancel any time",
},
]}
footnote="Prices exclude parts and materials unless stated."
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title (required) | ReactNode | — | Section heading. |
| packages (required) | PricingPackage[] | — | Two or three tiers. More than three stops being comparable. |
| columns | 2 | 3 | 3 | Columns on wide screens. |
| toggle | ReactNode | — | Control above the grid, e.g. a monthly/yearly switch. Supply your own client component. |
| footnote | ReactNode | — | Tax treatment, contract length, what is excluded. |
| align | "start" | "center" | "center" | Heading alignment. |
| surface | "default" | "muted" | "brand" | "inverted" | "default" | Background treatment. |
PricingPackage
| Prop | Type | Default | Description |
|---|---|---|---|
| id (required) | string | — | Stable key. |
| name (required) | ReactNode | — | Tier name. |
| description | ReactNode | — | One sentence on who this tier suits. Stops people guessing. |
| price | ReactNode | — | A `PriceDisplay`, or plain text such as "Custom quote". |
| inclusions | (InclusionItem | string)[] | — | What is included. Pass `{ included: false }` to show an exclusion. |
| action | ReactNode | — | The button. Every tier needs one, including the custom-quote tier. |
| featured | boolean | — | Promotes the tier and shows a label. |
| featuredLabel | ReactNode | "Most popular" | Text of the promotion label. |
Behaviour
The featured tier is labelled, not just styled. Signalling "recommended" through a border alone leaves the recommendation invisible to anyone who cannot see the border, and it is the most load-bearing piece of information on a pricing page.
Exclusions are shown, not omitted. "No phone support" is something a buyer needs to see before they pay. Excluded lines are struck through and carry a screen-reader-only "Not included", so the distinction does not depend on noticing a different icon.
Every tier gets an action. A column a visitor cannot act on is a dead end, and it is usually the column with the largest deal in it.
Billing toggles
The section renders on the server, so a monthly/yearly toggle is your own client
component passed through toggle. A static pricing page then ships no JavaScript.
Keep switched values consistent
If you add a toggle, make sure every tier's price, period, and footnote all update together. A page showing yearly prices beside a "per month" label is the most common pricing bug there is.
Limitations
- No feature-matrix table variant yet.
- No add-ons, usage pricing, or per-seat calculator.
- No built-in billing toggle — see above.