Skip to content

PricingTable

Shared Components

Responsive plan comparisons with explicit monthly and annual billed totals.

Three plan cardsMonthly and annual totalsFeatured and custom-priced plansCurrent/unavailable actions
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Provide uniquely identified plans with a monthly price, optional annual total, feature inclusion, and an optional featured state. Own period/onPeriodChange or let the component own its billing selection. Missing annual prices use twelve times the monthly price without inventing a discount. Null, negative, or non-finite prices display custom pricing. The callback only reports a selection; no purchase, billing, or navigation occurs. Use currentId for the existing plan and provide real tax/eligibility information in the surrounding product.

Import
import { PricingTable, type PricingPlan } from "@/components/ui/pricing-table";

Shipped as editable source in src/components/ui/pricing-table.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/pricing-table.tsx
"use client";
import { useId, useState } from "react";
import { Check, Minus } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export type BillingPeriod = "monthly" | "annual";
export interface PricingPlan {
  id: string;
  name: string;
  description: string;
  monthlyPrice: number | null;
  annualPrice?: number | null;
  features: { label: string; included: boolean }[];
  featured?: boolean;
  disabled?: boolean;
  actionLabel?: string;
}
export interface PricingTableProps {
  label: string;
  plans: PricingPlan[];
  period?: BillingPeriod;
  onPeriodChange?: (period: BillingPeriod) => void;
  onSelect?: (plan: PricingPlan, period: BillingPeriod) => void;
  currentId?: string;
  currency?: string;
  className?: string;
}
export function planPrice(plan: PricingPlan, period: BillingPeriod) {
  const price =
    period === "monthly"
      ? plan.monthlyPrice
      : plan.annualPrice === undefined
        ? plan.monthlyPrice === null
          ? null
          : plan.monthlyPrice * 12
        : plan.annualPrice;
  return price !== null && Number.isFinite(price) && price >= 0 ? price : null;
}
export function PricingTable({
  label,
  plans,
  period: controlled,
  onPeriodChange,
  onSelect,
  currentId,
  currency = "USD",
  className,
}: PricingTableProps) {
  const id = useId();
  const [internal, setInternal] = useState<BillingPeriod>("monthly");
  const period = controlled ?? internal;
  const format = (price: number) => {
    try {
      return new Intl.NumberFormat("en-US", {
        style: "currency",
        currency,
        maximumFractionDigits: price % 1 ? 2 : 0,
      }).format(price);
    } catch {
      return `${price} ${currency}`;
    }
  };
  return (
    <section aria-label={label} className={cn("min-w-0 space-y-5", className)}>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <h3 className="font-semibold">{label}</h3>
        <fieldset className="flex gap-1 rounded-lg border bg-muted/20 p-1">
          <legend className="sr-only">Billing period</legend>
          {(["monthly", "annual"] as const).map((option) => (
            <label
              key={option}
              className={cn(
                "cursor-pointer rounded-md px-3 py-2 text-xs has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
                period === option && "bg-background font-medium shadow-xs",
              )}
            >
              <input
                type="radio"
                name={id}
                className="sr-only"
                checked={period === option}
                onChange={() => {
                  setInternal(option);
                  onPeriodChange?.(option);
                }}
              />
              {option === "monthly" ? "Monthly" : "Annual"}
            </label>
          ))}
        </fieldset>
      </div>
      <div className="grid gap-4 lg:grid-cols-3">
        {plans.map((plan) => {
          const price = planPrice(plan, period);
          return (
            <article
              key={plan.id}
              className={cn(
                "flex min-w-0 flex-col gap-5 rounded-xl border bg-card p-5",
                plan.featured && "border-primary shadow-sm",
              )}
            >
              <div className="flex items-center justify-between gap-2">
                <h4 className="font-semibold">{plan.name}</h4>
                {plan.featured && (
                  <span className="rounded-full bg-primary/10 px-2 py-1 text-[10px] font-medium text-primary">
                    Popular
                  </span>
                )}
              </div>
              <p className="min-h-10 text-xs leading-5 text-muted-foreground">
                {plan.description}
              </p>
              <div>
                <p className="text-3xl font-semibold tracking-tight">
                  {price === null ? "Let’s talk" : format(price)}
                </p>
                <p className="mt-1 text-xs text-muted-foreground">
                  {price === null
                    ? "Custom pricing"
                    : period === "monthly"
                      ? "Billed monthly"
                      : "Billed annually, total per year"}
                </p>
              </div>
              <ul className="flex-1 space-y-3">
                {plan.features.map((feature) => (
                  <li
                    key={feature.label}
                    className="flex items-start gap-2 text-xs"
                  >
                    {feature.included ? (
                      <Check
                        aria-hidden
                        className="size-4 shrink-0 text-primary"
                      />
                    ) : (
                      <Minus
                        aria-hidden
                        className="size-4 shrink-0 text-muted-foreground"
                      />
                    )}
                    <span>
                      {feature.label}
                      <span className="sr-only">
                        : {feature.included ? "included" : "not included"}
                      </span>
                    </span>
                  </li>
                ))}
              </ul>
              <Button
                type="button"
                variant={plan.featured ? "default" : "outline"}
                disabled={plan.disabled || plan.id === currentId || !onSelect}
                onClick={() => onSelect?.(plan, period)}
              >
                {plan.id === currentId
                  ? "Current plan"
                  : (plan.actionLabel ?? `Choose ${plan.name}`)}
              </Button>
            </article>
          );
        })}
      </div>
      {!plans.length && (
        <p className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
          No plans available.
        </p>
      )}
    </section>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

PricingTable props
PropType / DefaultDescription
labelstring

Default: required

Visible comparison heading.
plansPricingPlan[]

Default: required

Unique id, name, description, monthlyPrice, optional annualPrice, features, featured, disabled, and actionLabel.
period / onPeriodChangeBillingPeriod / callback

Default: internal monthly

Optional controlled monthly/annual selection.
onSelect(plan, period) => void

Default: —

Enable plan action buttons and receive the selected plan/period.
currentIdstring

Default: —

Existing plan, whose action is disabled.
currencystring

Default: USD

Intl currency code; invalid codes fall back to a plain number plus code.
classNamestring

Default: —

Extend the container with semantic theme utilities.

Build with these too