# Pricing Table

> Pricing with an honest yearly anchor, in three forms: three plans with one raised, a side-by-side feature matrix with a sticky plan header, or one plan priced by the seat.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/pricing-table).

- Page, with a live preview: https://shadcn.exchange/r/miseui/pricing-table
- Screenshot: https://shadcn.exchange/shot/miseui/pricing-table/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/pricing-table.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/pricing-table.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/motion.json`, `https://miseui.junsu4621.workers.dev/r/badge.json`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/card.json`, `https://miseui.junsu4621.workers.dev/r/label.json`, `https://miseui.junsu4621.workers.dev/r/number-input.json`, `https://miseui.junsu4621.workers.dev/r/scroll-shadow.json`, `https://miseui.junsu4621.workers.dev/r/switch.json`

## Files

### `registry/miseui/blocks/pricing-table.tsx`

```tsx
import { PricingCards } from "./pricing-table-cards"
import { PricingComparison } from "./pricing-table-comparison"
import type { PricingCommonProps } from "./pricing-table-billing"
import type { PricingBilling, PricingFeature, PricingPlan } from "./pricing-table-parts"
import type { PricingSeatProps } from "./pricing-table-seats"
import { PricingSingle } from "./pricing-table-single"

type PricingTableVariant = "cards" | "comparison" | "single"

interface PricingTableProps extends PricingCommonProps, PricingSeatProps {
  /** `cards` three plans with one raised, `comparison` every row side by side, `single` one plan priced by the seat. */
  variant?: PricingTableVariant
  /** Sends a beam around the recommended plan instead of its still accent border, on `cards`. */
  beam?: boolean
  /** On `comparison`, how far below the top of the page the plan header sticks: the height of your fixed site header, in px. */
  stickyTop?: number
}

function PricingTable({ variant, beam, stickyTop, seats, defaultSeats, minSeats, maxSeats, onSeatsChange, ...props }: PricingTableProps) {
  switch (variant) {
    case "comparison":
      return <PricingComparison stickyTop={stickyTop} {...props} />
    case "single":
      return <PricingSingle seat={{ seats, defaultSeats, minSeats, maxSeats, onSeatsChange }} {...props} />
    default:
      return <PricingCards beam={beam} {...props} />
  }
}

export { PricingTable, type PricingBilling, type PricingFeature, type PricingPlan, type PricingTableProps, type PricingTableVariant }
```

### `registry/miseui/blocks/pricing-table-parts.tsx`

