Pricing Table

Plan comparison with feature checklist, tier highlighting, CTA, and an optional monthly/annual toggle.

Report a bug

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

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

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

Dependencies

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