Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
uiatom

Pricing tier

One plan card with an accessible price, an honest not-included state, and a recommended flag.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/pricing-tier.json

Pulls in lucide-react@^1.47.0, badge, button, heading, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/pricing-tier.tsx
import { PricingTier } from "@registry/ui/pricing-tier"

export function Demo() {
  return (
    <ul className="grid gap-6 md:grid-cols-2">
      <PricingTier
        name="Starter"
        price="$0"
        srPrice="Free"
        period="/month"
        description="Everything you need to ship a landing page."
        features={[
          { label: "All sections and atoms" },
          { label: "Three theme presets" },
          { label: "Priority support", included: false },
        ]}
        cta={{ label: "Start free", href: "#start" }}
      />
      <PricingTier
        featured
        name="Team"
        price="$29"
        srPrice="29 dollars"
        period="/month"
        description="For teams shipping more than one site."
        features={[
          { label: "Everything in Starter" },
          { label: "Shared theme tokens", note: "Sync across projects" },
          { label: "Priority support" },
        ]}
        cta={{ label: "Start a trial", href: "#trial" }}
        footnote="No card required for 14 days."
      />
    </ul>
  )
}

Source

What the CLI copies into your project, byte for byte.

components/ui/pricing-tier.tsx
/**
 * PricingTier — one plan card: name, price, feature list, and a call to action.
 *
 * a11y: three things worth spelling out.
 *
 * 1. The price is split into a visible form and a spoken form, because "$29/mo"
 *    is read as "dollar twenty nine slash m o". `srPrice` fixes that.
 * 2. Features are a real `<ul>`, and a feature marked `included: false` gets
 *    visually hidden "Not included" text — a struck-through row with a grey
 *    cross conveys nothing without sight.
 * 3. The "Most popular" flag is a `Badge` with text, not a coloured border, and
 *    it is referenced from the tier's accessible name via `aria-describedby`.
 *
 * The card is a `<li>` by default: a pricing table is a list of plans.
 *
 * Dependencies: lucide-react, react, @/lib/types, @/lib/utils, ./badge, ./button,
 * ./heading.
 */

import { CheckIcon, XIcon } from "lucide-react"
import type { ElementType, ReactNode } from "react"

import type { CtaItem, HeadingLevel, LinkComponent } from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Heading } from "@/components/ui/heading"

export interface PricingFeature {
  label: string
  /** Default `true`. `false` renders a struck row with hidden "Not included". */
  included?: boolean
  /** Extra detail shown under the feature. */
  note?: string
}

export interface PricingTierItem {
  name: string
  /** Display form, e.g. `"$29"`. */
  price: string
  /** Spoken form, e.g. `"29 dollars"`. Strongly recommended. */
  srPrice?: string
  /** Billing cadence, e.g. `"/month"`. */
  period?: string
  description?: string
  features: PricingFeature[]
  cta: CtaItem
  /** Marks this tier as the recommended one. */
  featured?: boolean
  /** Text for the featured flag. Defaults to `"Most popular"`. */
  featuredLabel?: string
  /** Small print under the CTA. */
  footnote?: string
}

export interface PricingTierProps extends PricingTierItem {
  link?: LinkComponent
  /** Outline level of the tier name. Defaults to `3` (under a section `<h2>`). */
  headingLevel?: HeadingLevel
  as?: "li" | "div"
  className?: string
  children?: ReactNode
}

export function PricingTier({
  name,
  price,
  srPrice,
  period,
  description,
  features,
  cta,
  featured = false,
  featuredLabel = "Most popular",
  footnote,
  link,
  headingLevel = 3,
  as = "li",
  className,
  children,
}: PricingTierProps) {
  const Link = (link ?? "a") as ElementType
  const Root = as as ElementType
  const flagId = featured ? `${slugId(name, "tier")}-flag` : undefined

  return (
    <Root
      className={cn(
        "bg-card text-card-foreground relative flex h-full flex-col gap-6 rounded-xl border p-6 sm:p-8",
        featured && "border-primary ring-primary shadow-lg ring-1",
        className,
      )}
    >
      <div className="flex flex-col gap-2">
        <div className="flex items-center justify-between gap-3">
          <Heading level={headingLevel} className="text-foreground text-lg font-semibold">
            {name}
          </Heading>
          {featured ? (
            <Badge id={flagId} variant="primary" size="sm">
              {featuredLabel}
            </Badge>
          ) : null}
        </div>
        {description ? (
          <p className="text-muted-foreground text-pretty text-sm">{description}</p>
        ) : null}
      </div>

      <p className="flex items-baseline gap-1">
        <span
          aria-hidden
          className="text-foreground text-display-sm font-semibold tracking-tight"
        >
          {price}
        </span>
        {period ? (
          <span aria-hidden className="text-muted-foreground text-sm">
            {period}
          </span>
        ) : null}
        <span className="sr-only">
          {srPrice ?? price}
          {period ? ` ${period.replace(/^\//, "per ")}` : ""}
        </span>
      </p>

      <ul className="flex flex-1 flex-col gap-3">
        {features.map((feature) => {
          const included = feature.included ?? true
          const Icon = included ? CheckIcon : XIcon
          return (
            <li key={feature.label} className="flex items-start gap-3 text-sm">
              <Icon
                aria-hidden
                focusable="false"
                className={cn(
                  "mt-0.5 size-4 shrink-0",
                  included ? "text-primary" : "text-muted-foreground",
                )}
              />
              <span
                className={cn(
                  "flex flex-col gap-0.5",
                  !included && "text-muted-foreground",
                )}
              >
                <span className={cn(!included && "line-through")}>
                  <span className="sr-only">
                    {included ? "Included: " : "Not included: "}
                  </span>
                  {feature.label}
                </span>
                {feature.note ? (
                  <span className="text-muted-foreground text-xs">{feature.note}</span>
                ) : null}
              </span>
            </li>
          )
        })}
      </ul>

      {children}

      <div className="flex flex-col gap-2">
        <Link
          href={cta.href}
          aria-describedby={flagId}
          aria-label={cta["aria-label"] ?? `${cta.label} — ${name}`}
          {...(cta.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
          className={cn(
            buttonVariants({
              variant: cta.variant ?? (featured ? "primary" : "outline"),
              size: "md",
              fullWidth: true,
            }),
          )}
        >
          {cta.label}
        </Link>
        {footnote ? (
          <p className="text-muted-foreground text-pretty text-center text-xs">
            {footnote}
          </p>
        ) : null}
      </div>
    </Root>
  )
}

Props

PricingTierProps

Extends PricingTierItem.

Props for PricingTierProps
PropTypeDefault
linkLinkComponent
headingLevelOutline level of the tier name. Defaults to `3` (under a section `<h2>`).HeadingLevel3
as"li" | "div""li"
classNamestring
childrenReactNode