Skip to content
Uxelith
Sign in
Freev1.1.0Cards

Pricing Card

One plan in a pricing table, set large: a price that rolls when billing changes, a highlighted plan on a dark surface lit by your accent, and features read as included or not.

pnpm dlx shadcn add …/r/pricing-card.json
Licence
MIT
Packages
2
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/pricing-card.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as pricing-card.tsx. It's yours: change anything.

Usage

example.tsx
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.

pricing-card.tsx
"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

PropTypeDefaultDescription
namestring—Plan name, rendered as the card heading.
priceReactNode—Shown exactly as given: "$29", "₹2,499", "Free".
periodstring—Text after the price, e.g. "/month".
originalPricestring—Price before a discount, struck through and read as "Was …".
notestring—Small line under the price, e.g. "Billed yearly".
descriptionstring—One line under the price.
featuresTitlestring—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.
highlightedbooleanfalseA dark surface lit by the accent, a filled call to action.
badgestring—Small label beside the name.
headingLevel2 | 3 | 43Heading level for the plan name, to fit your page outline.
--pricing-accentCSS variable--primaryAccent 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

  1. v1.1.0Latest

    price accepts any node (e.g. an animated number), not only a string.

More in Cards

All 6 in Cards
  • CardsPro

    Card Stack

    A deck you throw cards from: the top card leans from where you grabbed it and flies off with the speed you gave it, the rest rise to take its place, and Undo brings it back. Keys and buttons too.

  • CardsPro

    Compare Slider

    Two layers split by a glass handle you drag, or that follows the pointer. A press glides the divider there on a spring, a double-click sends it home; optional feathered seam, vertical split and first-view sweep.

  • CardsPro

    Morph Card

    A card that grows into its own story: the photo, eyebrow and title fly to their places, and you pull the story down to fold it back into the grid. Built on the native modal dialog.