PricingTable
Shared ComponentsResponsive plan comparisons with explicit monthly and annual billed totals.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { PricingTable, type PricingPlan } from "@/components/ui/pricing-table";
import { useState } from "react";
const plans: PricingPlan[] = [
{
id: "starter",
name: "Starter",
description: "A focused foundation for your next idea.",
monthlyPrice: 9,
annualPrice: 90,
features: [
{ label: "Three active projects", included: true },
{ label: "Shared components", included: true },
{ label: "Advanced permissions", included: false },
],
},
{
id: "studio",
name: "Studio",
description: "A little more room for a growing team.",
monthlyPrice: 29,
annualPrice: 290,
featured: true,
features: [
{ label: "Unlimited projects", included: true },
{ label: "Shared components", included: true },
{ label: "Advanced permissions", included: true },
],
},
{
id: "enterprise",
name: "Enterprise",
description: "A considered setup for larger organizations.",
monthlyPrice: null,
actionLabel: "Contact our team",
features: [
{ label: "Custom workspace setup", included: true },
{ label: "Dedicated support", included: true },
{ label: "Flexible agreements", included: true },
],
},
];
export default function PricingTableExample() {
const [selected, setSelected] = useState("");
return (
<div className="w-full max-w-4xl space-y-4">
<PricingTable
label="Plans that grow with your team"
plans={plans}
onSelect={(plan, period) => setSelected(`${plan.name}, ${period}`)}
/>
<p role="status" className="text-xs text-muted-foreground">
{selected
? `Selected ${selected}. No purchase was made.`
: "Choose a billing period to compare the actual billed totals."}
</p>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible comparison heading. |
| plans | PricingPlan[]Default: required | Unique id, name, description, monthlyPrice, optional annualPrice, features, featured, disabled, and actionLabel. |
| period / onPeriodChange | BillingPeriod / callbackDefault: internal monthly | Optional controlled monthly/annual selection. |
| onSelect | (plan, period) => voidDefault: — | Enable plan action buttons and receive the selected plan/period. |
| currentId | stringDefault: — | Existing plan, whose action is disabled. |
| currency | stringDefault: USD | Intl currency code; invalid codes fall back to a plain number plus code. |
| className | stringDefault: — | Extend the container with semantic theme utilities. |