Sections

Pricing Tiers

Three-tier pricing with a monthly/annual toggle that rolls price digits, a ringed recommended tier, and staggered feature checklists.

Install

npx shadcn@latest add @paragon/pricing-tiers

Also installs: button, segmented-control

pricing-tiers.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useInView,
  useReducedMotion,
} from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/registry/paragon/ui/segmented-control";

const REVEAL =
  "transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";

const TIERS = [
  {
    name: "Starter",
    description: "For small teams instrumenting their first services.",
    monthly: 29,
    annual: 24,
    cta: "Start free",
    ctaVariant: "outline" as const,
    features: [
      "Up to 10 seats",
      "30-day metric retention",
      "Community support",
      "Core dashboards and alerts",
    ],
  },
  {
    name: "Growth",
    description: "For teams running production workloads at scale.",
    monthly: 79,
    annual: 63,
    recommended: true,
    cta: "Start 14-day trial",
    ctaVariant: "default" as const,
    features: [
      "Unlimited seats",
      "13-month metric retention",
      "SSO and SCIM provisioning",
      "Custom alert policies",
      "Priority support",
    ],
  },
  {
    name: "Scale",
    description: "For organizations with compliance requirements.",
    monthly: 149,
    annual: 119,
    cta: "Contact sales",
    ctaVariant: "outline" as const,
    features: [
      "Everything in Growth",
      "Audit logs and BYOK encryption",
      "99.99% uptime SLA",
      "Dedicated success engineer",
      "Custom data residency",
    ],
  },
];

/**
 * Per-character digit roll: when the billing period flips, only the
 * characters that changed roll vertically (spring, blur-masked). Prices
 * are chosen with matching digit counts per tier so layout never shifts;
 * tabular-nums pins each column width.
 */
function RollingAmount({ amount }: { amount: number }) {
  const reduced = useReducedMotion();
  const chars = String(amount).split("");

  return (
    <span
      aria-label={`$${amount} per month`}
      className="inline-flex items-baseline"
    >
      <span aria-hidden className="text-xl font-semibold">
        $
      </span>
      <span
        aria-hidden
        className="inline-flex text-4xl font-semibold tracking-tight tabular-nums"
      >
        {chars.map((char, i) => (
          <span key={i} className="relative inline-flex overflow-hidden">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={char}
                className="inline-block"
                initial={
                  reduced
                    ? { opacity: 0 }
                    : { y: "0.9em", opacity: 0, filter: "blur(2px)" }
                }
                animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
                exit={
                  reduced
                    ? { opacity: 0 }
                    : { y: "-0.9em", opacity: 0, filter: "blur(2px)" }
                }
                transition={{
                  type: "spring",
                  duration: 0.35,
                  bounce: 0,
                  delay: i * 0.03,
                }}
              >
                {char}
              </motion.span>
            </AnimatePresence>
          </span>
        ))}
      </span>
    </span>
  );
}

export interface PricingTiersProps extends React.ComponentProps<"section"> {
  eyebrow?: string;
  headline?: string;
  subcopy?: string;
}

/**
 * Three-tier pricing section: a monthly/annual segmented toggle rolls the
 * price digits on switch, the recommended tier carries a ring and slight
 * scale, and feature checklists stagger in on first reveal.
 */
