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.

Report a bug

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.json

Source

"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.

Dependencies

  • @vllnt/ui@^0.3.0
  • lucide-react