Pricing Table
Plan comparison with feature checklist, tier highlighting, CTA, and an optional monthly/annual toggle. Install Pricing Table from the VLLNT UI registry with the shadcn CLI.
Plan comparison with feature checklist, tier highlighting, CTA, and an optional monthly/annual toggle. Part of the billing family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Pricing Table to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/pricing-table.jsonSource
"use client";
import {
type ButtonHTMLAttributes,
type ComponentPropsWithoutRef,
type KeyboardEvent,
type ReactNode,
type Ref,
useCallback,
useRef,
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",
)}
>
<span className="sr-only">
{included === false ? "Not included: " : "Included: "}
</span>
{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 = ({
ref,
...props
}: PricingPlanProps & { ref?: React.Ref<HTMLDivElement> }) => {
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;
};
const PERIOD_ARROW_KEYS = new Set([
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
]);
type PeriodRadioProps = {
buttonRef: Ref<HTMLButtonElement>;
children: ReactNode;
onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
onSelect: () => void;
selected: boolean;
};
function PeriodRadio({
buttonRef,
children,
onKeyDown,
onSelect,
selected,
}: PeriodRadioProps): ReactNode {
return (
<button
aria-checked={selected}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-1 transition-colors",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
onClick={onSelect}
onKeyDown={onKeyDown}
ref={buttonRef}
role="radio"
tabIndex={selected ? 0 : -1}
type="button"
>
{children}
</button>
);
}
function PeriodToggle({
labels,
onChange,
period,
}: PeriodToggleProps): ReactNode {
const monthlyRef = useRef<HTMLButtonElement>(null);
const annualRef = useRef<HTMLButtonElement>(null);
const handleSelectMonthly = useCallback(() => {
onChange("monthly");
}, [onChange]);
const handleSelectAnnual = useCallback(() => {
onChange("annual");
}, [onChange]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (!PERIOD_ARROW_KEYS.has(event.key)) return;
event.preventDefault();
const next: PricingPeriod = period === "monthly" ? "annual" : "monthly";
onChange(next);
(next === "monthly" ? monthlyRef : annualRef).current?.focus();
},
[onChange, period],
);
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"
>
<PeriodRadio
buttonRef={monthlyRef}
onKeyDown={handleKeyDown}
onSelect={handleSelectMonthly}
selected={period === "monthly"}
>
{labels.monthly ?? "Monthly"}
</PeriodRadio>
<PeriodRadio
buttonRef={annualRef}
onKeyDown={handleKeyDown}
onSelect={handleSelectAnnual}
selected={period === "annual"}
>
{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}
</PeriodRadio>
</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 = ({
ref,
...props
}: PricingTableProps & { ref?: React.Ref<HTMLDivElement> }) => {
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";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.