Pricing Table
Comparaison de forfaits avec liste de fonctionnalites, mise en avant des niveaux, CTA et une bascule mensuel/annuel optionnelle. Installez Pricing Table depuis le registre VLLNT UI avec la CLI shadcn.
Comparaison de forfaits avec liste de fonctionnalites, mise en avant des niveaux, CTA et une bascule mensuel/annuel optionnelle. Fait partie de la famille billing dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Pricing Table a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
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
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.