export function PricingTiers({
  eyebrow = "Pricing",
  headline = "Plans that scale with your traffic",
  subcopy = "Every plan includes unlimited services, OpenTelemetry ingest, and usage-based overage — never per-host pricing.",
  className,
  ...props
}: PricingTiersProps) {
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px" });
  const reduced = useReducedMotion();
  const revealed = inView || !!reduced;
  const [period, setPeriod] = React.useState("annual");

  return (
    <section
      ref={ref}
      aria-labelledby="pricing-tiers-headline"
      className={cn("w-full bg-background px-4 py-16 sm:px-6 md:py-24", className)}
      {...props}
    >
      <div className="mx-auto w-full max-w-5xl">
        <div
          className={cn(
            "mx-auto max-w-2xl text-center",
            REVEAL,
            revealed ? SHOWN : HIDDEN,
          )}
        >
          <p className="text-sm font-medium text-muted-foreground">{eyebrow}</p>
          <h2
            id="pricing-tiers-headline"
            className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl"
          >
            {headline}
          </h2>
          <p className="mt-4 text-base text-pretty text-muted-foreground">
            {subcopy}
          </p>
        </div>

        <div
          className={cn(
            "mt-8 flex flex-col items-center gap-2",
            REVEAL,
            revealed ? SHOWN : HIDDEN,
          )}
          style={{ transitionDelay: "80ms" }}
        >
          <SegmentedControl
            value={period}
            onValueChange={setPeriod}
            aria-label="Billing period"
          >
            <SegmentedControlItem value="monthly">Monthly</SegmentedControlItem>
            <SegmentedControlItem value="annual">Annual</SegmentedControlItem>
          </SegmentedControl>
          <p className="text-xs text-muted-foreground">
            Save around 20% with annual billing
          </p>
        </div>

        <div className="mt-10 grid gap-4 md:grid-cols-3 md:gap-5">
          {TIERS.map((tier, i) => {
            const cardDelay = 160 + i * 80;
            return (
              <div
                key={tier.name}
                className={cn(
                  "relative flex flex-col rounded-xl bg-card p-6 shadow-border",
                  tier.recommended && "ring-1 ring-primary md:scale-[1.02]",
                  REVEAL,
                  revealed ? SHOWN : HIDDEN,
                )}
                style={{ transitionDelay: `${cardDelay}ms` }}
              >
                {tier.recommended && (
                  <span className="absolute -top-2.5 left-1/2 -translate-x-1/2 rounded-full bg-primary px-2.5 py-0.5 text-[11px] font-medium whitespace-nowrap text-primary-foreground">
                    Most popular
                  </span>
                )}

                <h3 className="text-sm font-medium">{tier.name}</h3>
                <p className="mt-1 min-h-10 text-sm text-pretty text-muted-foreground">
                  {tier.description}
                </p>

                <div className="mt-5 flex items-baseline gap-1.5">
                  <RollingAmount
                    amount={period === "annual" ? tier.annual : tier.monthly}
                  />
                  <span className="text-sm text-muted-foreground">
                    / user / mo
                  </span>
                </div>
                <p className="mt-1 text-xs text-muted-foreground">
                  {period === "annual"
                    ? "Billed annually"
                    : "Billed monthly"}
                </p>

                <Button
                  variant={tier.ctaVariant}
                  className="mt-5 w-full"
                  size="default"
                >
                  {tier.cta}
                </Button>

                <ul className="mt-6 space-y-2.5 border-t border-border pt-5">
                  {tier.features.map((feature, j) => (
                    <li
                      key={feature}
                      className={cn(
                        "flex items-start gap-2 text-sm text-muted-foreground",
                        REVEAL,
                        revealed ? SHOWN : HIDDEN,
                      )}
                      style={{
                        transitionDelay: `${cardDelay + 120 + j * 40}ms`,
                      }}
                    >
                      <Check
                        aria-hidden
                        className="mt-0.5 size-4 shrink-0 text-foreground"
                        strokeWidth={1.75}
                      />
                      {feature}
                    </li>
                  ))}
                </ul>
              </div>
            );
          })}
        </div>

        <p
          className={cn(
            "mt-8 text-center text-xs text-muted-foreground",
            REVEAL,
            revealed ? SHOWN : HIDDEN,
          )}
          style={{ transitionDelay: "400ms" }}
        >
          All prices in USD. Need more than 500 services?{" "}
          <a href="#" className="font-medium text-foreground underline-offset-4 hover:underline">
            Talk to us about Enterprise
          </a>
          .
        </p>
      </div>
    </section>
  );
}