Pricing Table
Plan comparison with feature checklist, tier highlighting, CTA, and an optional monthly/annual toggle.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/pricing-table.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import {
type ButtonHTMLAttributes,
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
useCallback,
useState,
} from "react";
import { Check, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button, type ButtonProps } from "../button/button";
/**
* One row in a {@link PricingPlan}'s feature checklist.
*
* @public
*/
export type PricingFeature = {
/**
* When `true`, the row renders a check; when `false`, an X. Pass a string
* (e.g. `"5 users"`) to render the value as the limit indicator instead.
*/
included: boolean | string;
/** Human-readable feature description. */
label: ReactNode;
};
/**
* Call-to-action descriptor for a {@link PricingPlan}.
*
* @public
*/
export type PricingPlanCta = {
/** Button label. */
label: ReactNode;
/** Click handler. */
onClick?: ButtonHTMLAttributes<HTMLButtonElement>["onClick"];
/** Underlying {@link Button} variant. Defaults to `"default"`. */
variant?: ButtonProps["variant"];
};
/**
* Props for {@link PricingPlan}.
*
* @public
*/
export type PricingPlanProps = {
/** Optional badge text shown on highlighted plans (e.g. "Most Popular"). */
badge?: ReactNode;
/** Bottom-row call-to-action descriptor. */
cta?: PricingPlanCta;
/** Sub-headline shown under the plan name. */
description?: ReactNode;
/** Feature checklist. */
features?: PricingFeature[];
/** When `true`, the plan renders with emphasis styling. */
highlighted?: boolean;
/** Plan name (e.g. "Free", "Pro"). */
name: ReactNode;
/** Suffix shown next to the price (e.g. "/month"). */
period?: ReactNode;
/** Headline price. */
price: ReactNode;
} & Omit<ComponentPropsWithoutRef<"div">, "children">;
function FeatureIndicator({
included,
}: {
included: boolean | string;
}): ReactNode {
if (typeof included === "string") {
return (
<span
aria-hidden="true"
className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary"
>
✓
</span>
);
}
if (included) {
return (
<Check
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-primary"
/>
);
}
return (
<X
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60"
/>
);
}
function FeatureRow({ feature }: { feature: PricingFeature }): ReactNode {
const { included, label } = feature;
const isLimit = typeof included === "string";
return (
<li className="flex items-start gap-2 text-sm">
<FeatureIndicator included={included} />
<span
className={cn(
"flex-1",
included === false && "text-muted-foreground line-through",
)}
>
{label}
{isLimit ? (
<span className="ml-1 text-muted-foreground">({included})</span>
) : null}
</span>
</li>
);
}
function PlanBadgePill({
badge,
highlighted,
}: {
badge: ReactNode;
highlighted: boolean;
}): ReactNode {
return (
<span
className={cn(
"absolute -top-3 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide",
highlighted
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{badge}
</span>
);
}
function PlanHeader({
description,
name,
}: {
description: ReactNode;
name: ReactNode;
}): ReactNode {
return (
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold tracking-tight text-foreground">
{name}
</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
);
}
function PlanPrice({
period,
price,
}: {
period: ReactNode;
price: ReactNode;
}): ReactNode {
return (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold tracking-tight text-foreground">
{price}
</span>
{period ? (
<span className="text-sm text-muted-foreground">{period}</span>
) : null}
</div>
);
}
function PlanFeatures({ features }: { features: PricingFeature[] }): ReactNode {
return (
<ul className="flex flex-col gap-2">
{features.map((feature, index) => (
<FeatureRow
feature={feature}
key={`${typeof feature.label === "string" ? feature.label : index.toString()}-${index.toString()}`}
/>
))}
</ul>
);
}
type PlanCtaProps = {
cta: PricingPlanCta;
highlighted: boolean;
};
function PlanCta({ cta, highlighted }: PlanCtaProps): ReactNode {
const { label, onClick: handleCtaClick, variant } = cta;
return (
<Button
className="mt-auto w-full"
onClick={handleCtaClick}
type="button"
variant={variant ?? (highlighted ? "default" : "outline")}
>
{label}
</Button>
);
}
/**
* Single plan column inside a {@link PricingTable}.
*
* @example
* ```tsx
* <PricingPlan
* name="Pro"
* price="$29"
* period="/month"
* highlighted
* badge="Most Popular"
* features={[
* { label: "Unlimited projects", included: true },
* { label: "Storage", included: "100 GB" },
* ]}
* cta={{ label: "Start trial", onClick: startTrial }}
* />
* ```
*
* @public
*/
export const PricingPlan = forwardRef<HTMLDivElement, PricingPlanProps>(
(props, ref) => {
const {
badge,
className,
cta,
description,
features,
highlighted = false,
name,
period,
price,
...rest
} = props;
return (
<div
className={cn(
"relative flex flex-col gap-6 rounded-2xl border bg-background p-6 shadow-sm transition-colors",
highlighted
? "border-primary shadow-md ring-1 ring-primary/20"
: "border-border",
className,
)}
ref={ref}
{...rest}
>
{badge ? (
<PlanBadgePill badge={badge} highlighted={highlighted} />
) : null}
<PlanHeader description={description} name={name} />
<PlanPrice period={period} price={price} />
{features && features.length > 0 ? (
<PlanFeatures features={features} />
) : null}
{cta ? <PlanCta cta={cta} highlighted={highlighted} /> : null}
</div>
);
},
);
PricingPlan.displayName = "PricingPlan";
/**
* Billing period for {@link PricingTable}'s built-in toggle.
*
* @public
*/
export type PricingPeriod = "annual" | "monthly";
type PeriodLabels = {
annual?: ReactNode;
monthly?: ReactNode;
/** Optional caption shown next to the annual option (e.g. "Save 20%"). */
savings?: ReactNode;
};
/**
* Props for {@link PricingTable}.
*
* @public
*/
export type PricingTableProps = {
/** Period selected when uncontrolled. Defaults to `"monthly"`. */
defaultPeriod?: PricingPeriod;
/** Fires when the user changes the period (controlled or uncontrolled). */
onPeriodChange?: (period: PricingPeriod) => void;
/** Controlled value for the period toggle. */
period?: PricingPeriod;
/** Captions for the toggle. Defaults to `Monthly` / `Annual`. */
periodLabels?: PeriodLabels;
/** Set to `true` to render the built-in monthly/annual toggle. */
showPeriodToggle?: boolean;
} & ComponentPropsWithoutRef<"div">;
type PeriodToggleProps = {
labels: PeriodLabels;
onChange: (period: PricingPeriod) => void;
period: PricingPeriod;
};
function PeriodToggle({
labels,
onChange,
period,
}: PeriodToggleProps): ReactNode {
const handleSelectMonthly = useCallback(() => {
onChange("monthly");
}, [onChange]);
const handleSelectAnnual = useCallback(() => {
onChange("annual");
}, [onChange]);
return (
<div
aria-label="Billing period"
className="mx-auto inline-flex items-center gap-2 rounded-full border bg-muted/40 p-1 text-sm"
role="radiogroup"
>
<button
aria-checked={period === "monthly"}
className={cn(
"rounded-full px-3 py-1 transition-colors",
period === "monthly"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={handleSelectMonthly}
role="radio"
type="button"
>
{labels.monthly ?? "Monthly"}
</button>
<button
aria-checked={period === "annual"}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-1 transition-colors",
period === "annual"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={handleSelectAnnual}
role="radio"
type="button"
>
{labels.annual ?? "Annual"}
{labels.savings ? (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
{labels.savings}
</span>
) : null}
</button>
</div>
);
}
/**
* Plan comparison container. Lays out child {@link PricingPlan} columns
* side-by-side on desktop and stacks them on mobile. Optionally renders a
* monthly/annual period toggle whose value flows through `onPeriodChange`.
*
* @example
* ```tsx
* const [period, setPeriod] = useState<PricingPeriod>("monthly")
*
* <PricingTable showPeriodToggle period={period} onPeriodChange={setPeriod}>
* <PricingPlan name="Free" price="$0" period="/mo" cta={{ label: "Start" }} />
* <PricingPlan name="Pro" price={period === "monthly" ? "$29" : "$24"} highlighted />
* </PricingTable>
* ```
*
* @public
*/
export const PricingTable = forwardRef<HTMLDivElement, PricingTableProps>(
(props, ref) => {
const {
children,
className,
defaultPeriod = "monthly",
onPeriodChange,
period: controlledPeriod,
periodLabels,
showPeriodToggle = false,
...rest
} = props;
const [uncontrolledPeriod, setUncontrolledPeriod] =
useState<PricingPeriod>(defaultPeriod);
const period = controlledPeriod ?? uncontrolledPeriod;
const handlePeriodChange = useCallback(
(next: PricingPeriod) => {
if (controlledPeriod === undefined) setUncontrolledPeriod(next);
onPeriodChange?.(next);
},
[controlledPeriod, onPeriodChange],
);
return (
<div className={cn("flex flex-col gap-6", className)} ref={ref} {...rest}>
{showPeriodToggle ? (
<PeriodToggle
labels={periodLabels ?? {}}
onChange={handlePeriodChange}
period={period}
/>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{children}
</div>
</div>
);
},
);
PricingTable.displayName = "PricingTable";
typescript