Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Minus, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";

export type PricingTier = {
  id: string;
  name: string;
  description: string;
  /** Price per month when billed monthly. */
  monthly: number;
  /** Price per month when billed yearly. */
  yearly: number;
  features: string[];
  cta: string;
  popular?: boolean;
};

export type ComparisonRow = {
  feature: string;
  /** One value per tier, in tier order. `true` = check, `false` = dash, string = text. */
  values: (boolean | string)[];
};

export type ComparisonGroup = { group: string; rows: ComparisonRow[] };

export interface PricingToggleProps {
  eyebrow?: string;
  title?: string;
  subtitle?: string;
  tiers?: PricingTier[];
  comparison?: ComparisonGroup[];
  currency?: string;
  /** Label for the yearly savings badge. */
  savingsLabel?: string;
  defaultCycle?: "monthly" | "yearly";
  onSelect?: (tier: PricingTier, cycle: "monthly" | "yearly") => void;
  className?: string;
}

const DEFAULT_TIERS: PricingTier[] = [
  {
    id: "starter",
    name: "Starter",
    description: "Everything you need to launch your first project.",
    monthly: 0,
    yearly: 0,
    cta: "Start for free",
    features: ["Up to 3 projects", "1,000 monthly visitors", "Community support", "Basic analytics"],
  },
  {
    id: "pro",
    name: "Pro",
    description: "For growing teams that ship every week.",
    monthly: 29,
    yearly: 23,
    cta: "Start 14-day trial",
    popular: true,
    features: ["Unlimited projects", "100,000 monthly visitors", "Priority email support", "Advanced analytics", "Custom domains"],
  },
  {
    id: "business",
    name: "Business",
    description: "Security, control and scale for larger orgs.",
    monthly: 89,
    yearly: 71,
    cta: "Talk to sales",
    features: ["Everything in Pro", "1M monthly visitors", "SSO & audit logs", "Dedicated success manager", "99.99% uptime SLA"],
  },
];

const DEFAULT_COMPARISON: ComparisonGroup[] = [
  {
    group: "Usage",
    rows: [
      { feature: "Projects", values: ["3", "Unlimited", "Unlimited"] },
      { feature: "Monthly visitors", values: ["1k", "100k", "1M"] },
      { feature: "Team seats", values: ["1", "10", "Unlimited"] },
      { feature: "File storage", values: ["1 GB", "100 GB", "2 TB"] },
    ],
  },
  {
    group: "Features",
    rows: [
      { feature: "Custom domains", values: [false, true, true] },
      { feature: "Advanced analytics", values: [false, true, true] },
      { feature: "A/B experiments", values: [false, true, true] },
      { feature: "Workflow automation", values: [false, "5 flows", "Unlimited"] },
    ],
  },
  {
    group: "Security & support",
    rows: [
      { feature: "SSO / SAML", values: [false, false, true] },
      { feature: "Audit logs", values: [false, false, true] },
      { feature: "Support", values: ["Community", "Priority email", "Dedicated manager"] },
    ],
  },
];

/* ---------- rolling number ---------- */

