# Pricing

> Pricing table with a volt billing toggle, a seat slider with volume-discount tiers and plan prices that roll digit by digit whenever billing or seats change; the featured plan glows.

- Collection: Blocks
- Tier: free
- Docs: https://geturui.com/docs/pricing-section
- Install: `npx shadcn@latest add @geturui/pricing-section`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { PricingSection } from "@/components/blocks/pricing-section";

export default function Pricing() {
  return (
    <PricingSection
      defaultBilling="yearly"
      maxSeats={100}
      tiers={[
        { from: 1, discount: 0 },
        { from: 20, discount: 0.1 },
        { from: 50, discount: 0.2 },
      ]}
      onSelectPlan={({ planId, billing, seats }) => {
        window.location.href = `/checkout?plan=${planId}&billing=${billing}&seats=${seats}`;
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| plans | `PricingPlan[]` | — | { id, name, description, monthly, yearly, features, cta?, featured?, badge? }. Prices are per seat per month. |
| billing | `"monthly" | "yearly"` | — | Controlled billing period. |
| defaultBilling | `"monthly" | "yearly"` | "yearly" | Initial period when uncontrolled. |
| onBillingChange | `(billing) => void` | — | Called when the toggle changes. |
| tiers | `{ from: number; discount: number }[]` | — | Volume discounts, ascending. Pass [] to hide the seat slider and price flat. |
| minSeats / maxSeats / defaultSeats | `number` | 1 / 50 / 8 | Seat slider range and start. |
| yearlyNote | `string` | "Save 20%" | Chip on the yearly tab. |
| currency | `string` | "$" | Prefix for prices. |
| onSelectPlan | `(selection: { planId, billing, seats, total }) => void` | — | Called when a plan's button is pressed. |
| eyebrow / heading / subheading | `string` | — | Section copy. |

## Accessibility

- The billing toggle is a tablist with arrow-key support; the seat slider is a labelled role=slider with Home, End and Page keys.
- Rolling prices expose the final value through aria-label, so screen readers never hear intermediate digits.
- Feature rows say Included or Not included in visually hidden text instead of relying on check and dash icons.

## Source

### components/blocks/pricing-section.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import { RangeSlider } from "@/components/ui/range-slider";
import { Tabs } from "@/components/ui/tabs";

export type Billing = "monthly" | "yearly";

export interface PricingFeature {
  label: string;
  /** false renders a dash; a string renders as a detail after the label. */
  included: boolean | string;
}

export interface PricingPlan {
  id: string;
  name: string;
  description: string;
  /** Per-seat price per month when billed monthly. */
  monthly: number;
  /** Per-seat price per month when billed yearly. */
  yearly: number;
  features: PricingFeature[];
  cta?: string;
  /** Highlight with the volt glow and a badge. */
  featured?: boolean;
  badge?: string;
}

export interface SeatTier {
  /** First seat count this tier applies to. */
  from: number;
  /** Fraction off, 0–1. */
  discount: number;
}

export interface PlanSelection {
  planId: string;
  billing: Billing;
  seats: number;
  /** Monthly total after the seat discount. */
  total: number;
}

export interface PricingSectionProps {
  eyebrow?: string;
  heading?: string;
  subheading?: string;
  plans?: PricingPlan[];
  billing?: Billing;
  defaultBilling?: Billing;
  onBillingChange?: (billing: Billing) => void;
  minSeats?: number;
  maxSeats?: number;
  defaultSeats?: number;
  /** Volume discounts, ascending by `from`. Pass [] to hide the seat slider. */
  tiers?: SeatTier[];
  /** Label on the yearly tab, e.g. "Save 20%". */
  yearlyNote?: string;
  currency?: string;
  onSelectPlan?: (selection: PlanSelection) => void;
  className?: string;
}

const DEFAULT_PLANS: PricingPlan[] = [
  {
    id: "starter",
    name: "Starter",
    description: "For small teams putting their first polished product out.",
    monthly: 12,
    yearly: 10,
    cta: "Start with Starter",
    features: [
      { label: "All free components", included: true },
      { label: "Pro blocks", included: "20 / month" },
      { label: "Figma-ready tokens", included: true },
      { label: "Private registry", included: false },
      { label: "Priority support", included: false },
    ],
  },
  {
    id: "team",
    name: "Team",
    description: "Every Pro block, a private registry and help when you need it.",
    monthly: 24,
    yearly: 19,
    cta: "Choose Team",
    featured: true,
    badge: "Most popular",
    features: [
      { label: "All free components", included: true },
      { label: "Pro blocks", included: "Unlimited" },
      { label: "Figma-ready tokens", included: true },
      { label: "Private registry", included: true },
      { label: "Priority support", included: "4h reply" },
    ],
  },
];

const DEFAULT_TIERS: SeatTier[] = [
  { from: 1, discount: 0 },
  { from: 10, discount: 0.1 },
  { from: 25, discount: 0.15 },
  { from: 40, discount: 0.2 },
];

function tierFor(tiers: SeatTier[], seats: number) {
  let found: SeatTier | undefined;
  for (const t of tiers) if (seats >= t.from) found = t;
  return found;
}

/** Pricing table: volt billing toggle, seat slider with volume tiers, and plan prices that roll digit by digit as inputs change. */
export function PricingSection({
  eyebrow = "Pricing",
  heading = "Pay per seat, save as you grow",
  subheading = "Every plan includes lifetime access to the free library. Switch or cancel any time.",
  plans = DEFAULT_PLANS,
  billing,
  defaultBilling = "yearly",
  onBillingChange,
  minSeats = 1,
  maxSeats = 50,
  defaultSeats = 8,
  tiers = DEFAULT_TIERS,
  yearlyNote = "Save 20%",
  currency = "$",
  onSelectPlan,
  className,
}: PricingSectionProps) {
  const [innerBilling, setInnerBilling] = React.useState<Billing>(defaultBilling);
  const cycle = billing ?? innerBilling;
  const [seats, setSeats] = React.useState(defaultSeats);
  const showSeats = tiers.length > 0;
  const effectiveSeats = showSeats ? seats : 1;
  const tier = tierFor(tiers, effectiveSeats);
  const discount = tier?.discount ?? 0;
  const tierGroup = React.useId();

  function changeBilling(v: string) {
    const next = v as Billing;
    if (billing === undefined) setInnerBilling(next);
    onBillingChange?.(next);
  }

  return (
    <section className={cn("@container w-full px-4 py-16 sm:px-6 sm:py-24", className)}>
      <div className="mx-auto grid max-w-5xl gap-10">
        <header className="mx-auto grid max-w-2xl justify-items-center gap-3 text-center">
          {eyebrow && <span className="text-[13px] font-semibold uppercase tracking-[1px] text-volt-ink">{eyebrow}</span>}
          <h2 className="text-balance font-display text-3xl font-semibold tracking-[-0.8px] text-ink @xl:text-[40px] @xl:leading-[1.1]">{heading}</h2>
          {subheading && <p className="text-balance text-base leading-7 text-ink-muted">{subheading}</p>}
          <Tabs
            variant="volt"
            aria-label="Billing period"
            className="mt-3"
            value={cycle}
            onValueChange={changeBilling}
            items={[
              { value: "monthly", label: "Monthly" },
              {
                value: "yearly",
                label: (
                  <>
                    Yearly
                    {yearlyNote && (
                      <span className={cn("rounded-full px-1.5 text-[10.5px] font-semibold", cycle === "yearly" ? "bg-on-volt/15" : "bg-volt-soft text-volt-ink")}>
                        {yearlyNote}
                      </span>
                    )}
                  </>
                ),
              },
            ]}
          />
        </header>

        {showSeats && (
          <div className="mx-auto grid w-full max-w-xl gap-4 rounded-lg border border-border bg-surface p-5">
            <RangeSlider
              label="Team size"
              min={minSeats}
              max={maxSeats}
              value={seats}
              onValueChange={setSeats}
              format={(v) => `${v} ${v === 1 ? "seat" : "seats"}`}
            />
            <div className="flex flex-wrap gap-1.5" role="list" aria-label="Volume discounts">
              {tiers.map((t, i) => {
                const next = tiers[i + 1];
                const on = tier === t;
                const range = next ? `${t.from}–${next.from - 1}` : `${t.from}+`;
                return (
                  <span
                    key={t.from}
                    role="listitem"
                    aria-current={on || undefined}
                    className={cn(
                      "relative inline-flex h-7 items-center rounded-full px-3 text-[12px] font-medium transition-colors duration-200",
                      on ? "text-volt-ink" : "text-ink-subtle",
                    )}
                  >
                    {on && (
                      <motion.span
                        layoutId={`tier-${tierGroup}`}
                        className="absolute inset-0 rounded-full bg-volt-soft ring-1 ring-inset ring-volt-ink/40"
                        transition={{ type: "spring", stiffness: 420, damping: 32 }}
                      />
                    )}
                    <span className="relative">
                      {range} seats{t.discount > 0 ? ` · ${Math.round(t.discount * 100)}% off` : ""}
                    </span>
                  </span>
                );
              })}
            </div>
          </div>
        )}

        <div className={cn("grid gap-4", plans.length >= 3 ? "@2xl:grid-cols-2 @4xl:grid-cols-3" : "@2xl:grid-cols-2")}>
          {plans.map((plan) => {
            const perSeat = cycle === "yearly" ? plan.yearly : plan.monthly;
            const total = Math.round(perSeat * effectiveSeats * (1 - discount));
            return (
              <motion.article
                key={plan.id}
                layout
                transition={{ type: "spring", stiffness: 300, damping: 30 }}
                className={cn(
                  "relative flex flex-col gap-6 rounded-xl border p-6 @xl:p-7",
                  plan.featured ? "border-volt-ink/60 bg-surface-raised shadow-glow" : "border-border bg-surface",
                )}
              >
                <div className="grid gap-2">
                  <div className="flex items-center justify-between gap-3">
                    <h3 className="font-display text-xl font-semibold tracking-[-0.3px] text-ink">{plan.name}</h3>
                    {plan.featured && plan.badge && <Badge tone="new">{plan.badge}</Badge>}
                  </div>
                  <p className="text-sm leading-[22px] text-ink-muted">{plan.description}</p>
                </div>

                <div className="grid gap-1.5">
                  <div className="flex items-baseline gap-1.5">
                    <NumberTicker
                      value={total}
                      prefix={currency}
                      className="font-display text-[44px] font-semibold tracking-[-1.4px] text-ink"
                    />
                    <span className="text-sm text-ink-muted">/ month</span>
                  </div>
                  <div className="relative h-5 text-[13px] text-ink-subtle">
                    <AnimatePresence mode="popLayout" initial={false}>
                      <motion.span
                        key={`${cycle}-${effectiveSeats}`}
                        className="absolute inset-x-0 top-0 truncate"
                        initial={{ opacity: 0, y: 6, filter: "blur(8px)" }}
                        animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                        exit={{ opacity: 0, y: -6, filter: "blur(8px)" }}
                        transition={{ duration: 0.25 }}
                      >
                        {currency}
                        {perSeat} per seat
                        {showSeats ? ` × ${effectiveSeats}` : ""}
                        {cycle === "yearly" ? ` · billed ${currency}${(total * 12).toLocaleString("en-US")} yearly` : " · billed monthly"}
                      </motion.span>
                    </AnimatePresence>
                  </div>
                </div>

                <Button
                  block
                  size="lg"
                  variant={plan.featured ? "primary" : "secondary"}
                  trailing="arrow"
                  onClick={() => onSelectPlan?.({ planId: plan.id, billing: cycle, seats: effectiveSeats, total })}
                >
                  {plan.cta ?? `Choose ${plan.name}`}
                </Button>

                <ul className="grid gap-3 border-t border-border pt-6">
                  {plan.features.map((f) => {
                    const on = f.included !== false;
                    return (
                      <li key={f.label} className={cn("flex items-start gap-3 text-sm leading-5", on ? "text-ink" : "text-ink-subtle")}>
                        {on ? (
                          <span className="mt-px grid size-[18px] shrink-0 place-items-center rounded-full bg-volt-soft text-volt-ink">
                            <Check className="size-3" strokeWidth={3} aria-hidden />
                          </span>
                        ) : (
                          <span className="mt-px grid size-[18px] shrink-0 place-items-center rounded-full bg-surface-sunken text-ink-subtle">
                            <Minus className="size-3" strokeWidth={3} aria-hidden />
                          </span>
                        )}
                        <span className="flex-1">
                          <span className="sr-only">{on ? "Included: " : "Not included: "}</span>
                          {f.label}
                          {typeof f.included === "string" && <span className="ml-1.5 text-ink-muted">· {f.included}</span>}
                        </span>
                      </li>
                    );
                  })}
                </ul>
              </motion.article>
            );
          })}
        </div>
      </div>
    </section>
  );
}

```