```tsx
import { Check, Loader2, Minus } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Badge } from "@/registry/miseui/ui/badge"
import { Button } from "@/registry/miseui/ui/button"
import { Card } from "@/registry/miseui/ui/card"

type PricingBilling = "monthly" | "yearly"

interface PricingFeature {
  label: string
  /** Text such as "Unlimited", or a plain yes or no. */
  value: string | boolean
  /** A heading the row sits under on `comparison`. Rows with the same group stay together. */
  group?: string
}

interface PricingPlan {
  id: string
  name: string
  description: string
  /** Price per month on the monthly plan. */
  monthly: number
  /** Price per month when billed yearly. Keep it true: this is what the crossed-out anchor is compared with. */
  yearly: number
  cta: string
  /** The plan that carries the visual weight. Mark one. */
  recommended?: boolean
  /** Same rows in the same order on every plan, so the eye can read straight across. */
  features: readonly PricingFeature[]
}

const MONTHS_IN_YEAR = 12
const PRICE_START = { opacity: 0, y: 8 }
const PRICE_END = { opacity: 1, y: 0 }

/** What a plan's price means in words, so a yearly number never hides what actually gets charged. */
function billingNote(plan: PricingPlan, billing: PricingBilling, currency: string) {
  if (plan.monthly === 0) return "No card needed"
  return billing === "yearly" ? `${formatMoney(plan.yearly * MONTHS_IN_YEAR, currency)} billed yearly` : "Billed monthly"
}

function formatMoney(amount: number, currency: string) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
    minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
    maximumFractionDigits: 2,
  }).format(amount)
}

interface PlanPriceProps {
  plan: PricingPlan
  billing: PricingBilling
  currency: string
}

function PlanPrice({ plan, billing, currency }: PlanPriceProps) {
  const reduceMotion = useReducedMotion()
  const yearly = billing === "yearly"
  const amount = yearly ? plan.yearly : plan.monthly
  const anchored = yearly && plan.monthly > plan.yearly
  return (
    <div className="flex flex-col gap-1 px-6">
      <div className="flex flex-wrap items-baseline gap-x-2">
        {anchored ? (
          <s data-testid="anchor-price" className="text-[15px] text-muted-foreground tabular-nums">
            <span className="sr-only">Monthly price </span>
            {formatMoney(plan.monthly, currency)}
          </s>
        ) : null}
        <motion.span
          key={billing}
          className={cn("text-display tabular-nums", plan.recommended ? "text-4xl" : "text-3xl")}
          initial={reduceMotion ? false : PRICE_START}
          animate={PRICE_END}
          transition={SPRING_DEFAULT}
        >
          {formatMoney(amount, currency)}
        </motion.span>
        <span className="text-[13px] text-muted-foreground">/ month</span>
      </div>
      <p className="text-[13px] text-muted-foreground">{billingNote(plan, billing, currency)}</p>
    </div>
  )
}

function FeatureValue({ value }: { value: string | boolean }) {
  if (typeof value === "string") return <span className="text-right">{value}</span>
  const Icon = value ? Check : Minus
  return (
    <span className="grid size-4 shrink-0 place-items-center">
      <span className="sr-only">{value ? "Included" : "Not included"}</span>
      <Icon aria-hidden="true" className={cn("size-4", !value && "text-muted-foreground")} />
    </span>
  )
}

interface PlanCardProps {
  plan: PricingPlan
  billing: PricingBilling
  currency: string
  /** Let the light travel around the recommended plan. Off by default: a loop on a page people read is decoration. */
  beam: boolean
  pending: boolean
  current: boolean
  disabled: boolean
  onSelect?: (planId: string, billing: PricingBilling) => void
}

/** A subgrid row per region, so prices, buttons and feature rows line up across plans whatever the copy length. */
function PlanCard({ plan, billing, currency, beam, pending, current, disabled, onSelect }: PlanCardProps) {
  const emphasized = Boolean(plan.recommended)
  return (
    <Card
      effect={emphasized && beam ? "beam" : undefined}
      data-recommended={emphasized ? "true" : undefined}
      className={cn(
        "row-span-4 grid grid-rows-subgrid rounded-2xl",
        emphasized
          ? "z-10 order-first bg-popover shadow-[0_24px_48px_-24px_color-mix(in_oklab,var(--mise)_32%,oklch(0_0_0/40%))] @3xl:order-none @3xl:-my-6 @3xl:py-12"
          : "bg-card shadow-none",
        emphasized && !beam && "border-mise/60",
      )}
    >
      <div className="flex flex-col gap-2 px-6">
        <div className="flex items-center justify-between gap-2">
          <h3 className="text-[15px] font-medium">{plan.name}</h3>
          {emphasized ? <Badge variant="mise">Recommended</Badge> : null}
        </div>
        <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{plan.description}</p>
      </div>
      <PlanPrice plan={plan} billing={billing} currency={currency} />
      <div className="px-6">
        <Button
          variant={emphasized ? "mise" : "outline"}
          disabled={disabled || current}
          aria-busy={pending || undefined}
          onClick={() => onSelect?.(plan.id, billing)}
          className="w-full"
        >
          {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {current ? "Current plan" : plan.cta}
        </Button>
      </div>
      <ul aria-label={`${plan.name} features`} className="flex flex-col px-6 text-[13px] leading-relaxed">
        {plan.features.map((feature) => (
          <li key={feature.label} className="flex items-center justify-between gap-3 border-t py-2">
            <span className="min-w-0 text-muted-foreground">{feature.label}</span>
            <FeatureValue value={feature.value} />
          </li>
        ))}
      </ul>
    </Card>
  )
}

export { FeatureValue, MONTHS_IN_YEAR, PlanCard, billingNote, formatMoney, type PricingBilling, type PricingFeature, type PricingPlan }
```

### `registry/miseui/blocks/pricing-table-billing.tsx`

