Starter
For solo builders.
- 3 projects, included
- Community support, included
- Custom domains, not included
sections
A three-tier pricing section with a living-spotlight featured card. The recommended plan sits elevated with a luminous ring border that pulses once on in-view entry. A shared-layoutId billing toggle slides the active pill under its label while the price performs an asymmetric blur-wipe on each switch, and the cards enter on scroll as a staggered cascade with the featured card surfacing 20ms ahead. Each plan can opt out of the numeric price with `overridePrice` (a verbatim label like "$0", "Free", or "Custom", rendered with no /mo|/yr suffix), and the whole toggle can be hidden with `showBillingToggle={false}` for a single-price product (billing then stays at `defaultBilling`). Fully accessible: grouped toggle with aria-pressed, per-card articles, included/excluded features announced to AT, unique CTA labels, focus-visible rings, and an aria-live discount badge.
For solo builders.
For growing teams.
For organizations.
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/pricing-table<PricingTable plans={[]} />| Prop | Type | Default | Description |
|---|---|---|---|
| plans* | PricingPlan[] | — | |
| defaultBilling | enum | monthly | Billing period selected on mount. Default "monthly". |
| onCtaClick | (planId: string, billing: Billing) => void | — | Called when a CTA is activated. Omit to let ctaHref drive navigation. |
| annualDiscountLabel | string | — | Badge text shown next to the annual toggle when active. Omit to hide. |
| showBillingToggle | boolean | true | Render the monthly/annual billing toggle. Default true. When false, the toggle is omitted entirely (for a free-only / single-price product) and billing stays fixed at `defaultBilling`. |
| className | string |
"use client";
import type React from "react";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import styles from "./pricing-table.module.css";
type Billing = "monthly" | "annual";
export interface PricingFeature {
label: string;
included: boolean;
}
export interface PricingPlan {
id: string;
name: string;
description: string;
monthlyPrice: number;
annualPrice: number;
/**
* Verbatim price label that overrides the numeric `${monthlyPrice}`/`${annualPrice}`
* display (e.g. "$0", "Free", "Your time", "Custom"). When set, the billing suffix
* (`/mo`|`/yr`) is suppressed and the label is billing-independent. Unset = numeric.
*/
overridePrice?: string;
features: PricingFeature[];
ctaLabel: string;
ctaHref: string;
featured?: boolean;
}
export interface PricingTableProps {
plans: PricingPlan[];
/** Billing period selected on mount. Default "monthly". */
defaultBilling?: Billing;
/** Called when a CTA is activated. Omit to let ctaHref drive navigation. */
onCtaClick?: (planId: string, billing: Billing) => void;
/** Badge text shown next to the annual toggle when active. Omit to hide. */
annualDiscountLabel?: string;
/**
* Render the monthly/annual billing toggle. Default true. When false, the toggle
* is omitted entirely (for a free-only / single-price product) and billing stays
* fixed at `defaultBilling`.
*/
showBillingToggle?: boolean;
className?: string;
}
// Motion JS timings are in SECONDS and are bespoke micro-interaction values —
// distinct from the --duration/--ease CSS UI-transition tokens (≈300ms).
const ENTRY_DURATION = 0.4; // staggered card cascade
const STAGGER_GAP = 0.06; // 60ms between flanking cards
const FEATURED_LEAD = 0.02; // featured surfaces 20ms earlier
const PRICE_OUT = 0.08; // blur-wipe exit
const PRICE_IN = 0.14; // blur-wipe settle (asymmetric, slower in)
const PULSE_DURATION = 0.6; // one-shot featured ring/scale pulse
const EASE = [0.22, 1, 0.36, 1] as const; // matches --ease curve
function priceFor(plan: PricingPlan, billing: Billing) {
return billing === "annual" ? plan.annualPrice : plan.monthlyPrice;
}
function cx(...parts: (string | false | undefined)[]) {
return parts.filter(Boolean).join(" ");
}
export function PricingTable({
plans,
defaultBilling = "monthly",
onCtaClick,
annualDiscountLabel,
showBillingToggle = true,
className = "",
}: PricingTableProps) {
const [billing, setBilling] = useState<Billing>(defaultBilling);
// Mount-gate + null-safe reduced-motion gate (contract snippet).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
return (
<section
className={cx(
"w-full bg-background text-foreground",
"px-6 py-16 sm:py-20",
className,
)}
>
{showBillingToggle && (
<BillingToggle
billing={billing}
onChange={setBilling}
annualDiscountLabel={annualDiscountLabel}
animate={animate}
/>
)}
<div
className={cx(
"mx-auto grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:items-start",
// Top gap separates cards from the toggle; drop it when the toggle is hidden.
showBillingToggle && "mt-12",
)}
>
{plans.map((plan, i) => (
<PlanCard
key={plan.id}
plan={plan}
index={i}
billing={billing}
animate={animate}
onCtaClick={onCtaClick}
/>
))}
</div>
</section>
);
}
function BillingToggle({
billing,
onChange,
annualDiscountLabel,
animate,
}: {
billing: Billing;
onChange: (b: Billing) => void;
annualDiscountLabel?: string;
animate: boolean;
}) {
const options: { value: Billing; label: string }[] = [
{ value: "monthly", label: "Monthly" },
{ value: "annual", label: "Annual" },
];
return (
<div className="flex flex-col items-center gap-3">
<div
role="group"
aria-label="Billing period"
className="relative inline-flex rounded-[var(--radius)] border border-border bg-muted p-1"
>
{options.map((opt) => {
const active = billing === opt.value;
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => onChange(opt.value)}
className={cx(
"relative z-10 rounded-[calc(var(--radius)-0.25rem)] px-5 py-2 text-sm font-medium",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
active ? "text-primary-foreground" : "text-foreground",
)}
>
{/* Active pill slides under the label via shared layoutId. */}
{active &&
(animate ? (
<motion.span
layoutId="billing-pill"
aria-hidden="true"
className="absolute inset-0 -z-10 rounded-[calc(var(--radius)-0.25rem)] bg-primary"
transition={{ duration: 0.25, ease: EASE }}
/>
) : (
// Reduced motion / pre-mount: plain opacity swap, no slide.
<span
aria-hidden="true"
className="absolute inset-0 -z-10 rounded-[calc(var(--radius)-0.25rem)] bg-primary"
/>
))}
{opt.label}
</button>
);
})}
</div>
{/* aria-live so the badge toggle reaches AT without a full re-read. */}
<div aria-live="polite" className="h-6">
{annualDiscountLabel && billing === "annual" && (
<AnnualBadge animate={animate}>{annualDiscountLabel}</AnnualBadge>
)}
</div>
</div>
);
}
function AnnualBadge({
children,
animate,
}: {
children: React.ReactNode;
animate: boolean;
}) {
const content = (
<span className="inline-flex items-center rounded-full border border-border bg-accent px-3 py-1 text-xs font-medium text-foreground">
{children}
</span>
);
if (!animate) return content;
return (
<motion.span
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2, ease: EASE }}
className="inline-block"
>
{content}
</motion.span>
);
}
function PlanCard({
plan,
index,
billing,
animate,
onCtaClick,
}: {
plan: PricingPlan;
index: number;
billing: Billing;
animate: boolean;
onCtaClick?: (planId: string, billing: Billing) => void;
}) {
const featured = !!plan.featured;
const lift = featured ? -8 : 0;
// Featured surfaces FEATURED_LEAD earlier than its stagger slot.
const delay = index * STAGGER_GAP - (featured ? FEATURED_LEAD : 0);
const cardBg = featured ? "bg-primary" : "bg-background";
const cardText = featured ? "text-primary-foreground" : "text-foreground";
// Entry settles to the lifted resting position; the featured card adds a single
// scale pulse keyframe (1 -> 1.015 -> 1) tacked onto entry — driven by Motion so
// it shares the same inline transform channel as `y` (no CSS/Motion collision).
const enterTarget = featured
? { opacity: 1, y: lift, scale: [1, 1, 1.015, 1] }
: { opacity: 1, y: lift };
const motionProps = animate
? {
initial: { opacity: 0, y: 24, scale: 1 },
whileInView: enterTarget,
viewport: { once: true, amount: 0.3 as const },
transition: {
duration: ENTRY_DURATION,
delay: Math.max(delay, 0),
ease: EASE,
// Pulse runs across the entry window, peaking after the lift settles.
scale: featured
? {
duration: ENTRY_DURATION + PULSE_DURATION,
delay: Math.max(delay, 0),
times: [0, 0.4, 0.7, 1],
ease: EASE,
}
: undefined,
},
whileHover: { y: lift - 4 },
}
: {};
return (
<motion.article
{...motionProps}
// Pre-mount/reduced-motion: featured stays statically elevated via the
// co-located CSS module class (.featuredStatic), see className below.
aria-label={`${plan.name} plan`}
{...(featured ? { "aria-description": "Recommended plan" } : {})}
className={cx(
"group relative flex flex-col gap-6 p-6",
"rounded-[var(--radius)]",
cardBg,
cardText,
featured ? "border" : "border border-border",
// ring-ring border at partial opacity; deepens on hover.
featured && "border-[oklch(from_var(--ring)_l_c_h_/_0.3)] hover:border-[oklch(from_var(--ring)_l_c_h_/_0.7)]",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
// Reduced-motion / pre-mount featured fallback keeps a static elevated ring.
!animate && featured && styles.featuredStatic,
)}
>
<header className="flex flex-col gap-2">
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className={cx("text-sm", featured ? "opacity-80" : "opacity-70")}>
{plan.description}
</p>
</header>
<PriceDisplay plan={plan} billing={billing} animate={animate} />
<ul className="flex flex-col gap-2">
{plan.features.map((feat, i) => (
<FeatureRow key={i} feature={feat} featured={featured} zebra={i % 2 === 1} />
))}
</ul>
<CtaButton plan={plan} billing={billing} featured={featured} onCtaClick={onCtaClick} />
</motion.article>
);
}
function PriceDisplay({
plan,
billing,
animate,
}: {
plan: PricingPlan;
billing: Billing;
animate: boolean;
}) {
// overridePrice renders verbatim (e.g. "$0", "Free") with no /mo|/yr suffix and is
// billing-independent; the numeric path keeps the `${value}` + suffix behavior.
const override = plan.overridePrice;
const display = override ?? `$${priceFor(plan, billing)}`;
const suffix = override ? undefined : billing === "annual" ? "/yr" : "/mo";
const number = (
<span className="text-4xl font-bold tracking-tight tabular-nums">
{display}
</span>
);
return (
<div className="flex items-end gap-1">
{animate ? (
// mode="wait" so exit completes before the new number wipes in.
// Numeric prices key on billing so the wipe always runs on a toggle
// (even when monthly/annual render the same number); an override is
// billing-independent, so it keys on its display string and never
// re-keys on a billing change — no needless wipe.
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={
override === undefined
? `${plan.id}-${billing}`
: `${plan.id}-${display}`
}
// blur-wipe: asymmetric (fast exit, slower settle) so it feels intentional.
// filter:blur is compositor-friendly and is the brief's mandated effect.
initial={{ y: 8, opacity: 0, filter: "blur(4px)" }}
animate={{
y: 0,
opacity: 1,
filter: "blur(0px)",
transition: { duration: PRICE_IN, ease: EASE },
}}
exit={{
y: -6,
opacity: 0,
filter: "blur(4px)",
transition: { duration: PRICE_OUT, ease: EASE },
}}
className="inline-block"
>
{number}
</motion.span>
</AnimatePresence>
) : (
// Reduced motion: instant text swap, no blur-wipe.
number
)}
{suffix && <span className="pb-1 text-sm opacity-70">{suffix}</span>}
</div>
);
}
function FeatureRow({
feature,
featured,
zebra,
}: {
feature: PricingFeature;
featured: boolean;
zebra: boolean;
}) {
const { label, included } = feature;
return (
<li
className={cx(
"flex items-center gap-3 rounded-[calc(var(--radius)-0.375rem)] px-2 py-1.5 text-sm",
// Alternating row tint; muted inside non-featured, faint white inside featured.
zebra && !featured && "bg-muted",
zebra && featured && "bg-[oklch(from_var(--primary-foreground)_l_c_h_/_0.08)]",
!included && "opacity-55",
)}
>
{included ? (
// Filled dot: bg-primary-foreground inside featured, bg-accent inside non-featured.
<span
aria-hidden="true"
className={cx(
"h-2.5 w-2.5 shrink-0 rounded-full",
featured ? "bg-primary-foreground" : "bg-accent",
)}
/>
) : (
// Bare border ring for excluded — no red/green.
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full border border-border"
/>
)}
<span aria-label={`${label}, ${included ? "included" : "not included"}`}>
{label}
<span className="sr-only">{included ? ", included" : ", not included"}</span>
</span>
</li>
);
}
function CtaButton({
plan,
billing,
featured,
onCtaClick,
}: {
plan: PricingPlan;
billing: Billing;
featured: boolean;
onCtaClick?: (planId: string, billing: Billing) => void;
}) {
// Unique, descriptive label per card for unambiguous SR nav.
const label = plan.ctaLabel || `Get ${plan.name}`;
const baseClasses = cx(
"mt-auto inline-flex w-full items-center justify-center",
"rounded-[calc(var(--radius)-0.25rem)] px-5 py-2.5 text-sm font-medium",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
featured
? // Knockout: inverts the featured card's bg-primary surface.
"bg-background text-foreground hover:opacity-90"
: // Non-featured: muted at rest, accent on hover, primary intent via group.
"bg-muted text-foreground hover:bg-accent group-hover:bg-primary group-hover:text-primary-foreground",
);
if (onCtaClick) {
return (
<button
type="button"
onClick={() => onCtaClick(plan.id, billing)}
className={baseClasses}
>
{label}
</button>
);
}
return (
<a href={plan.ctaHref} className={baseClasses}>
{label}
</a>
);
}