Skip to content
Corsair UI
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/pricing

No 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.

Also adds
utilsbadgebuttoncardtoggletoggle-group
npm
@radix-ui/react-slot@radix-ui/react-toggle@radix-ui/react-toggle-groupclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Default

Prices given per period show the monthly and yearly switch.

Open in a new tab

Single price

Plain string prices, so there is no billing switch.

Open in a new tab

API

PropTypeDefault
eyebrow

Small line above the title; null hides it.

ReactNode"Pricing"
title / description

The header; the title is an h2. null hides either.

ReactNode"Plans that grow with you" / example copy
plans

Each 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 / onBillingChange

The period shown, controlled or not. Exposed as data-billing.

"monthly" | "yearly" / "monthly" | "yearly" / (billing) => void"monthly"
monthlyLabel / yearlyLabel

The switch's two options.

ReactNode"Monthly" / "Yearly"
yearlyNote

Shown next to the yearly label; null hides it.

ReactNode"2 months free"
billingLabel

Accessible 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 };