Browse the docs
BlocksNo. 101 of 109
Pricing
A pricing section with plan cards, a featured plan and an optional monthly or yearly billing switch.
Installation
pnpm dlx shadcn@latest add @corsair-ui/pricingNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
Examples
Default
Prices given per period show the monthly and yearly switch.
Single price
Plain string prices, so there is no billing switch.
API
| Prop | Type | Default |
|---|---|---|
eyebrowSmall line above the title; null hides it. | ReactNode | "Pricing" |
title / descriptionThe header; the title is an h2. null hides either. | ReactNode | "Plans that grow with you" / example copy |
plansEach has name, price and features, and optionally id, description, period, action ({ label, href }), featured and badge. price and period take { monthly, yearly } to show the switch. | PricingPlan[] | three examples |
billing / defaultBilling / onBillingChangeThe period shown, controlled or not. Exposed as data-billing. | "monthly" | "yearly" / "monthly" | "yearly" / (billing) => void | "monthly" |
monthlyLabel / yearlyLabelThe switch's two options. | ReactNode | "Monthly" / "Yearly" |
yearlyNoteShown next to the yearly label; null hides it. | ReactNode | "2 months free" |
billingLabelAccessible name of the switch. | string | "Billing period" |
Accessibility
- The title is an h2 and plan names are h3. Plans and features are lists; the check icons are hidden.
- The switch is a toggle group named by billingLabel that always keeps one period selected: Tab reaches it, the arrow keys move between periods.
- The featured ring is only visual: give that plan a badge so it is said in words too.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
pricing.tsxView on GitHub"use client";
import { CheckIcon } from "lucide-react";
import { isValidElement, useState, type ComponentProps, type ReactNode } from "react";
import { cn } from "@/registry/default/lib/utils";
import { Badge } from "@/registry/default/ui/badge";
import { Button } from "@/registry/default/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/registry/default/ui/card";
import { ToggleGroup, ToggleGroupItem } from "@/registry/default/ui/toggle-group";
type PricingBilling = "monthly" | "yearly";
/** A value that is either the same for both billing periods or differs per period. */
type PricingValue = ReactNode | { monthly: ReactNode; yearly: ReactNode };
interface PricingPlan {
/** Used as the React key; falls back to the index. */
id?: string;
name: ReactNode;
description?: ReactNode;
/** One price, or `{ monthly, yearly }` to show the billing switch. Pass formatted strings. */
price: PricingValue;
/** Shown after the price in muted text, e.g. "/month". */
period?: PricingValue;
features: ReactNode[];
action?: { label: ReactNode; href: string };
/** Highlights the plan with a ring and a solid action button. */
featured?: boolean;
/** Small label next to the plan name, e.g. "Most popular". */
badge?: ReactNode;
}
interface PricingProps extends Omit<ComponentProps<"section">, "title"> {
eyebrow?: ReactNode;
title?: ReactNode;
description?: ReactNode;
plans?: PricingPlan[];
/** The billing period shown (controlled). */
billing?: PricingBilling;
/** The billing period shown first (uncontrolled). Defaults to "monthly". */
defaultBilling?: PricingBilling;
onBillingChange?: (billing: PricingBilling) => void;
monthlyLabel?: ReactNode;
yearlyLabel?: ReactNode;
/** Accessible name of the billing switch. */
billingLabel?: string;
/** Shown next to the yearly label, e.g. "Save 20%". Pass `null` to hide. */
yearlyNote?: ReactNode;
}
const DEFAULT_PLANS: PricingPlan[] = [
{
id: "starter",
name: "Starter",
description: "For individuals trying things out.",
price: { monthly: "$0", yearly: "$0" },
period: { monthly: "/month", yearly: "/year" },
features: ["Up to 3 projects", "Basic analytics", "Community support"],
action: { label: "Get started", href: "#get-started" },
},
{
id: "pro",
name: "Pro",
description: "For growing teams that need more room.",
price: { monthly: "$12", yearly: "$120" },
period: { monthly: "/month", yearly: "/year" },
features: [
"Unlimited projects",
"Advanced analytics",
"Shared workspaces",
"Priority email support",
],
action: { label: "Start free trial", href: "#get-started" },
featured: true,
badge: "Most popular",
},
{
id: "enterprise",
name: "Enterprise",
description: "For organisations with custom needs.",
price: "Custom",
features: ["Everything in Pro", "Single sign-on", "Audit logs", "Dedicated account manager"],
action: { label: "Contact sales", href: "#contact" },
},
];
// Full class names so Tailwind picks them up.
const GRID_COLUMNS: Record<number, string> = {
1: "mx-auto max-w-md",
2: "mx-auto max-w-4xl md:grid-cols-2",
3: "lg:grid-cols-3",
4: "md:grid-cols-2 lg:grid-cols-4",
};
function isPerBilling(value: PricingValue): value is { monthly: ReactNode; yearly: ReactNode } {
return (
typeof value === "object" &&
value !== null &&
!isValidElement(value) &&
"monthly" in value &&
"yearly" in value
);
}
function resolve(value: PricingValue, billing: PricingBilling): ReactNode {
return isPerBilling(value) ? value[billing] : value;
}
/**
* A pricing section: a centred header, an optional monthly / yearly switch
* and one card per plan. Give a plan `price: { monthly, yearly }` (and the
* same shape for `period`) and the switch appears above the grid; plain
* values stay the same for both periods. Prices are shown as passed, so
* format them for your locale and currency. The featured plan gets a ring
* and a solid button, and its `badge` sits next to the name.
*
* Every string is a prop: `monthlyLabel`, `yearlyLabel`, `yearlyNote` and
* `billingLabel` (the switch's accessible name). The billing period works
* controlled (`billing` + `onBillingChange`) or uncontrolled
* (`defaultBilling`), and the current one is exposed as `data-billing`.
*
* Accessibility: the section title is an `<h2>` and plan names are `<h3>`;
* plans and features are lists; the switch is a radio-style toggle group
* that cannot be cleared, reachable with Tab and the arrow keys.
*
* @example
* <Pricing
* title="Pricing"
* plans={[
* { name: "Free", price: "$0", features: ["1 project"] },
* { name: "Team", price: { monthly: "$20", yearly: "$200" }, features: ["10 projects"], featured: true },
* ]}
* />
*/
function Pricing({
className,
eyebrow = "Pricing",
title = "Plans that grow with you",
description = "Start for free and upgrade when your team needs more. Every plan includes a 14-day trial.",
plans = DEFAULT_PLANS,
billing: billingProp,
defaultBilling = "monthly",
onBillingChange,
monthlyLabel = "Monthly",
yearlyLabel = "Yearly",
billingLabel = "Billing period",
yearlyNote = "2 months free",
...props
}: PricingProps) {
const [billingState, setBillingState] = useState<PricingBilling>(defaultBilling);
const billing = billingProp ?? billingState;
const hasBillingSwitch = plans.some(
(plan) => isPerBilling(plan.price) || (plan.period !== undefined && isPerBilling(plan.period))
);
const hasHeader = eyebrow != null || title != null || description != null;
function handleBillingChange(value: string) {
// A single toggle group can be cleared by pressing the active item; keep one selected.
if (value !== "monthly" && value !== "yearly") return;
if (billingProp === undefined) setBillingState(value);
onBillingChange?.(value);
}
return (
<section
data-slot="pricing"
data-billing={billing}
className={cn("py-16 sm:py-24", className)}
{...props}
>
<div data-slot="pricing-container" className="mx-auto w-full max-w-6xl px-4 sm:px-6">
{hasHeader ? (
<div data-slot="pricing-header" className="mx-auto max-w-2xl text-center">
{eyebrow != null ? (
<p data-slot="pricing-eyebrow" className="text-muted-foreground text-sm font-medium">
{eyebrow}
</p>
) : null}
{title != null ? (
<h2
data-slot="pricing-title"
className={cn(
"text-3xl font-semibold tracking-tight text-balance sm:text-4xl",
eyebrow != null && "mt-2"
)}
>
{title}
</h2>
) : null}
{description != null ? (
<p
data-slot="pricing-description"
className="text-muted-foreground mt-4 text-lg text-pretty"
>
{description}
</p>
) : null}
</div>
) : null}
<div data-slot="pricing-content" className={cn(hasHeader && "mt-12")}>
{hasBillingSwitch ? (
<div data-slot="pricing-billing" className="mb-10 flex justify-center">
<ToggleGroup
type="single"
variant="outline"
value={billing}
onValueChange={handleBillingChange}
aria-label={billingLabel}
>
<ToggleGroupItem value="monthly" data-slot="pricing-billing-monthly">
{monthlyLabel}
</ToggleGroupItem>
<ToggleGroupItem value="yearly" data-slot="pricing-billing-yearly">
{yearlyLabel}
{yearlyNote != null ? (
<span
data-slot="pricing-yearly-note"
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs"
>
{yearlyNote}
</span>
) : null}
</ToggleGroupItem>
</ToggleGroup>
</div>
) : null}
<ul
data-slot="pricing-plans"
className={cn("grid gap-6", GRID_COLUMNS[Math.min(Math.max(plans.length, 1), 4)])}
>
{plans.map((plan, index) => {
const period = plan.period === undefined ? null : resolve(plan.period, billing);
return (
<li key={plan.id ?? index} data-slot="pricing-plan-item" className="flex">
<Card
data-slot="pricing-plan"
data-featured={plan.featured ? "" : undefined}
className={cn("w-full", plan.featured && "border-primary ring-primary ring-2")}
>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<h3
data-slot="pricing-plan-name"
className="text-lg leading-none font-semibold"
>
{plan.name}
</h3>
{plan.badge != null ? (
<Badge data-slot="pricing-plan-badge">{plan.badge}</Badge>
) : null}
</div>
{plan.description != null ? (
<CardDescription data-slot="pricing-plan-description">
{plan.description}
</CardDescription>
) : null}
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-6">
<p data-slot="pricing-price" className="flex flex-wrap items-baseline gap-1">
<span className="text-4xl font-semibold tracking-tight">
{resolve(plan.price, billing)}
</span>
{period != null ? (
<span
data-slot="pricing-period"
className="text-muted-foreground text-sm"
>
{period}
</span>
) : null}
</p>
<ul data-slot="pricing-features" className="flex flex-col gap-3 text-sm">
{plan.features.map((feature, featureIndex) => (
<li
key={featureIndex}
data-slot="pricing-feature"
className="flex items-start gap-2"
>
<CheckIcon
aria-hidden="true"
className="text-primary mt-0.5 size-4 shrink-0"
/>
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
{plan.action ? (
<CardFooter>
<Button
asChild
// "outline" is the button variant name, not the Tailwind utility.
// tailwind-compat-ignore-next-line
variant={plan.featured ? "default" : "outline"}
className="w-full"
>
<a data-slot="pricing-plan-action" href={plan.action.href}>
{plan.action.label}
</a>
</Button>
</CardFooter>
) : null}
</Card>
</li>
);
})}
</ul>
</div>
</div>
</section>
);
}
export { Pricing, type PricingBilling, type PricingPlan, type PricingProps, type PricingValue };