Tessli

Pricing tiers compared on the limits that matter

Two or three plans compared on the few limits buyers actually weigh, with one recommended plan and an honest billing-period switch.

  • Section
  • Pricing
  • Community

Community — not yet reviewed by a maintainer

Preview

Theme
Width
State

1440 px wide. Open this preview on its own ↗

Install

npx shadcn@latest add online-scope-lab/tessli/pricing-tiers

Run it in a project set up with shadcn/ui. It adds pricing-tiers.tsx to your components folder, plus the shadcn/ui button component if you don’t have it, and the item takes on your project’s theme.

When to use it

Use when

  • The product has two or three self-serve plans that differ on a few clear limits.
  • Monthly and annual billing are both offered and the difference should be visible before checkout.

Avoid when

  • There are more than three plans, or pricing is usage-based; use a calculator or a full comparison table.
  • Every plan needs a sales conversation; show what is included and one contact route instead.
  • The plans differ on dozens of features; link to a full comparison table below a short summary.

Common AI default vs. this

The common default

Three identical rounded cards, the middle one raised with a gradient border and a "Most popular" pill, a dozen ticked features per card that mostly repeat, the same "Get started" button on every card, and an annual toggle whose saving is a hard-coded percentage.

What this item does instead

  • Plans are compared on three named limits, written out as values ("Up to 50", "1 year") in the same order on every plan, so the difference is readable at a glance.
  • Only the recommended plan gets the primary button; it is marked with a text label as well as a border, so the recommendation does not depend on colour.
  • The annual saving is computed from the prices, and the billed-yearly total is shown, so the switch never promises a discount the numbers do not support.
  • Each call to action says what happens next ("Start 14-day trial", "Choose Business") instead of repeating "Get started".
  • A footnote states taxes and trial terms near the price, not on another page.

Why it is built this way

Pricing pages go wrong when every plan looks the same and lists everything. Buyers compare a few limits, so this item makes those limits the structure: the same three rows on every plan, values written out, in the same order.

Only one plan is recommended, and only that plan gets the primary button. The billing switch is a pair of native radio buttons; the saving it shows is calculated from the prices you pass in, so it cannot drift out of date.

Pass two or three plans. Beyond that, the grid stops working on smaller screens and a comparison table or calculator serves people better.

Rules

How it breaks

Common mistakes when adapting this item.

  • Adding a fourth plan to the same grid, which breaks the comparison on tablets and phones.
  • Listing features every plan has, which buries the limits that differ.
  • Marking more than one plan as recommended, or giving every plan a primary button.
  • Typing a saving percentage by hand that no longer matches the prices.

States, responsive behaviour and accessibility

States
default, annual
Desktop
Plans side by side (three plans from 1024px, two from 768px), each a subgrid over the same rows so names, prices, actions and limits line up; the billing switch sits beside the heading.
Mobile
Plans stack in order with the recommended plan unchanged in position; each keeps its own labelled list of limits, so nothing depends on column alignment.

Accessibility

  • The billing period is a fieldset with a visible legend and two native radio buttons, operable with arrow keys and labelled with the saving in text.
  • Plan names are h3 headings inside a list; prices and limits are text, and the recommended plan is named in text, not only by its border.
  • Calls to action are links at least 44px tall with a visible focus ring.

Code

web/registry/items/pricing-tiers/pricing-tiers.tsx

"use client";

import * as React from "react";

import { cn } from "@/registry/shadcn/lib/utils";
import { buttonVariants } from "@/registry/shadcn/ui/button";

export type BillingPeriod = "monthly" | "annual";

export type PricingLimit = {
  id: string;
  /** What buyers compare: "Members", "Version history". */
  label: string;
};

export type PricingPlan = {
  id: string;
  name: string;
  /** Who the plan is for, in one short sentence. */
  description: string;
  /** Price per seat per month for each billing period. 0 renders "Free". */
  price: Record<BillingPeriod, number>;
  /** The value for each limit id, written out ("Up to 50", not a tick). */
  limits: Record<string, string>;
  cta: { label: string; href: string };
  /** At most one plan. It gets the only primary button and a text label. */
  recommended?: boolean;
};

export type PricingTiersProps = Omit<
  React.ComponentProps<"section">,
  "title"
> & {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Two or three plans. Use a comparison table or calculator beyond that. */
  plans: PricingPlan[];
  /** The three to five limits the plans actually differ on, in order. */
  limits: PricingLimit[];
  currency?: string;
  locale?: string;
  /** Unit after the price, e.g. "per member / month". */
  unit?: string;
  recommendedLabel?: string;
  defaultPeriod?: BillingPeriod;
  /** Taxes, trial terms or anything a buyer must know before paying. */
  footnote?: React.ReactNode;
};

function formatPrice(value: number, currency: string, locale: string) {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    minimumFractionDigits: Number.isInteger(value) ? 0 : 2,
    maximumFractionDigits: 2,
  }).format(value);
}

