Components

Pricing Cards

A three-tier pricing layout with a monthly/yearly billing toggle and a highlighted recommended plan.

marketing

Installation

npx cano-ui add pricing-cards

Installs badge, button, card, lucide-react automatically if missing.

Source

"use client"

import * as React from "react"
import { Check } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card"

type BillingPeriod = "monthly" | "yearly"

interface PricingTier {
  id: string
  name: string
  description: string
  /** Price per month for each billing period. `yearly` is the discounted per-month price when billed annually. */
  price: {
    monthly: number
    yearly: number
  }
  currency?: string
  features: string[]
  cta: {
    label: string
    onClick?: () => void
    href?: string
  }
  recommended?: boolean
}

interface PricingCardsProps extends React.ComponentProps<"div"> {
  tiers: PricingTier[]
  /** Controlled billing period. Leave undefined for uncontrolled behavior. */
  billingPeriod?: BillingPeriod
  onBillingPeriodChange?: (period: BillingPeriod) => void
  defaultBillingPeriod?: BillingPeriod
  /** Hint shown next to the toggle when yearly billing is available. */
  yearlyHint?: string
}

function PricingCards({
  tiers,
  billingPeriod: billingPeriodProp,
  onBillingPeriodChange,
  defaultBillingPeriod = "monthly",
  yearlyHint = "Save 20%",
  className,
  ...props
}: PricingCardsProps) {
  const [internalPeriod, setInternalPeriod] =
    React.useState<BillingPeriod>(defaultBillingPeriod)
  const billingPeriod = billingPeriodProp ?? internalPeriod

  const setBillingPeriod = React.useCallback(
    (period: BillingPeriod) => {
      if (billingPeriodProp === undefined) {
        setInternalPeriod(period)
      }
      onBillingPeriodChange?.(period)
    },
    [billingPeriodProp, onBillingPeriodChange]
  )

  return (
    <div className={cn("flex flex-col gap-8", className)} {...props}>
      <div className="flex items-center justify-center gap-3">
        <div
          role="group"
          aria-label="Billing period"
          className="inline-flex items-center rounded-lg bg-muted p-1"
        >
          {(["monthly", "yearly"] as const).map((period) => (
            <button
              key={period}
              type="button"
              aria-pressed={billingPeriod === period}
              onClick={() => setBillingPeriod(period)}
              className={cn(
                "rounded-md px-3 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
                billingPeriod === period
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              {period === "monthly" ? "Monthly" : "Yearly"}
            </button>
          ))}
        </div>
        {yearlyHint ? (
          <span className="text-sm text-muted-foreground">{yearlyHint}</span>
        ) : null}
      </div>

      <div className="grid gap-6 md:grid-cols-3">
        {tiers.map((tier) => {
          const currency = tier.currency ?? "$"
          const amount =
            billingPeriod === "yearly" ? tier.price.yearly : tier.price.monthly

          return (
            <Card
              key={tier.id}
              className={cn(
                "relative flex flex-col",
                tier.recommended && "border-primary"
              )}
            >
              {tier.recommended ? (
                <Badge className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2">
                  Recommended
                </Badge>
              ) : null}
              <CardHeader>
                <h3 className="text-sm font-medium">{tier.name}</h3>
                <div aria-live="polite" className="flex items-baseline gap-1">
                  <span className="text-4xl font-semibold tracking-tight">
                    {currency}
                    {amount}
                  </span>
                  <span className="text-sm text-muted-foreground">/month</span>
                  {billingPeriod === "yearly" ? (
                    <span className="sr-only">billed yearly</span>
                  ) : null}
                </div>
                <p className="text-sm text-muted-foreground">
                  {tier.description}
                </p>
              </CardHeader>
              <CardContent className="flex-1">
                {tier.cta.href ? (
                  <Button
                    asChild
                    variant={tier.recommended ? "default" : "outline"}
                    className="w-full"
                  >
                    <a href={tier.cta.href}>{tier.cta.label}</a>
                  </Button>
                ) : (
                  <Button
                    variant={tier.recommended ? "default" : "outline"}
                    className="w-full"
                    onClick={tier.cta.onClick}
                  >
                    {tier.cta.label}
                  </Button>
                )}
              </CardContent>
              <CardFooter>
                <ul className="flex flex-col gap-3">
                  {tier.features.map((feature) => (
                    <li key={feature} className="flex items-start gap-2">
                      <Check
                        aria-hidden="true"
                        className="mt-0.5 size-4 shrink-0 text-muted-foreground"
                      />
                      <span className="text-sm">{feature}</span>
                    </li>
                  ))}
                </ul>
              </CardFooter>
            </Card>
          )
        })}
      </div>
    </div>
  )
}

export { PricingCards }
export type { PricingCardsProps, PricingTier, BillingPeriod }

This exact file lands in your project at components/ui/pricing-cards.tsx. It is yours to edit.