bottega

sections

Pricing Table

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.

Starter

For solo builders.

$12/mo
  • 3 projects, included
  • Community support, included
  • Custom domains, not included
Get Starter

Pro

For growing teams.

$29/mo
  • Unlimited projects, included
  • Priority support, included
  • Custom domains, included
Get Pro

Scale

For organizations.

$79/mo
  • Unlimited projects, included
  • Dedicated support, included
  • Advanced analytics, included
Get Scale

Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/pricing-table

Usage

Usagetsx
<PricingTable plans={[]} />

Props

PropTypeDefaultDescription
plans*PricingPlan[]
defaultBillingenummonthlyBilling period selected on mount. Default "monthly".
onCtaClick(planId: string, billing: Billing) => voidCalled when a CTA is activated. Omit to let ctaHref drive navigation.
annualDiscountLabelstringBadge text shown next to the annual toggle when active. Omit to hide.
showBillingTogglebooleantrueRender 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`.
classNamestring

Source

pricing-table.tsxtsx
"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>
  );
}

Dependencies

  • motion
  • @bottega/tokens