Three-tier pricing with a monthly/annual toggle that rolls price digits, a ringed recommended tier, and staggered feature checklists.
npx shadcn@latest add @paragon/pricing-tiersAlso installs: button, segmented-control
"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>
);
}