```tsx
import type * as React from "react"
import { useId, useState } from "react"
import { Label } from "@/registry/miseui/ui/label"
import { Switch } from "@/registry/miseui/ui/switch"
import type { PricingBilling, PricingPlan } from "./pricing-table-parts"

const PERCENT = 100

/** Props every design takes, so switching `variant` never means rewiring the page. */
interface PricingCommonProps extends Omit<React.ComponentProps<"section">, "title" | "onSelect"> {
  plans?: readonly PricingPlan[]
  /** Control the toggle yourself. Leave it out and the block keeps its own state, starting from `defaultBilling`. */
  billing?: PricingBilling
  defaultBilling?: PricingBilling
  onBillingChange?: (billing: PricingBilling) => void
  /** `seats` is only passed on the `single` design, where the seat count is chosen here. */
  onSelect?: (planId: string, billing: PricingBilling, seats?: number) => void
  title?: string
  /** The muted second sentence of the heading. */
  description?: string
  /** ISO 4217 code. */
  currency?: string
  /** The plan whose checkout is opening. Every button waits meanwhile. */
  pendingPlanId?: string
  /** The plan this customer is already on. */
  currentPlanId?: string
  /** Checkout failed. Shown under the plans. */
  error?: string
}

/** The biggest saving real prices allow. Computed, never typed in, so the claim cannot drift from the numbers. */
function bestYearlySaving(plans: readonly PricingPlan[]) {
  const savings = plans
    .filter((plan) => plan.monthly > 0)
    .map((plan) => Math.round(((plan.monthly - plan.yearly) / plan.monthly) * PERCENT))
  return Math.max(0, ...savings)
}

function useBilling(billing: PricingBilling | undefined, defaultBilling: PricingBilling, onBillingChange?: (billing: PricingBilling) => void) {
  const [uncontrolled, setUncontrolled] = useState(defaultBilling)
  const change = (next: PricingBilling) => {
    setUncontrolled(next)
    onBillingChange?.(next)
  }
  return [billing ?? uncontrolled, change] as const
}

interface BillingToggleProps {
  billing: PricingBilling
  saving: number
  /** One plan has one saving, so "up to" would be untrue. */
  exact?: boolean
  /** Grows the switch's hit area to 40 px on touch with an invisible layer, without changing how it looks. */
  roomyTouch?: boolean
  onChange: (billing: PricingBilling) => void
}

/** Only the colour marks the active side. A weight change would widen the label and shove the switch sideways. */
function BillingToggle({ billing, saving, exact = false, roomyTouch = false, onChange }: BillingToggleProps) {
  const id = useId()
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Label htmlFor={id} className={billing === "monthly" ? "text-foreground" : "text-muted-foreground"}>
        Monthly
      </Label>
      <Switch
        id={id}
        aria-label="Bill yearly"
        checked={billing === "yearly"}
        onCheckedChange={(yearly) => onChange(yearly ? "yearly" : "monthly")}
        className={roomyTouch ? "relative pointer-coarse:after:absolute pointer-coarse:after:-inset-3" : undefined}
      />
      <Label htmlFor={id} className={billing === "yearly" ? "text-foreground" : "text-muted-foreground"}>
        Yearly
      </Label>
      {saving > 0 ? <span className="text-[13px] text-muted-foreground">{exact ? `Save ${saving}%` : `Save up to ${saving}%`}</span> : null}
    </div>
  )
}

function PricingHeader({ title, description, children }: { title: string; description: string; children?: React.ReactNode }) {
  return (
    <header className="flex flex-col gap-6 @2xl:flex-row @2xl:items-end @2xl:justify-between">
      <h2 className="text-display max-w-lg text-3xl leading-[1.12]! text-balance @2xl:text-4xl">
        {title} <span className="text-muted-foreground">{description}</span>
      </h2>
      {children}
    </header>
  )
}

function NoPlans() {
  return <p className="text-[15px] text-muted-foreground">Plans are not available right now. Check back soon.</p>
}

function PricingError({ message }: { message?: string }) {
  if (!message) return null
  return (
    <p role="alert" className="text-[13px] text-pretty text-destructive">
      {message}
    </p>
  )
}

export { BillingToggle, NoPlans, PricingError, PricingHeader, bestYearlySaving, useBilling, type PricingCommonProps }
```

### `registry/miseui/blocks/pricing-table-cards.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import {
  BillingToggle,
  NoPlans,
  PricingError,
  PricingHeader,
  bestYearlySaving,
  useBilling,
  type PricingCommonProps,
} from "./pricing-table-billing"
import { PlanCard } from "./pricing-table-parts"
import { DEFAULT_PLANS } from "./pricing-table-sample"

interface PricingCardsProps extends PricingCommonProps {
  /** Sends a beam around the recommended plan instead of its still accent border. */
  beam?: boolean
}

