Pricing

Pricing 02 — Billing Toggle

Joined three-column pricing panel with a monthly/yearly pill toggle, animated prices and a save-20% badge.

$ npx shadcn@latest add @ludusvibe/pricing-02
Open in v0

Code

Installed to components/sections/pricing-02.tsx — it's yours to edit.

"use client"

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

import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Reveal } from "@/components/ludus/reveal"
import { scaleIn } from "@/lib/motion"
import { cn } from "@/lib/utils"

type Cta = { label: string; href: string }

export type Pricing02Tier = {
  name: string
  description: string
  /** Price per month when billed monthly. `null` renders `customPriceLabel`. */
  monthlyPrice: number | null
  /** Price per month when billed yearly. `null` renders `customPriceLabel`. */
  yearlyPrice: number | null
  features: string[]
  cta: Cta
  /** Visually emphasizes this tier with a badge, tint and accent bar. */
  highlighted?: boolean
}

export type Pricing02Props = {
  eyebrow?: string
  title?: React.ReactNode
  description?: string
  tiers?: Pricing02Tier[]
  monthlyLabel?: string
  yearlyLabel?: string
  /** Small badge rendered inside the yearly toggle button. */
  yearlyBadge?: string
  /** Currency symbol prefixed to numeric prices. */
  currency?: string
  /** Suffix rendered after numeric prices, e.g. "/month". */
  periodLabel?: string
  /** Price text for tiers with `monthlyPrice: null`. */
  customPriceLabel?: string
  customPriceNote?: string
  billedMonthlyNote?: string
  billedYearlyNote?: string
  /** Badge label shown on the highlighted tier. */
  highlightLabel?: string
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const defaultTiers: Pricing02Tier[] = [
  {
    name: "Starter",
    description: "For solo builders shipping their first product.",
    monthlyPrice: 19,
    yearlyPrice: 15,
    features: [
      "Up to 3 projects",
      "10k monthly page views",
      "Core analytics dashboard",
      "Community support",
    ],
    cta: { label: "Start with Starter", href: "#" },
  },
  {
    name: "Pro",
    description: "For growing teams that need room to move fast.",
    monthlyPrice: 49,
    yearlyPrice: 39,
    features: [
      "Unlimited projects",
      "250k monthly page views",
      "Advanced funnels & cohorts",
      "A/B testing built in",
      "5 team seats",
      "Priority support",
    ],
    cta: { label: "Get started with Pro", href: "#" },
    highlighted: true,
  },
  {
    name: "Enterprise",
    description: "For organizations with security and scale requirements.",
    monthlyPrice: null,
    yearlyPrice: null,
    features: [
      "Everything in Pro",
      "SSO / SAML & audit logs",
      "99.99% uptime SLA",
      "Unlimited team seats",
      "Dedicated success manager",
    ],
    cta: { label: "Talk to sales", href: "#" },
  },
]

export function Pricing02({
  eyebrow = "Pricing",
  title = "Pay monthly, or save with yearly",
  description = "Every plan starts with a 14-day free trial — full access, no credit card. Switch billing at any time.",
  tiers = defaultTiers,
  monthlyLabel = "Monthly",
  yearlyLabel = "Yearly",
  yearlyBadge = "Save 20%",
  currency = "$",
  periodLabel = "/month",
  customPriceLabel = "Custom",
  customPriceNote = "Tailored to your team",
  billedMonthlyNote = "Billed monthly",
  billedYearlyNote = "Billed annually",
  highlightLabel = "Most popular",
  as: Heading = "h2",
  className,
}: Pricing02Props) {
  const [billing, setBilling] = React.useState<"monthly" | "yearly">("monthly")

  return (
    <section className={cn("bg-background px-6 py-24 md:py-32", className)}>
      <div className="mx-auto max-w-6xl">
        <div className="mx-auto max-w-2xl text-center">
          <Reveal>
            <p className="text-sm font-medium tracking-widest text-primary uppercase">
              {eyebrow}
            </p>
          </Reveal>
          <Reveal delay={0.1}>
            <Heading className="mt-4 text-balance font-heading text-4xl font-semibold tracking-tight md:text-5xl">
              {title}
            </Heading>
          </Reveal>
          <Reveal delay={0.2}>
            <p className="mt-5 text-pretty text-muted-foreground md:text-lg">
              {description}
            </p>
          </Reveal>
        </div>

        <Reveal delay={0.3} className="mt-10 flex justify-center">
          <div className="inline-flex items-center rounded-full border bg-muted p-1">
            <button
              type="button"
              onClick={() => setBilling("monthly")}
              aria-pressed={billing === "monthly"}
              className={cn(
                "rounded-full px-4 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
                billing === "monthly"
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              {monthlyLabel}
            </button>
            <button
              type="button"
              onClick={() => setBilling("yearly")}
              aria-pressed={billing === "yearly"}
              className={cn(
                "flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
                billing === "yearly"
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              {yearlyLabel}
              <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
                {yearlyBadge}
              </span>
            </button>
          </div>
        </Reveal>

        <Reveal
          variants={scaleIn}
          delay={0.15}
          className="mt-12 md:mt-14"
        >
          <div className="grid overflow-hidden rounded-3xl border bg-card max-md:divide-y md:grid-cols-3 md:divide-x">
            {tiers.map((tier) => {
              const price =
                billing === "monthly" ? tier.monthlyPrice : tier.yearlyPrice
              const note =
                price === null
                  ? customPriceNote
                  : billing === "yearly"
                    ? billedYearlyNote
                    : billedMonthlyNote

              return (
                <div
                  key={tier.name}
                  className={cn(
                    "relative flex flex-col p-8",
                    tier.highlighted && "bg-primary/5"
                  )}
                >
                  {tier.highlighted && (
                    <div
                      aria-hidden
                      className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-primary/40 via-primary to-primary/40"
                    />
                  )}

                  <div className="flex items-center gap-2.5">
                    <h3 className="font-heading text-lg font-semibold tracking-tight">
                      {tier.name}
                    </h3>
                    {tier.highlighted && (
                      <Badge className="rounded-full">{highlightLabel}</Badge>
                    )}
                  </div>
                  <p className="mt-2 text-sm text-pretty text-muted-foreground">
                    {tier.description}
                  </p>

                  <div className="mt-6 flex items-baseline gap-1.5">
                    <motion.span
                      key={`${tier.name}-${billing}`}
                      variants={scaleIn}
                      initial="hidden"
                      animate="visible"
                      className="inline-block font-heading text-5xl font-semibold tracking-tighter"
                    >
                      {price === null
                        ? customPriceLabel
                        : `${currency}${price}`}
                    </motion.span>
                    {price !== null && (
                      <span className="text-sm text-muted-foreground">
                        {periodLabel}
                      </span>
                    )}
                  </div>
                  <p className="mt-1.5 text-xs text-muted-foreground">{note}</p>

                  <ul className="mt-8 mb-8 flex flex-col gap-3">
                    {tier.features.map((feature) => (
                      <li key={feature} className="flex items-start gap-3">
                        <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10">
                          <Check className="size-3 text-primary" />
                        </span>
                        <span className="text-sm text-muted-foreground">
                          {feature}
                        </span>
                      </li>
                    ))}
                  </ul>

                  <a
                    href={tier.cta.href}
                    className={cn(
                      buttonVariants({
                        size: "lg",
                        variant: tier.highlighted ? "default" : "outline",
                      }),
                      "mt-auto w-full"
                    )}
                  >
                    {tier.cta.label}
                  </a>
                </div>
              )
            })}
          </div>
        </Reveal>
      </div>
    </section>
  )
}