export function PricingTiers({
  title,
  description,
  plans,
  limits,
  currency = "USD",
  locale = "en-US",
  unit = "per member / month",
  recommendedLabel = "Recommended",
  defaultPeriod = "monthly",
  footnote,
  className,
  ...props
}: PricingTiersProps) {
  const [period, setPeriod] = React.useState<BillingPeriod>(defaultPeriod);
  const id = React.useId();
  const headingId = `${id}-heading`;

  // The annual saving is derived from the prices, never typed in by hand.
  const savings = plans
    .filter((plan) => plan.price.monthly > 0)
    .map((plan) => 1 - plan.price.annual / plan.price.monthly);
  const bestSaving = savings.length ? Math.max(...savings) : 0;
  const savingLabel =
    bestSaving > 0 ? `save up to ${Math.round(bestSaving * 100)}%` : null;

  return (
    <section
      aria-labelledby={headingId}
      data-slot="pricing-tiers"
      className={cn("bg-background text-foreground", className)}
      {...props}
    >
      <div className="mx-auto w-full max-w-6xl px-4 py-12 sm:px-6 sm:py-16 lg:px-8 lg:py-24">
        <div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
          <div className="max-w-2xl">
            <h2
              id={headingId}
              className="text-3xl leading-tight font-semibold tracking-tight text-balance sm:text-4xl"
            >
              {title}
            </h2>
            {description ? (
              <p className="mt-4 text-base leading-relaxed text-pretty text-muted-foreground">
                {description}
              </p>
            ) : null}
          </div>

          <fieldset className="shrink-0">
            <legend className="mb-2 text-sm font-medium">Billing period</legend>
            <div className="inline-flex rounded-md border border-border bg-muted p-1">
              {(["monthly", "annual"] as const).map((option) => (
                <label
                  key={option}
                  className="relative flex min-h-11 cursor-pointer items-center rounded-sm px-4 text-sm font-medium text-muted-foreground has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-xs has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50"
                >
                  <input
                    type="radio"
                    name={`${id}-period`}
                    value={option}
                    checked={period === option}
                    onChange={() => setPeriod(option)}
                    className="sr-only"
                  />
                  {option === "monthly" ? "Monthly" : "Annual"}
                  {option === "annual" && savingLabel ? (
                    <span className="ml-1.5 font-normal">({savingLabel})</span>
                  ) : null}
                </label>
              ))}
            </div>
          </fieldset>
        </div>

        <ul
          className={cn(
            "mt-10 grid gap-6",
            // Three plans need 1024px to stay readable side by side.
            plans.length === 2 ? "md:grid-cols-2" : "lg:grid-cols-3",
          )}
        >
          {plans.map((plan) => {
            const price = plan.price[period];
            return (
              <li
                key={plan.id}
                className={cn(
                  // Side by side, each plan is a subgrid over the same seven
                  // rows, so names, prices, actions and limits line up.
                  "flex flex-col rounded-lg border bg-card p-6 text-card-foreground",
                  plans.length === 2
                    ? "md:row-span-7 md:grid md:grid-rows-subgrid md:gap-y-0"
                    : "lg:row-span-7 lg:grid lg:grid-rows-subgrid lg:gap-y-0",
                  plan.recommended
                    ? "border-2 border-primary"
                    : "border-border",
                )}
              >
                <p
                  className={cn(
                    "min-h-5 text-sm font-medium",
                    !plan.recommended &&
                      (plans.length === 2
                        ? "hidden md:block"
                        : "hidden lg:block"),
                  )}
                >
                  {plan.recommended ? recommendedLabel : null}
                </p>
                <h3 className="mt-1 text-xl font-semibold tracking-tight">
                  {plan.name}
                </h3>
                <p className="mt-1 text-sm leading-relaxed text-muted-foreground">
                  {plan.description}
                </p>

                <p className="mt-6 flex flex-wrap items-baseline gap-x-2">
                  <span className="text-4xl font-semibold tracking-tight tabular-nums">
                    {price === 0
                      ? "Free"
                      : formatPrice(price, currency, locale)}
                  </span>
                  {price === 0 ? null : (
                    <span className="text-sm text-muted-foreground">
                      {unit}
                    </span>
                  )}
                </p>
                <p className="mt-1 min-h-5 text-sm text-muted-foreground">
                  {price === 0
                    ? "No card needed"
                    : period === "annual"
                      ? `Billed yearly at ${formatPrice(price * 12, currency, locale)} per member`
                      : "Billed monthly"}
                </p>

                <a
                  href={plan.cta.href}
                  className={cn(
                    buttonVariants({
                      variant: plan.recommended ? "default" : "outline",
                      size: "lg",
                    }),
                    "mt-6 h-11 w-full",
                  )}
                >
                  {plan.cta.label}
                </a>

                <dl className="mt-6 border-t border-border text-sm">
                  {limits.map((limit) => (
                    <div
                      key={limit.id}
                      className="flex items-baseline justify-between gap-4 border-b border-border py-3 last:border-b-0"
                    >
                      <dt className="text-muted-foreground">{limit.label}</dt>
                      <dd className="text-right font-medium">
                        {plan.limits[limit.id] ?? "—"}
                      </dd>
                    </div>
                  ))}
                </dl>
              </li>
            );
          })}
        </ul>

        {footnote ? (
          <div className="mt-8 max-w-prose text-sm leading-relaxed text-muted-foreground">
            {footnote}
          </div>
        ) : null}
      </div>
    </section>
  );
}

Credits and licence

Contributors
None credited yet.
Provenance
Original work
Licence
MIT
Review
Community — not yet reviewed by a maintainer

For agents

The same taste notes and code as structured data.