function Digit({ value }: { value: number }) {
  const reduce = useReducedMotion();
  return (
    <span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        animate={{ y: `${-value * 10}%` }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 22, mass: 0.8 }}
      >
        {Array.from({ length: 10 }, (_, n) => (
          <span key={n} className="flex h-[1em] items-center justify-center">
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

function RollingNumber({ value, className }: { value: number; className?: string }) {
  const digits = String(Math.round(value)).split("");
  return (
    <span className={cn("inline-flex leading-none tabular-nums", className)} aria-hidden>
      <AnimatePresence initial={false} mode="popLayout">
        {digits.map((d, i) => {
          const key = digits.length - i; // key from the right so units stay put when length changes
          return (
            <motion.span
              key={key}
              layout
              initial={{ opacity: 0, y: "-0.4em" }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: "0.4em" }}
              className="inline-block"
            >
              <Digit value={Number(d)} />
            </motion.span>
          );
        })}
      </AnimatePresence>
    </span>
  );
}

/* ---------- main ---------- */

function GradientBorder({ children }: { children: React.ReactNode }) {
  const reduce = useReducedMotion();
  return (
    <div className="relative h-full rounded-3xl p-[1.5px]">
      {/* soft glow */}
      <div aria-hidden className="absolute -inset-2 rounded-[2rem] bg-gradient-to-br from-violet-500/30 via-fuchsia-500/20 to-sky-500/30 opacity-70 blur-2xl dark:opacity-50" />
      <div aria-hidden className="absolute inset-0 overflow-hidden rounded-3xl">
        <motion.div
          className="absolute left-1/2 top-1/2 aspect-square w-[250%] -translate-x-1/2 -translate-y-1/2 bg-[conic-gradient(from_0deg,#8b5cf6,#d946ef,#f59e0b,#0ea5e9,#8b5cf6)]"
          animate={reduce ? undefined : { rotate: 360 }}
          transition={{ duration: 6, ease: "linear", repeat: Infinity }}
        />
      </div>
      <div className="relative h-full rounded-[calc(1.5rem-1.5px)] bg-card">{children}</div>
    </div>
  );
}

export function PricingToggle({
  eyebrow = "Pricing",
  title = "Simple pricing that scales with you",
  subtitle = "Start free, upgrade when you're ready. Every plan includes unlimited API calls and our core toolkit.",
  tiers = DEFAULT_TIERS,
  comparison = DEFAULT_COMPARISON,
  currency = "$",
  savingsLabel = "Save 20%",
  defaultCycle = "yearly",
  onSelect,
  className,
}: PricingToggleProps) {
  const [cycle, setCycle] = React.useState<"monthly" | "yearly">(defaultCycle);
  const [open, setOpen] = React.useState(false);
  const tableId = React.useId();
  const reduce = useReducedMotion();

  return (
    <section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(50%_40%_at_50%_0%,color-mix(in_oklch,var(--primary)_12%,transparent),transparent)]" />
      <div className="relative mx-auto max-w-6xl">
        <header className="mx-auto max-w-3xl text-center">
          <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
          <h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
          <p className="mx-auto mt-4 max-w-2xl text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
        </header>

        <div className="mt-10 flex items-center justify-center gap-3">
          <div role="radiogroup" aria-label="Billing cycle" className="relative inline-flex rounded-full border bg-muted/60 p-1 text-sm">
            {(["monthly", "yearly"] as const).map((c) => (
              <button
                key={c}
                type="button"
                role="radio"
                aria-checked={cycle === c}
                onClick={() => setCycle(c)}
                onKeyDown={(e) => {
                  if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) {
                    e.preventDefault();
                    const nextCycle = cycle === "monthly" ? "yearly" : "monthly";
                    setCycle(nextCycle);
                    const sibling = e.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-cycle="${nextCycle}"]`);
                    sibling?.focus();
                  }
                }}
                tabIndex={cycle === c ? 0 : -1}
                data-cycle={c}
                className={cn(
                  "relative rounded-full px-5 py-2 font-medium capitalize transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  cycle === c ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                )}
              >
                {cycle === c && (
                  <motion.span
                    layoutId="pricing-toggle-pill"
                    className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
                    transition={{ type: "spring", stiffness: 420, damping: 34 }}
                  />
                )}
                <span className="relative">{c}</span>
              </button>
            ))}
          </div>
          <motion.span
            animate={{ scale: cycle === "yearly" ? 1 : 0.92, opacity: cycle === "yearly" ? 1 : 0.55 }}
            className="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-500/25 dark:text-emerald-300"
          >
            {savingsLabel}
          </motion.span>
        </div>

        <div className="mt-12 grid gap-6 lg:grid-cols-3 lg:items-stretch">
          {tiers.map((tier, i) => {
            const price = cycle === "yearly" ? tier.yearly : tier.monthly;
            const body = (
              <div className="flex h-full flex-col p-7 sm:p-8">
                <div className="flex items-center justify-between gap-3">
                  <h3 className="text-lg font-semibold text-foreground">{tier.name}</h3>
                  {tier.popular && (
                    <span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-600 px-2.5 py-1 text-xs font-semibold text-white shadow-sm">
                      <Sparkles aria-hidden className="size-3" /> Most popular
                    </span>
                  )}
                </div>
                <p className="mt-2 min-h-10 text-sm text-muted-foreground">{tier.description}</p>
                <div className="mt-6 flex items-end gap-1.5">
                  <span className="text-5xl font-semibold tracking-tight text-foreground">
                    <span className="mr-0.5 align-top text-2xl leading-none">{currency}</span>
                    <RollingNumber value={price} />
                    <span className="sr-only">
                      {price} per month{cycle === "yearly" ? ", billed yearly" : ""}
                    </span>
                  </span>
                  <span className="pb-1 text-sm text-muted-foreground">/ month</span>
                </div>
                <p className="mt-2 h-5 text-xs text-muted-foreground">
                  <AnimatePresence mode="wait" initial={false}>
                    <motion.span
                      key={cycle + price}
                      initial={{ opacity: 0, y: 4 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -4 }}
                      className="inline-block"
                    >
                      {price === 0
                        ? "Free forever, no card required"
                        : cycle === "yearly"
                          ? `Billed ${currency}${price * 12} yearly`
                          : "Billed monthly, cancel anytime"}
                    </motion.span>
                  </AnimatePresence>
                </p>
                <button
                  type="button"
                  onClick={() => onSelect?.(tier, cycle)}
                  className={cn(
                    "mt-7 inline-flex h-11 items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    tier.popular
                      ? "bg-primary text-primary-foreground shadow-lg shadow-primary/25 hover:brightness-110"
                      : "border bg-background text-foreground hover:bg-accent",
                  )}
                >
                  {tier.cta}
                </button>
                <ul className="mt-8 space-y-3 border-t pt-7 text-sm">
                  {tier.features.map((f) => (
                    <li key={f} className="flex items-start gap-3 text-foreground/85">
                      <span className={cn("mt-0.5 grid size-5 shrink-0 place-items-center rounded-full", tier.popular ? "bg-primary text-primary-foreground" : "bg-primary/12 text-primary")}>
                        <Check aria-hidden className="size-3" strokeWidth={3} />
                      </span>
                      {f}
                    </li>
                  ))}
                </ul>
              </div>
            );
            return (
              <motion.div
                key={tier.id}
                initial={reduce ? false : { opacity: 0, y: 24 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true, margin: "-40px" }}
                transition={{ delay: i * 0.08, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                className={cn(tier.popular && "lg:-my-3")}
              >
                {tier.popular ? <GradientBorder>{body}</GradientBorder> : <div className="h-full rounded-3xl border bg-card">{body}</div>}
              </motion.div>
            );
          })}
        </div>

        {comparison.length > 0 && (
          <div className="mt-12">
            <div className="flex justify-center">
              <button
                type="button"
                aria-expanded={open}
                aria-controls={tableId}
                onClick={() => setOpen((o) => !o)}
                className="inline-flex items-center gap-2 rounded-full border bg-card px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {open ? "Hide full comparison" : "See all features"}
                <motion.span animate={{ rotate: open ? 180 : 0 }} transition={{ type: "spring", stiffness: 300, damping: 22 }}>
                  <ChevronDown aria-hidden className="size-4" />
                </motion.span>
              </button>
            </div>
            <AnimatePresence initial={false}>
              {open && (
                <motion.div
                  id={tableId}
                  key="table"
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={{ duration: reduce ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}
                  className="overflow-hidden"
                >
                  <div className="mt-8 overflow-x-auto rounded-3xl border bg-card">
                    <table className="w-full min-w-[560px] table-fixed text-left text-sm">
                      <caption className="sr-only">Feature comparison</caption>
                      <thead>
                        <tr className="border-b">
                          <th scope="col" className="w-[34%] px-6 py-4 font-medium text-muted-foreground">
                            Feature
                          </th>
                          {tiers.map((t) => (
                            <th key={t.id} scope="col" className={cn("px-4 py-4 text-center font-semibold", t.popular ? "text-primary" : "text-foreground")}>
                              {t.name}
                            </th>
                          ))}
                        </tr>
                      </thead>
                      {comparison.map((g) => (
                        <tbody key={g.group}>
                          <tr>
                            <th colSpan={tiers.length + 1} scope="colgroup" className="bg-muted/50 px-6 py-2.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
                              {g.group}
                            </th>
                          </tr>
                          {g.rows.map((r) => (
                            <tr key={r.feature} className="border-t border-border/60">
                              <th scope="row" className="px-6 py-3.5 font-normal text-foreground/85">
                                {r.feature}
                              </th>
                              {r.values.map((v, vi) => (
                                <td key={vi} className={cn("px-4 py-3.5 text-center", tiers[vi]?.popular && "bg-primary/[0.04]")}>
                                  {v === true ? (
                                    <Check aria-label="Included" className="mx-auto size-4 text-primary" strokeWidth={2.5} />
                                  ) : v === false ? (
                                    <Minus aria-label="Not included" className="mx-auto size-4 text-muted-foreground/50" />
                                  ) : (
                                    <span className="text-foreground/85">{v}</span>
                                  )}
                                </td>
                              ))}
                            </tr>
                          ))}
                        </tbody>
                      ))}
                    </table>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        )}
      </div>
    </section>
  );
}

More in Pricing

View all →