function PricingCards({
  plans = DEFAULT_PLANS,
  billing,
  defaultBilling = "yearly",
  onBillingChange,
  onSelect,
  title = "Pay for the team you have today.",
  description = "Move up or down whenever you like.",
  currency = "USD",
  beam = false,
  pendingPlanId,
  currentPlanId,
  error,
  className,
  ...props
}: PricingCardsProps) {
  const [active, change] = useBilling(billing, defaultBilling, onBillingChange)

  return (
    <section data-slot="pricing-table" data-variant="cards" className={cn("@container w-full bg-muted/40 dark:bg-background px-6 py-12", className)} {...props}>
      <div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
        <PricingHeader title={title} description={description}>
          {plans.length > 0 ? <BillingToggle billing={active} saving={bestYearlySaving(plans)} onChange={change} /> : null}
        </PricingHeader>
        {plans.length > 0 ? (
          <div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3 @3xl:gap-y-0 @3xl:py-6">
            {plans.map((plan) => (
              <PlanCard
                key={plan.id}
                plan={plan}
                billing={active}
                currency={currency}
                beam={beam}
                pending={plan.id === pendingPlanId}
                current={plan.id === currentPlanId}
                disabled={pendingPlanId !== undefined}
                onSelect={onSelect}
              />
            ))}
          </div>
        ) : (
          <NoPlans />
        )}
        <PricingError message={error} />
      </div>
    </section>
  )
}

