Files
"use client";
import { ArrowUpRight, Check } from "lucide-react";
import FluidWave from "./fluid-wave";
type Plan = {
name: string;
description: string;
price: number;
features: readonly string[];
cta: string;
href: string;
popular?: boolean;
};
const PLANS: readonly Plan[] = [
{
name: "Core",
description: "For individuals turning focused ideas into real products.",
price: 12,
features: [
"3 active projects",
"Unlimited collaborators",
"5 GB file storage",
"Community support",
],
cta: "Start with Core",
href: "/signup?plan=core",
},
{
name: "Studio",
description: "For ambitious teams shipping together, without the busywork.",
price: 29,
features: [
"Unlimited projects",
"Advanced permissions",
"100 GB file storage",
"Priority support",
],
cta: "Choose Studio",
href: "/signup?plan=studio",
popular: true,
},
];
function PlanCard({ plan }: { plan: Plan }) {
return (
<article
aria-label={`${plan.name} plan`}
className={`relative isolate flex min-h-[34rem] overflow-hidden rounded-[2rem] border p-7 sm:p-9 ${
plan.popular
? "border-white/10 bg-[#0a0a0a] text-white shadow-[0_24px_70px_-32px_rgba(252,76,1,0.42)]"
: "border-border bg-card text-card-foreground shadow-[0_24px_70px_-42px_rgba(15,23,42,0.3)]"
}`}
>
{plan.popular ? (
<>
<div
aria-hidden="true"
className="absolute inset-0 -z-20 bg-[radial-gradient(circle_at_70%_100%,rgba(252,76,1,0.66),transparent_66%)]"
/>
<div aria-hidden="true" className="absolute inset-0 -z-10">
<FluidWave />
</div>
<div
aria-hidden="true"
className="absolute inset-0 -z-10 bg-[linear-gradient(to_bottom,#0a0a0a_8%,rgba(10,10,10,0.9)_38%,rgba(10,10,10,0.36)_72%,rgba(10,10,10,0.08))]"
/>
</>
) : null}
<div className="flex w-full flex-col">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-medium text-2xl tracking-[-0.03em]">
{plan.name}
</h3>
<p
className={`mt-3 max-w-sm text-sm leading-6 ${
plan.popular ? "text-white/62" : "text-muted-foreground"
}`}
>
{plan.description}
</p>
</div>
{plan.popular ? (
<span className="shrink-0 whitespace-nowrap rounded-full border border-white/15 bg-white/10 px-3 py-1 font-medium text-[0.68rem] text-white/90 uppercase tracking-[0.14em] backdrop-blur-md">
Most popular
</span>
) : null}
</div>
<div className="mt-10 flex items-end gap-2">
<span className="font-medium text-5xl tracking-[-0.055em]">
${plan.price}
</span>
<span
className={`pb-1 text-sm ${
plan.popular ? "text-white/58" : "text-muted-foreground"
}`}
>
per month
</span>
</div>
<div
className={`my-8 h-px ${plan.popular ? "bg-white/12" : "bg-border"}`}
/>
<ul className="space-y-4">
{plan.features.map((feature) => (
<li className="flex items-center gap-3 text-sm" key={feature}>
<span
className={`flex size-5 shrink-0 items-center justify-center rounded-full ${
plan.popular ? "bg-white/14" : "bg-muted"
}`}
>
<Check
aria-hidden="true"
className="size-3"
strokeWidth={2.5}
/>
</span>
<span className={plan.popular ? "text-white/82" : undefined}>
{feature}
</span>
</li>
))}
</ul>
<a
className={`group mt-auto inline-flex h-12 touch-manipulation items-center justify-between rounded-full px-5 font-medium text-sm outline-none transition-[transform,background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:transition-none ${
plan.popular
? "bg-white text-[#111217] [@media(hover:hover)_and_(pointer:fine)]:hover:bg-white/88"
: "bg-foreground text-background [@media(hover:hover)_and_(pointer:fine)]:hover:bg-foreground/86"
}`}
href={plan.href}
>
{plan.cta}
<span className="flex size-7 items-center justify-center rounded-full bg-current/10">
<ArrowUpRight aria-hidden="true" className="size-3.5" />
</span>
</a>
</div>
</article>
);
}
export function Pricing03() {
return (
<section className="relative overflow-hidden bg-background px-4 py-20 text-foreground sm:px-6 sm:py-28 lg:px-8">
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-border to-transparent"
/>
<div className="mx-auto max-w-5xl">
<div className="mb-12 grid gap-7 md:mb-16 md:grid-cols-[1fr_0.72fr] md:items-end">
<h2 className="max-w-xl text-balance font-medium text-4xl tracking-[-0.045em] sm:text-5xl sm:leading-[1.02]">
Plans that scale with the work.
</h2>
<p className="max-w-md text-pretty text-muted-foreground text-sm leading-6 md:justify-self-end">
Start small, then move up when your team is ready. Every plan
includes a 14-day trial and no long-term contract.
</p>
</div>
<div className="grid gap-5 lg:grid-cols-2">
{PLANS.map((plan) => (
<PlanCard key={plan.name} plan={plan} />
))}
</div>
<p className="mt-7 text-center text-muted-foreground text-xs">
Prices are in USD. Cancel or change your plan at any time.
</p>
</div>
</section>
);
}
A focused two-plan pricing section with an ambient fluid treatment for the popular plan
@tentui/pricing-03
Files
"use client";
import { ArrowRight, Check } from "lucide-react";
import { useInView, useMotionValueEvent, useSpring } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { DashboardIcon, MousePointerClick01Icon, RocketIcon } from "./icons";
export type PricingPlan = {
title: string;
description: string;
price: number;
period: string;
features: string[];
cta: string;
ctaLink: string;
isHorizontal?: boolean;
showCustomBadge?: boolean;
showStartsAt?: boolean;
icon?: React.ReactNode;
};
// ── Tab categories with their plans ──────────────────────────────────
type TabCategory = {
label: string;
value: string;
plans: PricingPlan[];
};
const tabCategories: TabCategory[] = [
{
label: "Landing",
value: "landing",
plans: [
{
title: "Landing Page",
icon: (
<MousePointerClick01Icon
intervalDuration={2000}
className="size-6 text-zinc-900 dark:text-zinc-100"
/>
),
description: "Perfect to build your brand and get leads.",
price: 1995,
period: "one time",
features: [
"Design + Development",
"Search Engine Optimization",
"Responsive Design",
"4-7 days turnaround time",
"90+ Web vitals performance",
],
cta: "Book a Call",
ctaLink: "#",
},
],
},
{
label: "Full Site",
value: "fullsite",
plans: [
{
title: "Multi Page Website",
icon: (
<DashboardIcon
intervalDuration={2000}
className="size-6 text-zinc-900 dark:text-zinc-100"
/>
),
description: "Best for startups and businesses.",
price: 2995,
period: "/mo",
features: [
"Design + Development",
"Search Engine Optimization",
"Responsive Design",
"+$300 per additional page",
"7-10 days turnaround time",
],
cta: "Book a Call",
ctaLink: "#",
},
],
},
{
label: "Product",
value: "product",
plans: [
{
title: "MVP Development",
icon: (
<RocketIcon
intervalDuration={2000}
className="size-6 text-zinc-900 dark:text-zinc-100"
/>
),
description: "Ideal for quick product validation",
price: 2995,
period: "one time",
features: [
"Design + Development + Deployment",
"Core Feature Implementation",
"Responsive Design",
"Idea to Production",
"10-15 days turnaround time",
],
cta: "Book a Call",
ctaLink: "#",
},
],
},
];
// ── Static "Most Popular" plan (always visible) ──────────────────────
const mostPopularPlan: PricingPlan = {
title: "Design/Dev Retainer",
description:
"A dedicated engineer for a fraction of the cost of a full-time hire.",
price: 2995,
period: "/mo",
features: [
"Unlimited design & dev requests",
"Average 48-hr turnaround",
"Dedicated async channel",
"Pause or cancel anytime",
],
cta: "Get Started",
ctaLink: "#",
isHorizontal: true,
showCustomBadge: true,
};
function formatPrice(price: number) {
return price.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
});
}
const PRICE_SPRING = {
stiffness: 80,
damping: 14,
mass: 0.5,
};
export function PricingCard({ plan }: { plan: PricingPlan }) {
const priceRef = useRef<HTMLDivElement>(null);
const isInView = useInView(priceRef, { once: true });
const priceSpring = useSpring(0, PRICE_SPRING);
const [springyPrice, setSpringyPrice] = useState(0);
useMotionValueEvent(priceSpring, "change", (latest) => {
setSpringyPrice(Number(latest.toFixed(0)));
});
useEffect(() => {
if (isInView) {
priceSpring.set(plan.price ?? 0);
}
}, [isInView, plan.price, priceSpring]);
if (plan.isHorizontal) {
return (
<div className="relative w-full md:col-span-2">
<style>{`
.agency-pricing-badge::before {
content: 'Most Popular';
position: absolute;
width: 150%;
height: 40px;
background-image: linear-gradient(45deg, #ff6547 0%, #ffb144 51%, #ff7053 100%);
transform: rotate(-45deg) translateY(-20px);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
box-shadow: 0 5px 10px rgba(0,0,0,0.23);
}
.agency-pricing-badge::after {
content: '';
position: absolute;
width: 10px;
bottom: 0;
left: 0;
height: 10px;
z-index: -1;
box-shadow: 140px -140px #cc3f47;
background-image: linear-gradient(45deg, #FF512F 0%, #F09819 51%, #FF512F 100%);
}
`}</style>
{/* Custom Badge */}
{plan.showCustomBadge && (
<span className="agency-pricing-badge absolute -top-2.5 -left-2.5 z-20 flex h-[150px] w-[150px] items-center justify-center overflow-hidden" />
)}
<div className="overflow-hidden rounded-[23px] border p-1">
<div className="grid gap-6 rounded-[22px] bg-gradient-to-b from-zinc-50 to-zinc-100 p-4 md:grid-cols-[1fr_auto] dark:from-zinc-900 dark:to-zinc-900">
<div className="flex flex-col">
<div className="mb-10 flex flex-col">
<div
className={`flex items-start justify-between gap-2 ${plan.showCustomBadge ? "pt-9 pl-9" : ""}`}
>
<div className="flex flex-col gap-2">
{plan.icon && <div>{plan.icon}</div>}
<h2 className="mb-2 font-medium text-[24px]">
{plan.title}
</h2>
</div>
</div>
<p className="max-w-md text-muted-foreground text-sm">
{plan.description}
</p>
</div>
<div ref={priceRef} className="mb-6">
<p className="mb-1 text-muted-foreground text-sm">starts at</p>
<div className="flex items-baseline gap-2">
<span className="font-medium text-4xl">
{formatPrice(springyPrice)}
</span>
{plan.period && (
<span className="text-muted-foreground text-sm">
{plan.period}
</span>
)}
</div>
</div>
<ul className="mb-10 grid grid-cols-1 gap-3 md:grid-cols-2">
{plan.features.map((feature, featureIndex) => (
<li
key={featureIndex}
className="flex w-full items-start gap-3 text-sm"
>
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground" />
<span>{feature}</span>
</li>
))}
</ul>
<CardButton
ctaLink={plan.ctaLink}
cta={plan.cta}
className="w-full"
/>
</div>
</div>
</div>
</div>
);
}
return (
<div className="rounded-[23px] border border-border p-1">
{/* Header with Icon and Title */}
<div className="rounded-[22px] bg-gradient-to-b from-zinc-50 to-zinc-100 p-3 dark:from-zinc-900 dark:to-zinc-900">
<div className="mb-2 flex flex-col gap-2">
{plan.icon && <div className="mb-1">{plan.icon}</div>}
<h2 className="mb-1 font-medium text-lg">{plan.title}</h2>
</div>
{/* Description Box */}
<div className="mb-4 rounded-lg bg-muted p-2 text-muted-foreground text-xs">
{plan.description}
</div>
{/* Features List */}
<ul className="mb-4 space-y-2">
{plan.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-2 text-xs">
<Check className="mt-0.5 size-3.5 shrink-0 text-foreground" />
<span className="min-w-0 text-foreground">{feature}</span>
</li>
))}
</ul>
</div>
{/* Footer Buttons */}
<div className="flex flex-col gap-3 p-3">
{/* Pricing */}
<div
ref={priceRef}
className="flex flex-wrap items-baseline gap-1.5 px-1"
>
<span className="font-bold text-2xl text-foreground">
{formatPrice(springyPrice)}
</span>
{plan.period && (
<span className="text-muted-foreground text-xs">{plan.period}</span>
)}
</div>
<div className="flex flex-col gap-2 px-1">
<CardButton
ctaLink={plan.ctaLink}
cta={plan.cta}
className="w-full"
/>
</div>
</div>
</div>
);
}
function CardButton({
ctaLink,
cta,
className,
}: {
ctaLink: string;
cta: string;
className?: string;
}) {
function handleClick() {
if (ctaLink === "#") return;
if (ctaLink.startsWith("http")) {
window.open(ctaLink, "_blank", "noopener,noreferrer");
return;
}
window.location.assign(ctaLink);
}
return (
<button
type="button"
onClick={handleClick}
className={`group/agency-cta inline-flex h-10 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-foreground/10 bg-foreground bg-clip-padding px-4 font-medium text-background text-sm shadow-[inset_0_1px_0_rgb(255_255_255_/_0.16),0_1px_2px_rgb(0_0_0_/_0.2)] outline-none transition-[transform,background-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-foreground/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none ${className ?? ""}`}
>
<span className="text-sm">{cta}</span>
<span className="block h-4 w-px bg-background/25" />
<span
aria-hidden="true"
className="size-6 overflow-hidden rounded-full bg-background/10"
>
<span className="flex w-12 -translate-x-1/2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/agency-cta:translate-x-0 motion-reduce:transform-none motion-reduce:transition-none">
<span className="flex size-6 shrink-0">
<ArrowRight className="m-auto size-3" />
</span>
<span className="flex size-6 shrink-0">
<ArrowRight className="m-auto size-3" />
</span>
</span>
</span>
</button>
);
}
// ── Component ────────────────────────────────────────────────────────
export function Pricing02() {
const [activeTab, setActiveTab] = useState("landing");
const activeCategory =
tabCategories.find((category) => category.value === activeTab) ??
tabCategories[0];
return (
<div
id="pricing"
className="mx-auto max-w-4xl space-y-6 border border-border px-4 py-12 md:space-y-8 md:py-16"
>
{/* Section Header */}
<div className="mb-8 text-center md:mb-16">
<h1 className="mb-3 font-medium text-2xl md:mb-4 md:text-4xl">
No Contract, No Surprises
</h1>
<p className="mx-auto max-w-2xl px-2 text-muted-foreground text-sm md:px-0 md:text-base">
Consistent Pricing and Value Each Month, with the Flexibility to
Cancel Anytime
</p>
</div>
{/* Two-column layout: tabs + plans (left) | static popular (right) */}
<div className="grid grid-cols-1 gap-6 border-border border-y py-4 md:grid-cols-2 md:gap-8 md:py-6">
{/* ── Left: Tab Navigation + Dynamic Plans ── */}
<div className="flex flex-col gap-4">
<div
role="group"
aria-label="Project type"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
>
{tabCategories.map((category) => {
const isActive = category.value === activeTab;
return (
<button
key={category.value}
type="button"
aria-pressed={isActive}
onClick={() => setActiveTab(category.value)}
className={`inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap rounded-md border px-2 py-1 font-medium text-sm outline-none transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none ${
isActive
? "border-transparent bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30"
: "border-transparent text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground"
}`}
>
{category.label}
</button>
);
})}
</div>
<div className="mt-2 flex flex-col gap-4">
{activeCategory.plans.map((plan) => (
<PricingCard key={plan.title} plan={plan} />
))}
</div>
</div>
{/* ── Right: Static "Most Popular" Plan ── */}
<div className="flex flex-col">
<PricingCard plan={mostPopularPlan} />
</div>
</div>
</div>
);
}
Tabbed project pricing paired with a highlighted ongoing retainer
@tentui/pricing-02
Files
"use client";
import { CheckIcon } from "lucide-react";
import { MotionConfig, motion } from "motion/react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
type AnimatedTab,
AnimatedTabs,
} from "@/components/animated-tabs";
type BillingCycle = "monthly" | "annual";
type PlanId = "starter" | "growth" | "scale";
type LimitKey = "workspaces" | "ticketsPerMonth" | "agentCredits";
type PlanLimits = Record<LimitKey, number>;
type PricingPlan = {
id: PlanId;
name: string;
tagline: string;
monthly: number;
annual: number;
unit: string;
popular?: boolean;
limits: PlanLimits;
features: readonly string[];
};
const UNLIMITED = Number.POSITIVE_INFINITY;
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const USD_PRICE_FORMATTER = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const PLANS: readonly PricingPlan[] = [
{
id: "starter",
name: "Starter",
tagline: "For small businesses and startups.",
monthly: 12,
annual: 9,
unit: "/mo.",
limits: {
workspaces: 3,
ticketsPerMonth: 1_000,
agentCredits: 800,
},
features: [
"1,000 tickets every month",
"800 AI credits every month",
"3 workspaces",
"Real-time customer chat",
"Add more AI credits anytime",
],
},
{
id: "growth",
name: "Growth",
tagline: "For growing support teams.",
monthly: 19,
annual: 15,
unit: "/mo.",
popular: true,
limits: {
workspaces: 5,
ticketsPerMonth: 10_000,
agentCredits: 2_000,
},
features: [
"Everything in Starter",
"10,000 tickets every month",
"2,000 AI credits every month",
"Up to 5 workspaces",
"Add more AI credits anytime",
],
},
{
id: "scale",
name: "Scale",
tagline: "For established organizations.",
monthly: 39,
annual: 31,
unit: "/mo.",
limits: {
workspaces: UNLIMITED,
ticketsPerMonth: 50_000,
agentCredits: 10_000,
},
features: [
"Everything in Growth",
"50,000 tickets every month",
"10,000 AI credits every month",
"Unlimited workspaces",
"Priority support",
],
},
];
const LIMIT_ROWS: ReadonlyArray<{ label: string; key: LimitKey }> = [
{ label: "Workspaces", key: "workspaces" },
{ label: "Tickets / month", key: "ticketsPerMonth" },
{ label: "AI credits / month", key: "agentCredits" },
];
const MAX_SAVINGS_PERCENT = Math.max(
...PLANS.map((plan) => Math.round((1 - plan.annual / plan.monthly) * 100)),
);
const BILLING_TABS: readonly AnimatedTab[] = [
{ value: "monthly", label: "Monthly" },
{
value: "annual",
label: (
<span className="flex items-center gap-1.5">
Annual
<Badge variant="secondary">Save {MAX_SAVINGS_PERCENT}%</Badge>
</span>
),
},
];
const CTA_CLASS_NAME =
"touch-manipulation transition-[transform,background-color,color,box-shadow] duration-160 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]";
function isUnlimited(value: number) {
return !Number.isFinite(value);
}
function formatLimit(value: number) {
if (isUnlimited(value)) return "Unlimited";
return value
.toLocaleString("en-US", {
notation: "compact",
maximumFractionDigits: 1,
})
.toLowerCase();
}
function formatPrice(value: number) {
return USD_PRICE_FORMATTER.format(value);
}
function checkoutHref(plan: PricingPlan, cycle: BillingCycle) {
return `/signup?plan=${plan.id}&billing=${cycle}`;
}
function GridTick({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute z-20 flex size-4 items-center justify-center",
className,
)}
>
<span className="absolute h-px w-full bg-foreground/20" />
<span className="absolute h-full w-px bg-foreground/20" />
</span>
);
}
function GridRect({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn("pointer-events-none absolute z-20 size-1", className)}
>
<span className="block size-full bg-foreground/20" />
</span>
);
}
function PricingHeading({ level }: { level: "h1" | "h2" }) {
const className =
"text-balance font-normal text-[2.4rem] leading-[1.05] tracking-[-0.02em] sm:text-6xl";
const animation = {
initial: { opacity: 0, transform: "translateY(18px)" },
animate: { opacity: 1, transform: "translateY(0px)" },
transition: { duration: 0.55, ease: EASE_OUT },
};
if (level === "h1") {
return (
<motion.h1 className={className} {...animation}>
Choose your <em className="italic">pricing plan</em>
</motion.h1>
);
}
return (
<motion.h2
className={className}
initial={animation.initial}
transition={animation.transition}
viewport={{ once: true }}
whileInView={animation.animate}
>
Choose your <em className="italic">pricing plan</em>
</motion.h2>
);
}
export type Pricing01Props = {
headingLevel?: "h1" | "h2";
showLimits?: boolean;
};
export function Pricing01({
headingLevel = "h2",
showLimits = true,
}: Pricing01Props) {
const [cycle, setCycle] = useState<BillingCycle>("monthly");
const PlanHeading = headingLevel === "h1" ? "h2" : "h3";
return (
<MotionConfig reducedMotion="user">
<section className="relative w-full bg-background px-4 py-20 text-foreground sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-10">
<div className="mx-auto flex max-w-2xl flex-col gap-5 text-center">
<PricingHeading level={headingLevel} />
<motion.p
animate={{ opacity: 1, transform: "translateY(0px)" }}
className="mx-auto max-w-md text-balance text-base text-muted-foreground leading-relaxed"
initial={{ opacity: 0, transform: "translateY(14px)" }}
transition={{ duration: 0.55, delay: 0.08, ease: EASE_OUT }}
>
Flexible plans designed to scale with your support needs.
</motion.p>
</div>
<div className="flex justify-center">
<AnimatedTabs
aria-label="Billing cycle"
className="[&_[data-slot=animated-tabs-trigger]]:h-10 [&_[data-slot=animated-tabs-trigger]]:px-5"
layoutId="pricing-01-billing-indicator"
onValueChange={(value) => {
if (value === "monthly" || value === "annual") setCycle(value);
}}
tabs={BILLING_TABS}
value={cycle}
/>
</div>
<div className="flex flex-col gap-4">
<div className="relative grid grid-cols-1 border border-border bg-background text-foreground lg:grid-cols-3">
<GridTick className="-top-2 -left-2" />
<GridTick className="-top-2 -right-2" />
{showLimits ? null : (
<>
<GridTick className="-bottom-2 -left-2" />
<GridTick className="-right-2 -bottom-2" />
</>
)}
{PLANS.map((plan, index) => {
const price = cycle === "annual" ? plan.annual : plan.monthly;
return (
<motion.article
className={cn(
"relative flex flex-col overflow-hidden border-border py-4",
index < PLANS.length - 1 && "border-b lg:border-b-0",
index < 2 && "lg:border-r",
)}
initial={{ opacity: 0, transform: "translateY(12px)" }}
key={plan.id}
transition={{
duration: 0.4,
delay: index * 0.06,
ease: EASE_OUT,
}}
viewport={{ once: true, amount: 0.12 }}
whileInView={{
opacity: 1,
transform: "translateY(0px)",
}}
>
{plan.popular ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(125% 125% at 50% 10%, var(--background) 40%, var(--primary) 100%)",
}}
/>
) : null}
<div className="relative z-10 flex flex-1 flex-col gap-5 p-6">
<div className="flex flex-col gap-5">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex w-full items-center justify-between gap-2.5">
<PlanHeading className="font-normal text-2xl text-foreground tracking-tight">
{plan.name}
</PlanHeading>
{plan.popular ? <Badge>Popular</Badge> : null}
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{plan.tagline}
</p>
</div>
</div>
<div className="flex items-baseline gap-1.5">
<span className="font-normal text-4xl text-foreground tracking-tight">
{formatPrice(price)}
</span>
<span className="text-muted-foreground text-sm">
{plan.unit}
</span>
</div>
<p className="h-4 text-muted-foreground text-xs">
{cycle === "annual" ? "billed annually" : ""}
</p>
<a
aria-label={`Get started with the ${plan.name} plan`}
className={cn(
buttonVariants({
variant: plan.popular ? "default" : "secondary",
}),
CTA_CLASS_NAME,
"my-4 w-full",
)}
href={checkoutHref(plan, cycle)}
>
Get Started
</a>
<ul className="flex flex-col gap-3">
{plan.features.map((feature) => (
<li
className="flex items-start gap-2.5 text-foreground/80 text-sm"
key={feature}
>
<CheckIcon
aria-hidden="true"
className={cn(
"mt-0.5 size-4 shrink-0",
plan.popular
? "text-primary"
: "text-foreground",
)}
/>
<span>{feature}</span>
</li>
))}
</ul>
</div>
</motion.article>
);
})}
</div>
{showLimits ? (
<div className="relative border border-border bg-background text-foreground">
<GridTick className="-bottom-2 -left-2" />
<GridTick className="-right-2 -bottom-2" />
<div className="relative grid grid-cols-1 bg-muted/30 md:grid-cols-4">
<GridRect className="-top-[2px] -left-[2px]" />
<GridRect className="-top-[2px] -right-[3px]" />
<GridRect className="-bottom-[2px] -left-[2px]" />
<GridRect className="-right-[3px] -bottom-[2px]" />
<div className="p-4 font-medium text-muted-foreground text-xs uppercase tracking-wide">
Limits
</div>
{PLANS.map((plan) => (
<div
className="hidden border-border border-l p-4 text-center font-medium text-foreground text-sm md:block"
key={plan.id}
>
{plan.name}
</div>
))}
</div>
{LIMIT_ROWS.map((row) => (
<div
className="grid grid-cols-2 border-border border-t md:grid-cols-4"
key={row.key}
>
<div className="col-span-2 p-4 text-foreground/80 text-sm md:col-span-1">
{row.label}
</div>
{PLANS.map((plan) => {
const value = plan.limits[row.key];
return (
<div
className="flex items-center justify-between border-border p-4 text-sm md:justify-center md:border-l"
key={plan.id}
>
<span className="text-muted-foreground text-xs uppercase tracking-wide md:hidden">
{plan.name}
</span>
{isUnlimited(value) ? (
<span className="flex items-center gap-1.5 text-foreground">
<CheckIcon
aria-hidden="true"
className="size-3.5"
/>
Unlimited
</span>
) : (
<span className="text-foreground/80">
{formatLimit(value)}
</span>
)}
</div>
);
})}
</div>
))}
</div>
) : null}
</div>
</div>
</section>
</MotionConfig>
);
}
Pricing plans with a billing toggle and usage comparison
@tentui/pricing-01