# Pricing plans

> Plans that turn into their own checkout: pick one and its card becomes the order, seats and totals rolling, then Continue opens your checkout.

Most pricing sections end at a button that throws you to another page, and the price you saw is gone. Here the plan you pick turns into its own checkout. Its card becomes a slip on the same surface: seats on a stepper for per-seat plans, the line items and what’s due today rolling as you change seats or switch to yearly, and what yearly saves you, while the other plans step back. Continue goes Opening checkout → Redirecting through your onCheckout, where you create the Stripe or Paddle session and redirect; if that fails the slip stays and says why. The billing switch rolls every price the way it moved and its badge works out the saving from your own prices. The featured plan is the lit surface, ruled with fine ledger hairlines, and with currentPlanId the same block becomes a billing settings page: Your plan, Upgrade to, Switch to.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/pricing-plans.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `pricing-toggle`, `status-button`, `text-morph`, `stepper-input`
- Docs and live preview: https://hairlineui.com/ui/pricing-plans
- Registry item (source): https://hairlineui.com/r/pricing-plans.json

## Usage

```tsx
import { PricingPlans } from "@/components/pricing-plans";

<PricingPlans
  plans={plans}
  onCheckout={async ({ plan, billing, seats }) => {
    const { url } = await createCheckoutSession({ plan: plan.id, billing, seats });
    window.location.href = url;
  }}
/>
```

## With Stripe Checkout

Create the Checkout Session in a server action and send the visitor to its URL. Throwing shows your message in the slip.

```tsx
// app/pricing/actions.ts
"use server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const PRICES = { pro: { monthly: "price_pro_m", yearly: "price_pro_y" }, team: { monthly: "price_team_m", yearly: "price_team_y" } };

export async function startCheckout(plan: "pro" | "team", billing: "monthly" | "yearly", seats: number) {
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: PRICES[plan][billing], quantity: seats }],
    success_url: "https://example.com/welcome",
    cancel_url: "https://example.com/pricing",
  });
  return session.url!;
}

// app/pricing/plans.tsx
"use client";
import { PricingPlans } from "@/components/pricing-plans";
import { startCheckout } from "./actions";

export function Plans() {
  return (
    <PricingPlans
      plans={plans}
      onCheckout={async ({ plan, billing, seats }) => {
        const url = await startCheckout(plan.id as "pro" | "team", billing, seats);
        window.location.href = url;
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `plans` | `PricingPlan[]` | – | { id, name, description?, price: { monthly, yearly }, perSeat?, minSeats?, maxSeats?, features, featured?, badge?, cta?, href? }. Prices are per month (per seat when perSeat); yearly is the monthly rate when billed yearly. |
| `onCheckout` | `(checkout: PricingCheckout) => Promise<unknown>` | – | Gets { plan, billing, seats, total }. Start your checkout and redirect; throw an Error to show its message. Free plans call it straight from their button. |
| `billing / defaultBilling / onBillingChange` | `"monthly" \| "yearly"` | `— / "monthly"` | The billing period; controlled or not. |
| `currentPlanId` | `string` | – | Marks the account’s plan and turns the other buttons into Upgrade to / Switch to. |
| `currency / locale` | `string` | `"USD" / browser` | Formats every price with Intl. |

## States

- **plans**: Hairline cards; the featured plan is lit from its top corner with fine ledger rules fading from the top right. Monthly/Yearly rolls every price and slides the billing note in from the side the switch moved.
- **checkout**: The picked card turns into a slip in 460ms (the plan face slides left through a blur, the slip comes in from the right, the height eases); the other plans fade to half and settle back a little.
- **seats**: The stepper changes the seat count; the line ("$19 × 4 seats × 12 months"), the saving and Due today roll to the new totals.
- **pending**: Continue morphs to Opening checkout with a spinner; seats and billing are locked.
- **redirecting**: Once onCheckout resolves, a check draws itself and the button reads Redirecting.
- **error**: The button shakes into Try again and your message replaces "Cancel any time."; nothing else changes.
- **current plan**: With currentPlanId the plan reads Your plan with a quiet, disabled button; others offer Upgrade to or Switch to by price.

## What it handles

A pricing section is usually cards, a toggle and a link to a checkout page, with the seat maths left for the checkout to explain. This one carries the choice through to checkout in the same card, against your own payment provider.

- **The card becomes the checkout**: Picking a plan turns its card into an order slip in place; the other plans step back, Escape or × goes back.
- **Seats and totals that add up**: Per-seat plans get a stepper with your minimum and maximum; line items and the total roll as they change.
- **Yearly that shows its saving**: The toggle’s badge is worked out from your prices, and the slip says how much yearly saves for these seats.
- **Checkout through your provider**: onCheckout gets the plan, billing, seats and total; create the session there and redirect.
- **Failures stay put**: If checkout can’t start, the slip keeps everything and shows your message under Try again.
- **A billing page too**: With currentPlanId the current plan says Your plan and the others read Upgrade to or Switch to.

## Replaces

plan cards, a billing toggle, a seats picker, an order summary, a checkout redirect button, a current-plan state

## Where it fits

- **Pricing pages**: Plans with a monthly/yearly switch where picking one starts checkout right there.
- **Billing settings**: The account’s current plan marked, with Upgrade and Switch on the others.
- **Upgrade prompts**: Shown when someone hits a limit, so they can upgrade without leaving.
- **Per-seat SaaS**: Team plans priced per seat, with the total worked out as seats change.

## Notes

- Each plan is a list item with its name as a heading; the checkout slip is a labelled group that takes focus when it opens, and Escape or × returns focus to the plan’s button.
- Prices and totals are read as whole amounts; the billing note is plain text, never a live region, and the error is an alert.
- A plan with href is a real link (Contact sales); the current plan’s button is disabled and says so.
- The featured plan’s light comes from your theme: --primary-foreground mixed into --primary for the lit corner, 1px light edges and ledger rules in the same colour, so it follows your brand in light and dark. No drop shadows, glow or blur.
- Built from Pricing toggle, Number roll, Stepper input, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion the card and slip crossfade in place.

License: MIT (https://hairlineui.com/license#components)
