Skip to content

Glass Tier Pricing

Frosted pricing tiers with a spotlight that glides to the one you hover.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/glass-tier-pricing.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/pricing/billing-toggle.tsx
    "use client";
    
    import { useId, useRef, type KeyboardEvent } from "react";
    import { LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { spring } from "@/lib/motion";
    import type { Billing } from "@/lib/pricing/types";
    
    export interface BillingToggleProps {
      /** The selected billing period. */
      value: Billing;
      /** Called when another period is picked. */
      onValueChange: (billing: Billing) => void;
      /** Periods to offer, in order. @default ["monthly", "yearly"] */
      options?: readonly Billing[];
      /** Visible text per period. @default { monthly: "Monthly", yearly: "Yearly", once: "One time" } */
      labels?: Partial<Record<Billing, string>>;
      /** Accessible name of the group. @default "Billing period" */
      label?: string;
      /** Control height: 32px or 40px (both keep a 40px hit area). @default "md" */
      size?: "sm" | "md";
      className?: string;
    }
    
    const DEFAULT_OPTIONS: readonly Billing[] = ["monthly", "yearly"];
    const DEFAULT_LABELS: Record<Billing, string> = { monthly: "Monthly", yearly: "Yearly", once: "One time" };
    
    /** A segmented radio group for the billing period, with a pill that glides to the selected option. */
    export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS, labels, label = "Billing period", size = "md", className }: BillingToggleProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const refs = useRef<(HTMLButtonElement | null)[]>([]);
    
      function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
        const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
        if (!step) return;
        event.preventDefault();
        const next = (Math.max(0, options.indexOf(value)) + step + options.length) % options.length;
        onValueChange(options[next]);
        refs.current[next]?.focus();
      }
    
      return (
        <LayoutGroup id={id}>
          <div
            role="radiogroup"
            aria-label={label}
            onKeyDown={onKeyDown}
            className={cn("inline-flex rounded-full border border-border bg-surface p-1", className)}
          >
            {options.map((option, index) => {
              const active = option === value;
              return (
                <button
                  key={option}
                  ref={(node) => {
                    refs.current[index] = node;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  tabIndex={active ? 0 : -1}
                  onClick={() => onValueChange(option)}
                  className={cn(
                    "relative inline-flex cursor-pointer items-center rounded-full font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
                    "after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']",
                    size === "sm" ? "h-7 px-3 text-xs" : "h-8 px-4 text-[13px]",
                    active ? "text-background" : "text-foreground/65 hover:text-foreground",
                  )}
                >
                  {active ? (
                    <motion.span layoutId="pill" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-full bg-foreground" />
                  ) : null}
                  <span className="relative">{labels?.[option] ?? DEFAULT_LABELS[option]}</span>
                </button>
              );
            })}
          </div>
        </LayoutGroup>
      );
    }
    lib/pricing/format.ts
    export interface PriceParts {
      /** The whole formatted price, e.g. "$1,299" or "1.299 €". */
      text: string;
      /** Currency symbol or code, e.g. "$", "€", "Rp". */
      symbol: string;
      /** Everything but the symbol, e.g. "1,299". */
      digits: string;
      /** False when the locale puts the symbol after the number. */
      symbolFirst: boolean;
      /** Fraction digits used. */
      decimals: number;
    }
    
    const formatters = new Map<string, Intl.NumberFormat>();
    
    function formatter(currency: string, locale: string, decimals: number) {
      const key = `${locale}|${currency}|${decimals}`;
      let found = formatters.get(key);
      if (!found) {
        found = new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals });
        formatters.set(key, found);
      }
      return found;
    }
    
    /**
     * Formats a price in major units and splits it into its symbol and digits, so the symbol can be
     * styled on its own. Whole amounts drop their decimals ("$199", not "$199.00"). The locale is
     * fixed by the caller, never read from the browser, so server and client output match.
     */
    export function formatPrice(amount: number, currency = "USD", locale = "en-US"): PriceParts {
      const decimals = Number.isInteger(amount) ? 0 : 2;
      const parts = formatter(currency, locale, decimals).formatToParts(amount);
      const symbolIndex = parts.findIndex((part) => part.type === "currency");
      const numberIndex = parts.findIndex((part) => part.type === "integer");
      const symbol = symbolIndex === -1 ? "" : parts[symbolIndex].value;
      const digits = parts
        .filter((part) => part.type !== "currency" && part.type !== "literal")
        .map((part) => part.value)
        .join("");
      return {
        text: parts.map((part) => part.value).join(""),
        symbol,
        digits,
        symbolFirst: symbolIndex === -1 || symbolIndex < numberIndex,
        decimals,
      };
    }
    lib/pricing/types.ts
    import type { ReactNode } from "react";
    
    /** How a plan is billed. `once` is a single payment. */
    export type Billing = "monthly" | "yearly" | "once";
    
    export interface Price {
      /** One amount in major units (199 = $199), or one per billing period (yearly is the per-month equivalent; `once` falls back to monthly). */
      amount: number | { monthly: number; yearly: number; once?: number };
      /** ISO 4217 currency code, e.g. "USD". */
      currency: string;
      /** Text after the price, e.g. "per user / month" or "One time". */
      period?: string;
    }
    
    export interface PlanCta {
      /** Button label. */
      label: string;
      /** Renders the button as a link when set. */
      href?: string;
    }
    
    export interface Plan {
      /** Stable id, used for keys and selection. */
      id: string;
      name: string;
      description?: string;
      /** Small icon shown next to the name. */
      icon?: ReactNode;
      /** Omit for plans without a public price and set `customPrice` instead. */
      price?: Price;
      /** Shown in place of a price, e.g. "Custom quote". */
      customPrice?: string;
      /** Small line near the price, e.g. "Billed yearly". */
      priceNote?: string;
      features: string[];
      cta: PlanCta;
      /** Badge text, e.g. "Popular". */
      badge?: string;
      /** Panel tone, for components that style plans differently. */
      tone?: "accent" | "ink" | "neutral";
    }
    lib/pricing/use-billing.ts
    "use client";
    
    import { useCallback, useState } from "react";
    import { formatPrice, type PriceParts } from "@/lib/pricing/format";
    import type { Billing, Plan } from "@/lib/pricing/types";
    
    export interface UseBillingOptions {
      /** Controlled billing period. */
      billing?: Billing;
      /** Initial billing period when uncontrolled. @default "monthly" */
      defaultBilling?: Billing;
      /** Called when the billing period changes. */
      onBillingChange?: (billing: Billing) => void;
      /** Locale for formatting. @default "en-US" */
      locale?: string;
    }
    
    export interface PlanPrice extends PriceParts {
      /** The amount for the current billing period, in major units. */
      amount: number;
      currency: string;
      /** Props for RollingDigits (the number only, without the symbol), so the price rolls when billing changes. */
      rolling: { value: number; decimals: number; locale: string };
    }
    
    /** The amount a plan charges for a billing period, or null when it has no public price. */
    export function planAmount(plan: Plan, billing: Billing): number | null {
      const amount = plan.price?.amount;
      if (amount === undefined) return null;
      if (typeof amount === "number") return amount;
      if (billing === "once") return amount.once ?? amount.monthly;
      return billing === "yearly" ? amount.yearly : amount.monthly;
    }
    
    /** True when a plan charges a different amount per billing period, so a billing toggle changes its price. */
    export function hasBillingPrices(plan: Plan) {
      return typeof plan.price?.amount === "object";
    }
    
    /**
     * Billing period state, controlled or uncontrolled, plus the price to show for a plan.
     * `priceFor` returns null for plans with a custom price.
     */
    export function useBilling({ billing, defaultBilling = "monthly", onBillingChange, locale = "en-US" }: UseBillingOptions = {}) {
      const [internal, setInternal] = useState<Billing>(defaultBilling);
      const current = billing ?? internal;
    
      const setBilling = useCallback(
        (next: Billing) => {
          if (billing === undefined) setInternal(next);
          onBillingChange?.(next);
        },
        [billing, onBillingChange],
      );
    
      const priceFor = useCallback(
        (plan: Plan): PlanPrice | null => {
          const amount = planAmount(plan, current);
          if (amount === null || !plan.price) return null;
          const parts = formatPrice(amount, plan.price.currency, locale);
          return { ...parts, amount, currency: plan.price.currency, rolling: { value: amount, decimals: parts.decimals, locale } };
        },
        [current, locale],
      );
    
      return { billing: current, setBilling, priceFor };
    }
  3. Copy the source

    components/ui/glass-tier-pricing/glass-tier-pricing.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type PointerEvent, type ReactNode } from "react";
    import { LayoutGroup, motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "motion/react";
    import { Check, Sparkles } from "lucide-react";
    import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits";
    import { cn } from "@/lib/utils";
    import { spring } from "@/lib/motion";
    import { BillingToggle } from "@/lib/pricing/billing-toggle";
    import type { Billing, Plan } from "@/lib/pricing/types";
    import { hasBillingPrices, useBilling, type PlanPrice } from "@/lib/pricing/use-billing";
    
    export interface GlassTierPricingProps extends Omit<ComponentProps<"section">, "children" | "title" | "onSelect"> {
      /** Small pill above the title. @default "Pricing" */
      eyebrow?: string;
      /** Section title. @default "Clear pricing, no surprises." */
      title?: ReactNode;
      /** Supporting line under the title. */
      description?: ReactNode;
      /** Tiers, left to right. Three read best. */
      plans: Plan[];
      /** Id of the tier that gets the filled button and holds the spotlight at rest. @default the middle plan */
      featuredId?: string;
      /** Controlled billing period. Setting it (or `defaultBilling`) shows the billing toggle under the header. */
      billing?: Billing;
      /** Initial billing period when uncontrolled. Also shows the toggle. */
      defaultBilling?: Billing;
      /** Called when the billing toggle changes. */
      onBillingChange?: (billing: Billing) => void;
      /** Periods offered by the toggle. @default ["monthly", "yearly"] */
      billingOptions?: readonly Billing[];
      /** Small text after the price per billing period, used for plans priced per period while the toggle shows. @default { once: "One time", monthly: "/ month", yearly: "/ month, billed yearly" } */
      periodLabels?: Partial<Record<Billing, string>>;
      /** Locale for prices. @default "en-US" */
      locale?: string;
      /** Called when a tier's button is pressed (when its cta has no href). */
      onSelect?: (plan: Plan, billing: Billing) => void;
    }
    
    const PERIODS: Record<Billing, string> = { once: "One time", monthly: "/ month", yearly: "/ month, billed yearly" };
    const ACCENT_GRADIENT = "linear-gradient(135deg, var(--primary), color-mix(in oklab, var(--primary) 55%, #7c3aed))";
    
    interface TierProps {
      plan: Plan;
      price: PlanPrice | null;
      period?: string;
      featured: boolean;
      spotlit: boolean;
      onActivate: () => void;
      onSelect: () => void;
    }
    
    function Tier({ plan, price, period, featured, spotlit, onActivate, onSelect }: TierProps) {
      const reduceMotion = useReducedMotion();
      const headingId = useId();
      const rotateX = useSpring(0, { stiffness: 220, damping: 20 });
      const rotateY = useSpring(0, { stiffness: 220, damping: 20 });
      const glareX = useMotionValue(50);
      const glareY = useMotionValue(0);
      const glare = useMotionTemplate`radial-gradient(220px circle at ${glareX}% ${glareY}%, rgb(255 255 255 / 0.9), transparent 70%)`;
    
      function onPointerMove(event: PointerEvent<HTMLElement>) {
        if (event.pointerType === "touch") return;
        const rect = event.currentTarget.getBoundingClientRect();
        const x = (event.clientX - rect.left) / rect.width;
        const y = (event.clientY - rect.top) / rect.height;
        glareX.set(x * 100);
        glareY.set(y * 100);
        if (reduceMotion) return;
        // The inner panel tilts toward the pointer, 3° at most.
        rotateY.set((x - 0.5) * 6);
        rotateX.set((0.5 - y) * 6);
      }
    
      function onPointerLeave() {
        rotateX.set(0);
        rotateY.set(0);
      }
    
      const buttonClass = cn(
        "relative flex h-11 w-full cursor-pointer items-center justify-center rounded-full text-sm font-semibold outline-none transition-[box-shadow,transform,background-color] duration-200 active:scale-[0.98]",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
        featured
          ? "text-white shadow-[0_8px_22px_-6px_color-mix(in_oklab,var(--primary)_75%,transparent),inset_0_1px_0_rgb(255_255_255/0.3)] hover:shadow-[0_10px_28px_-6px_color-mix(in_oklab,var(--primary)_90%,transparent),inset_0_1px_0_rgb(255_255_255/0.3)]"
          : "bg-white text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.06),0_6px_16px_-8px_rgb(0_0_0/0.2)] hover:bg-white/80",
      );
      const buttonStyle = featured ? { background: ACCENT_GRADIENT } : undefined;
    
      return (
        <article
          aria-labelledby={headingId}
          data-tier={plan.id}
          onPointerEnter={(event) => {
            if (event.pointerType !== "touch") onActivate();
          }}
          onPointerMove={onPointerMove}
          onPointerLeave={onPointerLeave}
          onFocus={onActivate}
          className={cn(
            "group/tier relative flex w-[84%] max-w-sm shrink-0 snap-center flex-col rounded-3xl border border-white/80 bg-white/55 p-2 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_16px_40px_-24px_rgb(30_41_90/0.35)] backdrop-blur-xl",
            "[@supports_not_(backdrop-filter:blur(1px))]:bg-white/[0.92] @[40rem]:w-auto @[40rem]:max-w-none",
          )}
        >
          {/* One spotlight for the whole row: it glides to whichever tier is hovered, focused or snapped. */}
          {spotlit ? (
            <motion.div
              layoutId="spotlight"
              aria-hidden="true"
              transition={reduceMotion ? { duration: 0 } : spring.smooth}
              style={{ borderRadius: 24 }}
              className="pointer-events-none absolute inset-0 bg-[radial-gradient(90%_55%_at_50%_0%,color-mix(in_oklab,var(--primary)_22%,transparent),transparent_75%)] shadow-[0_24px_60px_-28px_color-mix(in_oklab,var(--primary)_65%,transparent)]"
            />
          ) : null}
          <motion.div
            aria-hidden="true"
            style={{ background: glare }}
            className="pointer-events-none absolute inset-0 rounded-3xl opacity-0 transition-opacity duration-300 group-hover/tier:opacity-60"
          />
    
          {plan.badge ? (
            <span className="absolute top-5 right-5 z-10 rounded-full px-2.5 py-1 text-[11px] font-semibold text-white" style={{ background: ACCENT_GRADIENT }}>
              {plan.badge}
            </span>
          ) : null}
    
          <div className="relative [perspective:900px]">
            <motion.div
              style={{ rotateX, rotateY }}
              className="rounded-2xl bg-white p-4 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_10px_24px_-14px_rgb(30_41_90/0.3)]"
            >
              <span aria-hidden="true" className="grid size-10 place-items-center rounded-xl text-white [&_svg]:size-5" style={{ background: ACCENT_GRADIENT }}>
                {plan.icon ?? <Sparkles />}
              </span>
              <h3 id={headingId} className="mt-4 text-lg font-semibold tracking-tight text-foreground">
                {plan.name}
              </h3>
              {plan.description ? <p className="mt-1.5 min-h-20 text-[13px] leading-5 text-muted-foreground">{plan.description}</p> : null}
              <p className="mt-4 flex min-h-10 flex-wrap items-baseline gap-x-2 gap-y-1">
                {price ? (
                  <>
                    <span className="text-4xl font-semibold tracking-tight text-foreground">
                      {price.symbolFirst ? price.symbol : null}
                      <RollingDigits {...price.rolling} animateOnMount={false} />
                      {price.symbolFirst ? null : ` ${price.symbol}`}
                    </span>
                    {period ? <span className="text-[13px] text-muted-foreground">{period}</span> : null}
                  </>
                ) : (
                  <span className="text-2xl font-semibold tracking-tight whitespace-nowrap text-foreground @[56rem]:text-3xl">{plan.customPrice}</span>
                )}
              </p>
            </motion.div>
          </div>
    
          <ul className="relative flex-1 space-y-2.5 px-3 pt-5 pb-6 text-[13.5px] text-foreground">
            {plan.features.map((feature) => (
              <li key={feature} className="flex items-start gap-2.5">
                <Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" strokeWidth={3} />
                {feature}
              </li>
            ))}
          </ul>
    
          <div className="relative p-2">
            {plan.cta.href ? (
              <a href={plan.cta.href} className={buttonClass} style={buttonStyle}>
                {plan.cta.label}
              </a>
            ) : (
              <button type="button" onClick={onSelect} className={buttonClass} style={buttonStyle}>
                {plan.cta.label}
              </button>
            )}
          </div>
        </article>
      );
    }
    
    export function GlassTierPricing({
      eyebrow = "Pricing",
      title = "Clear pricing, no surprises.",
      description,
      plans,
      featuredId,
      billing,
      defaultBilling,
      onBillingChange,
      billingOptions,
      periodLabels,
      locale = "en-US",
      onSelect,
      className,
      ...props
    }: GlassTierPricingProps) {
      const id = useId();
      const state = useBilling({ billing, defaultBilling, onBillingChange, locale });
      const showToggle = billing !== undefined || defaultBilling !== undefined;
      const featured = featuredId ?? plans[Math.floor((plans.length - 1) / 2)]?.id;
    
      /** The tier the pointer or focus is on, if any. */
      const [active, setActive] = useState<string | null>(null);
      /** The tier snapped to the middle while the row scrolls as a carousel; null when every tier fits. */
      const [snapped, setSnapped] = useState<string | null>(null);
      const rowRef = useRef<HTMLDivElement>(null);
      const spotlight = active ?? snapped ?? featured;
    
      useEffect(() => {
        const row = rowRef.current;
        if (!row) return;
        let frame = 0;
        let started = false;
        const tiers = () => Array.from(row.querySelectorAll<HTMLElement>("[data-tier]"));
        const read = () => {
          frame = 0;
          if (row.scrollWidth <= row.clientWidth + 1) {
            started = false;
            setSnapped(null);
            return;
          }
          if (!started) {
            // The carousel opens on the featured tier.
            started = true;
            const first = tiers().find((tier) => tier.dataset.tier === featured);
            if (first) row.scrollLeft = first.offsetLeft - (row.clientWidth - first.offsetWidth) / 2;
          }
          const center = row.scrollLeft + row.clientWidth / 2;
          let nearest: string | null = null;
          let best = Infinity;
          for (const tier of tiers()) {
            const distance = Math.abs(tier.offsetLeft + tier.offsetWidth / 2 - center);
            if (distance < best) {
              best = distance;
              nearest = tier.dataset.tier ?? null;
            }
          }
          setSnapped(nearest);
        };
        const schedule = () => {
          if (!frame) frame = requestAnimationFrame(read);
        };
        schedule();
        row.addEventListener("scroll", schedule, { passive: true });
        const observer = new ResizeObserver(schedule);
        observer.observe(row);
        return () => {
          cancelAnimationFrame(frame);
          row.removeEventListener("scroll", schedule);
          observer.disconnect();
        };
      }, [featured, plans.length]);
    
      function showTier(planId: string) {
        const row = rowRef.current;
        const tier = row?.querySelector<HTMLElement>(`[data-tier="${CSS.escape(planId)}"]`);
        if (!row || !tier) return;
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        row.scrollTo({ left: tier.offsetLeft - (row.clientWidth - tier.offsetWidth) / 2, behavior: reduce ? "auto" : "smooth" });
      }
    
      return (
        <section
          className={cn(
            "@container relative isolate w-full overflow-hidden rounded-[2rem] bg-[#f4f5fb] px-4 py-12 @[40rem]:px-5 @[40rem]:py-14 @[56rem]:px-8",
            className,
          )}
          {...props}
        >
          {/* Soft color behind the cards, so the frosted glass has something to blur. */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(40%_45%_at_18%_62%,color-mix(in_oklab,var(--primary)_20%,transparent),transparent),radial-gradient(38%_42%_at_84%_70%,rgb(168_85_247/0.2),transparent),radial-gradient(45%_35%_at_52%_100%,rgb(56_189_248/0.22),transparent)]"
          />
    
          <div className="flex flex-col items-center text-center">
            {eyebrow ? (
              <p className="rounded-full border border-white bg-white/70 px-3 py-1 text-xs font-medium text-foreground/70 shadow-[0_1px_2px_rgb(0_0_0/0.05)]">{eyebrow}</p>
            ) : null}
            <h2 className="mt-4 max-w-xl text-3xl font-semibold tracking-tight text-balance text-foreground @[40rem]:text-4xl">{title}</h2>
            {description ? <p className="mt-3 max-w-lg text-[15px] leading-7 text-foreground/65">{description}</p> : null}
            {showToggle ? <BillingToggle value={state.billing} onValueChange={state.setBilling} options={billingOptions} className="mt-6 border-white bg-white/70" /> : null}
          </div>
    
          <LayoutGroup id={id}>
            <div
              ref={rowRef}
              onPointerLeave={() => setActive(null)}
              onBlur={(event) => {
                if (!event.currentTarget.contains(event.relatedTarget)) setActive(null);
              }}
              className="-mx-4 mt-10 flex snap-x snap-mandatory gap-3 @[56rem]:gap-4 overflow-x-auto px-4 pt-1 pb-8 [scrollbar-width:none] @[40rem]:mx-0 @[40rem]:grid @[40rem]:grid-cols-[repeat(var(--tiers),minmax(0,1fr))] @[40rem]:overflow-visible @[40rem]:px-0 @[40rem]:pb-0 [&::-webkit-scrollbar]:hidden"
              style={{ "--tiers": plans.length } as CSSProperties}
            >
              {plans.map((plan) => (
                <Tier
                  key={plan.id}
                  plan={plan}
                  price={state.priceFor(plan)}
                  period={showToggle && hasBillingPrices(plan) ? (periodLabels?.[state.billing] ?? PERIODS[state.billing]) : plan.price?.period}
                  featured={plan.id === featured}
                  spotlit={plan.id === spotlight}
                  onActivate={() => setActive(plan.id)}
                  onSelect={() => onSelect?.(plan, state.billing)}
                />
              ))}
            </div>
          </LayoutGroup>
    
          {snapped ? (
            <div className="flex justify-center gap-1 @[40rem]:hidden">
              {plans.map((plan) => (
                <button
                  key={plan.id}
                  type="button"
                  aria-label={`Show ${plan.name}`}
                  aria-current={plan.id === snapped ? "true" : undefined}
                  onClick={() => showTier(plan.id)}
                  className="group/dot grid size-10 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <span className={cn("size-2 rounded-full transition-[transform,background-color] duration-200", plan.id === snapped ? "scale-125 bg-foreground" : "bg-foreground/25 group-hover/dot:bg-foreground/50")} />
                </button>
              ))}
            </div>
          ) : null}
        </section>
      );
    }

Usage

import { GlassTierPricing } from "@/components/ui/glass-tier-pricing/glass-tier-pricing";
import type { Plan } from "@/lib/pricing/types";

const plans: Plan[] = [
  { id: "solo", name: "Solo", price: { amount: { monthly: 19, yearly: 15 }, currency: "USD" }, features: ["3 projects"], cta: { label: "Start with Solo" } },
  { id: "studio", name: "Studio", badge: "Popular", price: { amount: { monthly: 49, yearly: 39 }, currency: "USD" }, features: ["Unlimited projects"], cta: { label: "Start with Studio" } },
  { id: "agency", name: "Agency", customPrice: "Custom quote", features: ["Everything in Studio"], cta: { label: "Talk to us" } },
];

export function Pricing() {
  return <GlassTierPricing plans={plans} featuredId="studio" defaultBilling="monthly" />;
}

API reference

PropTypeDefaultDescription
eyebrowstring"Pricing"Small pill above the title.
titleReactNode"Clear pricing, no surprises."Section title.
descriptionReactNode–Supporting line under the title.
plans*Plan[]–Tiers, left to right. Three read best.
featuredIdstringthe middle planId of the tier that gets the filled button and holds the spotlight at rest.
billingBilling–Controlled billing period. Setting it (or `defaultBilling`) shows the billing toggle under the header.
defaultBillingBilling–Initial billing period when uncontrolled. Also shows the toggle.
onBillingChange(billing: Billing) => void–Called when the billing toggle changes.
billingOptionsreadonly Billing[]["monthly", "yearly"]Periods offered by the toggle.
periodLabelsPartial<Record<Billing, string>>{ once: "One time", monthly: "/ month", yearly: "/ month, billed yearly" }Small text after the price per billing period, used for plans priced per period while the toggle shows.
localestring"en-US"Locale for prices.
onSelect(plan: Plan, billing: Billing) => void–Called when a tier's button is pressed (when its cta has no href).

Also accepts every native <section> attribute, including ref and className.

  • The accent wash and glow are one shared layout element that glides to the hovered, focused or snapped tier and returns to featuredId. The filled button stays on the featured tier.
  • The section paints its own soft color wash, so the frosted cards have something to blur. Without backdrop-filter support the cards fall back to solid white at 92%.
  • The toggle shows when billing or defaultBilling is set. Plans with one amount keep their own period text; plans with a custom price show customPrice.
  • The layout follows the component's own width (container queries): three columns from 40rem, a carousel that opens on the featured tier below that.
  • Colors come from --primary. Text on the filled button is white, so keep --primary at 4.5:1 or better against white.

Accessibility

  • Each tier is an article labelled by its heading, features are a list and every call to action is a real link or button.
  • The billing toggle is a radio group: arrow keys move between periods and the selected one is the only tab stop.
  • Moving focus into a tier moves the spotlight there, the same as hovering it.
  • On narrow containers the tiers become a horizontal scroll-snap row with a labelled dot per tier; the dot for the tier in view is marked aria-current.
  • Prices are announced once as full text; the rolling reels are hidden from assistive tech.
  • With reduced motion enabled, the spotlight jumps between tiers, the inner panels do not tilt and prices swap instantly.