Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/pricing-card.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
pricing-card.tsx. It's yours: change anything.
Usage
import { PricingCard } from "@/components/pricing-card"
export function Pricing() {
return (
<PricingCard
name="Pro"
price="$29"
period="/month"
badge="Most popular"
highlighted
description="Everything a growing product needs."
cta={{ label: "Upgrade to Pro", href: "/checkout" }}
features={["Unlimited projects", "Priority support", { text: "SSO", included: false }]}
/>
)
}Source
One file, 272 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface PricingFeature {
text: string;
/** `false` shows the feature as not included. Defaults to `true`. */
included?: boolean;
}
export interface PricingCardProps extends Omit<React.ComponentProps<"article">, "title"> {
/** Plan name, e.g. "Pro". Rendered as the card's heading. */
name: string;
/** Price exactly as you want it shown, e.g. "$29", "₹2,499" or "Free" — or any node, like an animated number. */
price: React.ReactNode;
/** Billing period shown after the price, e.g. "/month". Omit for one-off prices. */
period?: string;
/** The price before a discount, shown struck through beside the price, e.g. "$39". */
originalPrice?: string;
/** A small line under the price, e.g. "Billed yearly" or "Per seat". */
note?: string;
description?: string;
/** A line above the features, e.g. "Everything in Starter, plus:". */
featuresTitle?: string;
features?: (string | PricingFeature)[];
/** Call to action. With `href` it renders a link, otherwise a button using `onClick`. */
cta?: { label: string; href?: string; onClick?: React.MouseEventHandler<HTMLButtonElement> };
/** Make this the plan people notice: a dark surface lit by the accent. */
highlighted?: boolean;
/** Small label beside the plan name, e.g. "Most popular". */
badge?: string;
/** Heading level for the plan name, to fit your page outline. */
headingLevel?: 2 | 3 | 4;
}
function CheckIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path d="m4.5 8.25 2.25 2.25 4.75-5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function DashIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path d="M5 8h6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function ArrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path d="M3.5 8h8.5m-3.5-3.5L12 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// "$29" → "$" + "29"; "29 €" → "29 " + "€". The symbol is set smaller, the number large.
function splitPrice(price: string) {
const match = /^(\D*?)(\d[\d.,\s]*\d|\d)(\D*)$/.exec(price.trim());
return match ? { before: match[1], amount: match[2], after: match[3] } : null;
}
const amountOf = (price: React.ReactNode) =>
typeof price === "number" ? price : typeof price === "string" ? Number(price.replace(/[^\d.]/g, "")) || 0 : 0;
function Price({ price }: { price: React.ReactNode }) {
if (typeof price !== "string") return <>{price}</>;
const parts = splitPrice(price);
if (!parts) return <>{price}</>;
return (
<>
{parts.before && <span className="mr-0.5 align-[0.6em] text-[0.42em] font-medium tracking-normal opacity-60">{parts.before}</span>}
{parts.amount}
{parts.after && <span className="ml-0.5 align-[0.6em] text-[0.42em] font-medium tracking-normal opacity-60">{parts.after}</span>}
</>
);
}
/**
* One plan in a pricing table, set like a poster: a large price, a clear
* call to action and a list that says what's in and what isn't.
*
* - The highlighted plan turns dark and is lit by your accent, so it stands
* out in light and dark themes alike.
* - When the price changes (a monthly/yearly toggle), the old price rolls
* away and the new one rolls in, up when it rises and down when it falls.
* Instant under `prefers-reduced-motion`; needs the `ux-price-in` and
* `ux-price-out` keyframes, which the registry adds.
* - The plan name is a real heading (`headingLevel`); each feature says
* "Included" or "Not included" to screen readers; a struck-through
* original price is read as "Was …".
* - Accent: `--pricing-accent` (default `--primary`).
*/
export function PricingCard({
name,
price,
period,
originalPrice,
note,
description,
featuresTitle,
features = [],
cta,
highlighted = false,
badge,
headingLevel = 3,
className,
...props
}: PricingCardProps) {
const Heading = `h${headingLevel}` as const;
const headingId = React.useId();
// A changed text or number price rolls; anything else (a node) just renders.
const rolls = typeof price === "string" || typeof price === "number";
const [roll, setRoll] = React.useState({ price, previous: null as React.ReactNode, up: true, key: 0 });
if (rolls && roll.price !== price) {
setRoll({ price, previous: roll.price, up: amountOf(price) >= amountOf(roll.price), key: roll.key + 1 });
}
const ctaClass = cn(
"group/cta mt-7 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl px-5 text-sm font-medium outline-none",
"transition-[background-color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] active:scale-[0.98]",
"focus-visible:ring-2 focus-visible:ring-[var(--pricing-accent)] focus-visible:ring-offset-2 motion-reduce:transition-none motion-reduce:active:scale-100",
highlighted
? "bg-[var(--pricing-accent)] text-[var(--pricing-accent-foreground)] shadow-[inset_0_1px_0_rgb(255_255_255/0.3),0_10px_30px_-12px_var(--pricing-accent)] hover:opacity-90 focus-visible:ring-offset-[var(--pricing-ink)]"
: "bg-foreground text-background hover:bg-foreground/85 focus-visible:ring-offset-card",
);
const ctaContent = (
<>
{cta?.label}
<ArrowIcon className="size-4 transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover/cta:translate-x-0.5 motion-reduce:transition-none" />
</>
);
return (
<article
aria-labelledby={headingId}
data-highlighted={highlighted || undefined}
className={cn(
"[--pricing-accent:var(--primary)] [--pricing-accent-foreground:var(--primary-foreground)] [--pricing-ink:oklch(from_var(--pricing-accent)_0.2_0.025_h)]",
"relative isolate flex flex-col overflow-hidden rounded-3xl border p-7 sm:p-8",
highlighted
? "border-transparent bg-[var(--pricing-ink)] text-[oklch(0.98_0_0)] shadow-[0_30px_80px_-40px_var(--pricing-accent),0_0_0_1px_color-mix(in_oklab,var(--pricing-accent)_45%,transparent)] [--pricing-muted:oklch(0.98_0_0/0.62)] [--pricing-rule:oklch(1_0_0/0.1)]"
: "border-border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04)] [--pricing-muted:var(--muted-foreground)] [--pricing-rule:var(--border)]",
className,
)}
{...props}
>
{highlighted && (
<>
{/* The accent light from above, and the lit top edge. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-64 bg-[radial-gradient(70%_100%_at_50%_0%,color-mix(in_oklab,var(--pricing-accent)_38%,transparent),transparent_70%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-10 top-0 h-px bg-gradient-to-r from-transparent via-[oklch(from_var(--pricing-accent)_calc(l+0.2)_c_h)] to-transparent"
/>
</>
)}
<div className="flex min-h-7 items-center justify-between gap-3">
<Heading id={headingId} className="text-lg font-semibold tracking-tight">
{name}
</Heading>
{badge && (
<span
className={cn(
"inline-flex h-7 items-center rounded-full px-3 text-xs font-medium",
highlighted
? "bg-[var(--pricing-accent)] text-[var(--pricing-accent-foreground)]"
: "border border-[color-mix(in_oklab,var(--pricing-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--pricing-accent)_10%,transparent)] text-[var(--pricing-accent)]",
)}
>
{badge}
</span>
)}
</div>
{description && <p className="mt-2 text-sm leading-relaxed text-[var(--pricing-muted)]">{description}</p>}
<div className="mt-8 flex items-baseline gap-2">
<p className="relative flex items-end text-6xl font-semibold leading-none tabular-nums tracking-[-0.05em]">
<span
key={roll.key}
className={cn(
"inline-block",
roll.key > 0 && "animate-[ux-price-in_300ms_cubic-bezier(0.22,1,0.36,1)_both] motion-reduce:animate-none",
)}
style={{ "--price-from": roll.up ? "0.45em" : "-0.45em" } as React.CSSProperties}
>
<Price price={rolls ? roll.price : price} />
</span>
{roll.previous != null && (
<span
key={`out-${roll.key}`}
aria-hidden="true"
className="absolute left-0 top-0 inline-block animate-[ux-price-out_300ms_cubic-bezier(0.22,1,0.36,1)_both] motion-reduce:hidden"
style={{ "--price-to": roll.up ? "-0.45em" : "0.45em" } as React.CSSProperties}
onAnimationEnd={() => setRoll((r) => ({ ...r, previous: null }))}
>
<Price price={roll.previous} />
</span>
)}
</p>
{/* The period sits on the price's baseline, whatever renders the price; a struck price sits just above it. */}
{(period || originalPrice) && (
<span className="relative text-sm leading-tight text-[var(--pricing-muted)]">
{originalPrice && (
<s className={cn("whitespace-nowrap decoration-[color-mix(in_oklab,currentColor_70%,transparent)]", period && "absolute bottom-full left-0 mb-0.5")}>
<span className="sr-only">Was </span>
{originalPrice}
</s>
)}
{period}
</span>
)}
</div>
{note && <p className="mt-3 text-xs text-[var(--pricing-muted)]">{note}</p>}
{cta &&
(cta.href ? (
<a href={cta.href} className={ctaClass}>
{ctaContent}
</a>
) : (
<button type="button" onClick={cta.onClick} className={ctaClass}>
{ctaContent}
</button>
))}
{features.length > 0 && (
<div className="mt-8 border-t border-[var(--pricing-rule)] pt-7 text-sm">
{featuresTitle && <p className="mb-4 font-medium">{featuresTitle}</p>}
<ul className="space-y-3">
{features.map((feature) => {
const { text, included = true } = typeof feature === "string" ? { text: feature } : feature;
return (
<li key={text} className={cn("flex items-start gap-3", !included && "text-[var(--pricing-muted)]")}>
{included ? (
<span
className={cn(
"mt-px grid size-[1.125rem] shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--pricing-accent)_16%,transparent)]",
highlighted ? "text-[oklch(from_var(--pricing-accent)_max(l,0.75)_c_h)]" : "text-[var(--pricing-accent)]",
)}
>
<CheckIcon className="size-3.5" />
</span>
) : (
<span className="mt-px grid size-[1.125rem] shrink-0 place-items-center rounded-full opacity-60">
<DashIcon className="size-3.5" />
</span>
)}
<span>
<span className="sr-only">{included ? "Included: " : "Not included: "}</span>
{text}
</span>
</li>
);
})}
</ul>
</div>
)}
</article>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | — | Plan name, rendered as the card heading. |
| price | ReactNode | — | Shown exactly as given: "$29", "₹2,499", "Free". |
| period | string | — | Text after the price, e.g. "/month". |
| originalPrice | string | — | Price before a discount, struck through and read as "Was …". |
| note | string | — | Small line under the price, e.g. "Billed yearly". |
| description | string | — | One line under the price. |
| featuresTitle | string | — | Line above the features, e.g. "Everything in Starter, plus:". |
| features | (string | { text, included? })[] | [] | Feature list. included: false shows it as missing. |
| cta | { label, href?, onClick? } | — | With href renders a link, otherwise a button. |
| highlighted | boolean | false | A dark surface lit by the accent, a filled call to action. |
| badge | string | — | Small label beside the name. |
| headingLevel | 2 | 3 | 4 | 3 | Heading level for the plan name, to fit your page outline. |
| --pricing-accent | CSS variable | --primary | Accent for the highlight, badge and checkmarks. |
Accessibility
- An article named by its plan heading; headingLevel fits it to your page outline.
- Each feature is announced as "Included" or "Not included", not only shown with an icon.
- A struck-through original price is read as "Was …".
- The rolling price is instant under prefers-reduced-motion; the old price is aria-hidden while it leaves.
Changelog
- v1.1.0Latest
price accepts any node (e.g. an animated number), not only a string.