export { PricingCards }
```

### `registry/miseui/blocks/pricing-table-comparison.tsx`

```tsx
import type * as React from "react"
import { Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { ScrollShadow } from "@/registry/miseui/ui/scroll-shadow"
import {
  BillingToggle,
  NoPlans,
  PricingError,
  PricingHeader,
  bestYearlySaving,
  useBilling,
  type PricingCommonProps,
} from "./pricing-table-billing"
import { matrixGroups, matrixValue, type MatrixGroup } from "./pricing-table-matrix"
import { FeatureValue, billingNote, formatMoney, type PricingBilling, type PricingPlan } from "./pricing-table-parts"
import { COMPARISON_PLANS } from "./pricing-table-sample"

const FEATURE_COLUMN_PX = 128
const PLAN_COLUMN_PX = 160
const PRICE_START = { opacity: 0, y: 8 }
const PRICE_END = { opacity: 1, y: 0 }

/** Opaque, because rows slide underneath. Each surface below must be the same one the cell sits on. */
const planSurface = (plan: PricingPlan) => (plan.recommended ? "bg-muted" : "bg-background")
/** A wide table sticks to the page, a narrow one to its own scroller, so only the wide one honours the site header's height. */
const STICKY_HEAD_CLASS = "sticky top-0 @3xl:top-(--pricing-sticky-top) border-b align-top"
/** z-11: above the scroller's edge shadows (z-10), which would otherwise paint across the pinned column. */
const FEATURE_CELL_CLASS = "sticky left-0 z-11 border-r bg-background pr-2 pl-6 text-start @3xl:border-r-0 @3xl:pl-0"

interface HeadProps {
  plan: PricingPlan
  billing: PricingBilling
  currency: string
  pendingPlanId?: string
  currentPlanId?: string
  onSelect?: PricingCommonProps["onSelect"]
}

function PlanHead({ plan, billing, currency, pendingPlanId, currentPlanId, onSelect }: HeadProps) {
  const reduceMotion = useReducedMotion()
  const yearly = billing === "yearly"
  const current = plan.id === currentPlanId
  const pending = plan.id === pendingPlanId
  return (
    // abbr is what a screen reader repeats on every cell of the column: the plan's name, not its price and button too.
    <th scope="col" abbr={plan.name} className={cn(STICKY_HEAD_CLASS, "z-20 p-3 text-start font-normal @3xl:p-4", planSurface(plan))}>
      <div className="flex flex-col gap-3">
        <div className="flex flex-wrap items-baseline gap-x-2">
          <span className="text-[15px] font-medium">{plan.name}</span>
          {plan.recommended ? <span className="text-[13px] text-muted-foreground">Recommended</span> : null}
        </div>
        <div className="flex flex-col gap-1">
          <div className="flex flex-wrap items-baseline gap-x-2">
            {yearly && plan.monthly > plan.yearly ? (
              <s data-testid="anchor-price" className="text-[13px] text-muted-foreground tabular-nums">
                <span className="sr-only">Monthly price </span>
                {formatMoney(plan.monthly, currency)}
              </s>
            ) : null}
            <motion.span
              key={billing}
              className="text-display text-2xl tabular-nums"
              initial={reduceMotion ? false : PRICE_START}
              animate={PRICE_END}
              transition={SPRING_DEFAULT}
            >
              {formatMoney(yearly ? plan.yearly : plan.monthly, currency)}
            </motion.span>
            <span className="text-[13px] text-muted-foreground">/ month</span>
          </div>
          <p className="text-[13px] text-muted-foreground">{billingNote(plan, billing, currency)}</p>
        </div>
        <Button
          variant={plan.recommended ? "mise" : "outline"}
          disabled={pendingPlanId !== undefined || current}
          aria-busy={pending || undefined}
          onClick={() => onSelect?.(plan.id, billing)}
          className="h-auto min-h-9 w-full px-2 py-1.5 whitespace-normal pointer-coarse:min-h-10"
        >
          {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {current ? "Current plan" : plan.cta}
        </Button>
      </div>
    </th>
  )
}

function GroupRows({ group, plans }: { group: MatrixGroup; plans: readonly PricingPlan[] }) {
  return (
    <tbody>
      {group.name ? (
        <tr>
          <th scope="rowgroup" className={cn(FEATURE_CELL_CLASS, "pt-6 pb-2 text-[15px] font-medium")}>
            {group.name}
          </th>
          {plans.map((plan) => (
            <td key={plan.id} className={cn(plan.recommended && "bg-muted")} />
          ))}
        </tr>
      ) : null}
      {group.rows.map((label) => (
        <tr key={label}>
          <th scope="row" className={cn(FEATURE_CELL_CLASS, "border-b py-3 font-normal text-muted-foreground")}>
            {label}
          </th>
          {plans.map((plan) => (
            <td key={plan.id} className={cn("border-b px-3 py-3 @3xl:px-4", plan.recommended && "bg-muted")}>
              <span className="flex">
                <FeatureValue value={matrixValue(plan, label)} />
              </span>
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  )
}

interface MatrixProps extends Omit<HeadProps, "plan"> {
  plans: readonly PricingPlan[]
}

/** Overflow is on only while narrow: on a wide table it would capture the sticky header, which would stop following the page. */
function Matrix({ plans, ...head }: MatrixProps) {
  return (
    <ScrollShadow
      axis="both"
      aria-label="Plans compared feature by feature"
      className="-mx-6 @3xl:mx-0"
      scrollerClassName="relative max-h-[560px] @3xl:max-h-none @3xl:overflow-visible"
    >
      {/* Only while narrow: once overflow is off, a table wider than the page would widen the page. relative on the scroller keeps the absolute sr-only text in the cells inside its clip. */}
      <table
        className="w-full min-w-(--matrix-min-width) table-fixed border-separate border-spacing-0 text-[13px] leading-relaxed @3xl:min-w-0"
        style={{ "--matrix-min-width": `${FEATURE_COLUMN_PX + plans.length * PLAN_COLUMN_PX}px` } as React.CSSProperties}
      >
        <colgroup>
          <col className="w-32 @3xl:w-1/4" />
        </colgroup>
        <thead>
          <tr>
            <th scope="col" className={cn(STICKY_HEAD_CLASS, FEATURE_CELL_CLASS, "z-30 pt-3 font-normal @3xl:pt-4")}>
              <span className="sr-only">Feature</span>
            </th>
            {plans.map((plan) => (
              <PlanHead key={plan.id} plan={plan} {...head} />
            ))}
          </tr>
        </thead>
        {matrixGroups(plans).map((group) => (
          <GroupRows key={group.name} group={group} plans={plans} />
        ))}
      </table>
    </ScrollShadow>
  )
}

function PricingComparison({
  plans = COMPARISON_PLANS,
  billing,
  defaultBilling = "yearly",
  onBillingChange,
  onSelect,
  title = "Every line, side by side.",
  description = "Move up or down whenever you like.",
  currency = "USD",
  stickyTop = 0,
  pendingPlanId,
  currentPlanId,
  error,
  className,
  style,
  ...props
}: PricingCommonProps & { stickyTop?: number }) {
  const [active, change] = useBilling(billing, defaultBilling, onBillingChange)
  const rootStyle = { "--pricing-sticky-top": `${stickyTop}px`, ...style } as React.CSSProperties
  return (
    <section data-slot="pricing-table" data-variant="comparison" className={cn("@container w-full bg-background", className)} style={rootStyle} {...props}>
      <div className="px-6 py-12 @2xl:py-16">
        <div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
          <PricingHeader title={title} description={description}>
            {plans.length > 0 ? <BillingToggle billing={active} saving={bestYearlySaving(plans)} roomyTouch onChange={change} /> : null}
          </PricingHeader>
          {plans.length > 0 ? (
            <Matrix plans={plans} billing={active} currency={currency} pendingPlanId={pendingPlanId} currentPlanId={currentPlanId} onSelect={onSelect} />
          ) : (
            <NoPlans />
          )}
          <PricingError message={error} />
        </div>
      </div>
    </section>
  )
}

export { PricingComparison }
```

### `registry/miseui/blocks/pricing-table-matrix.ts`

```ts
import type { PricingFeature, PricingPlan } from "./pricing-table-parts"

interface MatrixGroup {
  /** Empty for rows that were given no group. */
  name: string
  rows: readonly string[]
}

function withRow(groups: readonly MatrixGroup[], feature: PricingFeature): readonly MatrixGroup[] {
  const name = feature.group ?? ""
  const existing = groups.find((group) => group.name === name)
  if (!existing) return [...groups, { name, rows: [feature.label] }]
  if (existing.rows.includes(feature.label)) return groups
  return groups.map((group) => (group === existing ? { ...group, rows: [...group.rows, feature.label] } : group))
}

/** Every row any plan mentions, in first-seen order, so a plan with an extra feature still gets its line. */
function matrixGroups(plans: readonly PricingPlan[]) {
  return plans.flatMap((plan) => plan.features).reduce<readonly MatrixGroup[]>(withRow, [])
}

/** A plan that never mentions a row does not have it. */
function matrixValue(plan: PricingPlan, label: string) {
  return plan.features.find((feature) => feature.label === label)?.value ?? false
}

export { matrixGroups, matrixValue, type MatrixGroup }
```

### `registry/miseui/blocks/pricing-table-sample.ts`

```ts
import type { PricingFeature, PricingPlan } from "./pricing-table-parts"

function rows(invoices: string, seats: string, exports: boolean, saml: boolean): readonly PricingFeature[] {
  return [
    { label: "Invoices a month", value: invoices },
    { label: "Team seats", value: seats },
    { label: "Payment reminders", value: true },
    { label: "Accounting export", value: exports },
    { label: "SAML sign-in", value: saml },
  ]
}

const DEFAULT_PLANS: readonly PricingPlan[] = [
  {
    id: "solo",
    name: "Solo",
    description: "For freelancers sending a few invoices.",
    monthly: 0,
    yearly: 0,
    cta: "Start free",
    features: rows("20", "1", false, false),
  },
  {
    id: "team",
    name: "Team",
    description: "For small teams that bill every week.",
    monthly: 29,
    yearly: 24,
    cta: "Start 14-day trial",
    recommended: true,
    features: rows("Unlimited", "5", true, false),
  },
  {
    id: "business",
    name: "Business",
    description: "For finance teams with approval steps.",
    monthly: 79,
    yearly: 66,
    cta: "Choose Business",
    features: rows("Unlimited", "25", true, true),
  },
]

interface MatrixValues {
  invoices: string
  recurring: boolean
  exports: boolean
  seats: string
  approvals: boolean
  saml: boolean
  priority: boolean
}

function matrixRows(values: MatrixValues): readonly PricingFeature[] {
  return [
    { group: "Billing", label: "Invoices a month", value: values.invoices },
    { group: "Billing", label: "Payment reminders", value: true },
    { group: "Billing", label: "Recurring invoices", value: values.recurring },
    { group: "Billing", label: "Accounting export", value: values.exports },
    { group: "Team", label: "Team seats", value: values.seats },
    { group: "Team", label: "Approval steps", value: values.approvals },
    { group: "Team", label: "SAML sign-in", value: values.saml },
    { group: "Support", label: "Email support", value: true },
    { group: "Support", label: "Reply within a day", value: values.priority },
  ]
}

const MATRIX_BY_PLAN: Readonly<Record<string, readonly PricingFeature[]>> = {
  solo: matrixRows({ invoices: "20", recurring: false, exports: false, seats: "1", approvals: false, saml: false, priority: false }),
  team: matrixRows({ invoices: "Unlimited", recurring: true, exports: true, seats: "5", approvals: false, saml: false, priority: true }),
  business: matrixRows({ invoices: "Unlimited", recurring: true, exports: true, seats: "25", approvals: true, saml: true, priority: true }),
}

/** The same plans at the same prices, with enough rows that a side-by-side is worth reading. */
const COMPARISON_PLANS: readonly PricingPlan[] = DEFAULT_PLANS.map((plan) => ({ ...plan, features: MATRIX_BY_PLAN[plan.id] }))

/** A false row is left off the list on `single`: with one plan there is nothing to compare it against. */
const SINGLE_PLANS: readonly PricingPlan[] = [
  {
    id: "paperline",
    name: "Paperline",
    description: "Everything is included, for every person on the team.",
    monthly: 12,
    yearly: 10,
    cta: "Start 14-day trial",
    features: [
      { label: "Unlimited invoices", value: true },
      { label: "Payment reminders", value: true },
      { label: "Recurring invoices", value: true },
      { label: "Accounting export", value: true },
      { label: "Approval steps", value: true },
      { label: "SAML sign-in", value: true },
    ],
  },
]

export { COMPARISON_PLANS, DEFAULT_PLANS, SINGLE_PLANS }
```

### `registry/miseui/blocks/pricing-table-seats.ts`

```ts
import { useState } from "react"
import { MONTHS_IN_YEAR, type PricingBilling, type PricingPlan } from "./pricing-table-parts"

const DEFAULT_SEATS = 5

interface PricingSeatProps {
  /** Control the seat count yourself. Leave it out and the block keeps its own, starting from `defaultSeats`. */
  seats?: number
  defaultSeats?: number
  minSeats?: number
  maxSeats?: number
  onSeatsChange?: (seats: number) => void
}

/** Prices per seat on the plan. Everything shown is worked out from these, so no sum on screen can disagree with another. */
function seatTotals(plan: PricingPlan, billing: PricingBilling, seats: number) {
  const perSeat = billing === "yearly" ? plan.yearly : plan.monthly
  const yearlySaving = billing === "yearly" ? (plan.monthly - plan.yearly) * seats * MONTHS_IN_YEAR : 0
  return {
    perSeat,
    perMonth: perSeat * seats,
    anchor: yearlySaving > 0 ? plan.monthly * seats : undefined,
    billedYearly: plan.yearly * seats * MONTHS_IN_YEAR,
    yearlySaving,
  }
}

function seatSummary(billing: PricingBilling, seats: number, perSeat: string, billedYearly: string) {
  const people = `${seats} ${seats === 1 ? "seat" : "seats"} at ${perSeat}`
  return billing === "yearly" ? `${people}, ${billedYearly} billed yearly` : `${people}, billed monthly`
}

function useSeats({ seats, defaultSeats = DEFAULT_SEATS, onSeatsChange }: PricingSeatProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultSeats)
  // A cleared field is someone halfway through typing. The last good count stays until they settle on a new one.
  const change = (next: number | null) => {
    if (next === null) return
    setUncontrolled(next)
    onSeatsChange?.(next)
  }
  return [seats ?? uncontrolled, change] as const
}

export { seatSummary, seatTotals, useSeats, type PricingSeatProps }
```

### `registry/miseui/blocks/pricing-table-single.tsx`

```tsx
import { useId } from "react"
import { Check, Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { SPRING_DEFAULT } from "@/registry/miseui/lib/motion"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Label } from "@/registry/miseui/ui/label"
import { NumberInput } from "@/registry/miseui/ui/number-input"
import { BillingToggle, NoPlans, PricingError, bestYearlySaving, useBilling, type PricingCommonProps } from "./pricing-table-billing"
import { formatMoney, type PricingBilling, type PricingPlan } from "./pricing-table-parts"
import { SINGLE_PLANS } from "./pricing-table-sample"
import { seatSummary, seatTotals, useSeats, type PricingSeatProps } from "./pricing-table-seats"

const DEFAULT_MIN_SEATS = 1
const DEFAULT_MAX_SEATS = 500
const PRICE_START = { opacity: 0, y: 8 }
const PRICE_END = { opacity: 1, y: 0 }
const PLATE_SHADOW_CLASS = "shadow-[0_16px_40px_-24px_color-mix(in_oklab,var(--foreground)_24%,transparent)] dark:shadow-none"

interface TotalProps {
  plan: PricingPlan
  billing: PricingBilling
  seats: number
  currency: string
}

/** Keyed on billing only: a total that re-enters on every seat would judder while someone holds the plus button. */
function SeatTotal({ plan, billing, seats, currency }: TotalProps) {
  const reduceMotion = useReducedMotion()
  const { perSeat, perMonth, anchor, billedYearly, yearlySaving } = seatTotals(plan, billing, seats)
  return (
    <div className="flex flex-col gap-1">
      <div className="flex flex-wrap items-baseline gap-x-2">
        {anchor ? (
          <s data-testid="anchor-price" className="text-[15px] text-muted-foreground tabular-nums">
            <span className="sr-only">Monthly price </span>
            {formatMoney(anchor, currency)}
          </s>
        ) : null}
        <motion.span
          key={billing}
          data-testid="seat-total"
          className="text-display text-4xl tabular-nums @md:text-5xl"
          initial={reduceMotion ? false : PRICE_START}
          animate={PRICE_END}
          transition={SPRING_DEFAULT}
        >
          {formatMoney(perMonth, currency)}
        </motion.span>
        <span className="text-[13px] text-muted-foreground">/ month</span>
      </div>
      <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">
        {seatSummary(billing, seats, formatMoney(perSeat, currency), formatMoney(billedYearly, currency))}
      </p>
      {yearlySaving > 0 ? <p className="text-[13px] leading-relaxed text-muted-foreground">You save {formatMoney(yearlySaving, currency)} a year.</p> : null}
    </div>
  )
}

function IncludedList({ plan }: { plan: PricingPlan }) {
  const included = plan.features.filter((feature) => feature.value !== false)
  return (
    <ul aria-label={`${plan.name} includes`} className="grid grid-cols-1 gap-x-6 gap-y-3 text-[15px] leading-relaxed @lg:grid-cols-2">
      {included.map((feature) => (
        <li key={feature.label} className="flex min-w-0 items-start gap-3">
          <Check aria-hidden="true" className="mt-1 size-4 shrink-0 text-muted-foreground" />
          <span className="min-w-0 text-pretty">
            {feature.label}
            {typeof feature.value === "string" ? <span className="text-muted-foreground"> {feature.value}</span> : null}
          </span>
        </li>
      ))}
    </ul>
  )
}

interface PlateProps extends PricingSeatProps {
  plan: PricingPlan
  billing: PricingBilling
  onBillingChange: (billing: PricingBilling) => void
  currency: string
  pending: boolean
  current: boolean
  disabled: boolean
  onSelect?: PricingCommonProps["onSelect"]
}

function SeatPlate({ plan, billing, onBillingChange, currency, pending, current, disabled, onSelect, minSeats = DEFAULT_MIN_SEATS, maxSeats = DEFAULT_MAX_SEATS, ...seatProps }: PlateProps) {
  const seatFieldId = useId()
  const [count, setCount] = useSeats(seatProps)
  return (
    <div className={cn("flex min-w-0 flex-col gap-6 rounded-2xl border bg-background p-6 @md:p-8 dark:bg-card", PLATE_SHADOW_CLASS)}>
      <BillingToggle billing={billing} saving={bestYearlySaving([plan])} exact roomyTouch onChange={onBillingChange} />
      <div className="flex items-center justify-between gap-4">
        <Label htmlFor={seatFieldId}>Seats</Label>
        <NumberInput
          id={seatFieldId}
          variant="split"
          value={count}
          min={minSeats}
          max={maxSeats}
          onValueChange={setCount}
          incrementLabel="Add a seat"
          decrementLabel="Remove a seat"
          className="w-36"
        />
      </div>
      <SeatTotal plan={plan} billing={billing} seats={count} currency={currency} />
      <Button
        size="lg"
        variant="mise"
        disabled={disabled || current}
        aria-busy={pending || undefined}
        onClick={() => onSelect?.(plan.id, billing, count)}
        className="w-full"
      >
        {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {current ? "Current plan" : plan.cta}
      </Button>
    </div>
  )
}

function PricingSingle({
  plans = SINGLE_PLANS,
  billing,
  defaultBilling = "yearly",
  onBillingChange,
  onSelect,
  title = "One plan, priced by the seat.",
  description = "Add people when they start, remove them when they leave.",
  currency = "USD",
  pendingPlanId,
  currentPlanId,
  error,
  seat,
  className,
  ...props
}: PricingCommonProps & { seat?: PricingSeatProps }) {
  const [active, change] = useBilling(billing, defaultBilling, onBillingChange)
  const plan = plans.find((candidate) => candidate.recommended) ?? plans[0]
  return (
    <section data-slot="pricing-table" data-variant="single" className={cn("@container w-full bg-muted/40 dark:bg-background", className)} {...props}>
      <div className="px-6 py-12 @2xl:py-16">
        <div className="mx-auto grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] items-center gap-8 @3xl:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] @3xl:gap-12">
          <div className="flex min-w-0 flex-col gap-8">
            <h2 className="text-display text-3xl leading-[1.12]! text-balance @2xl:text-4xl">
              {title} <span className="text-muted-foreground">{description}</span>
            </h2>
            {plan ? <IncludedList plan={plan} /> : <NoPlans />}
          </div>
          {plan ? (
            <SeatPlate
              plan={plan}
              billing={active}
              onBillingChange={change}
              currency={currency}
              pending={plan.id === pendingPlanId}
              current={plan.id === currentPlanId}
              disabled={pendingPlanId !== undefined}
              onSelect={onSelect}
              {...seat}
            />
          ) : null}
          <div className="@3xl:col-span-2 empty:hidden">
            <PricingError message={error} />
          </div>
        </div>
      </div>
    </section>
  )
}

export { PricingSingle }
```
