Skip to content
ilinxa/pro-ui

Pricing Table

alphav0.2.0

Pricing tiers side by side — monthly and annual toggle, highlighted tier, per-feature tooltips, and a comparison layout. RTL-safe, full i18n.

Category: MarketingUpdated: 2026-08-11Created: 2026-05-22Author: ilinxa

Context

Second component in the marketing category. Greenfield (no migration). Mirrors newsletter-signup's controlled-or-uncontrolled state pattern and share-bar's analytics callback shape. Part of the CMS conversion-block batch (sibling: signup-form). Tiers accept ReactNode CTAs so consumers wrap with their own router primitive (registry can't import next/*); a CtaSpec convenience overload renders a plain anchor/button for the common case.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/pricing-table

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/pricing-table-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Plans for every team

Start free, scale when you're ready.

Starter

For individuals trying things out.

$0
per month
Start free
  • Included:1 workspace
  • Included:Community support
  • Not included:
  • Not included:Custom domains
  • Not included:SLA
Most popular

Pro

For growing teams.

$19
per month
Start Pro trial
  • Included:Unlimited workspaces
  • Included:Priority email support
  • Included:Advanced analytics
  • Not included:Custom domains
  • Not included:SLA

Enterprise

For larger organizations.

$49
per month
Contact sales
  • Included:Unlimited workspaces
  • Included:Dedicated support
  • Included:Advanced analytics
  • Included:Custom domains
  • Included:

Demo source

demo.tsxtsx
"use client"; import { useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { PricingTable } from "./pricing-table";import {  PRICING_DEMO_LABELS_TR,  PRICING_DEMO_TIERS_THREE,  PRICING_DEMO_TIERS_TWO,} from "./dummy-data";import type { BillingPeriod } from "./types"; function ControlledDemo() {  const [billing, setBilling] = useState<BillingPeriod>("monthly");  const [log, setLog] = useState<string[]>([]);   return (    <div className="flex flex-col gap-6">      <PricingTable        heading="Plans"        subheading="Controlled toggle + analytics example."        billingToggle="monthly-annual"        billing={billing}        onBillingChange={setBilling}        tiers={PRICING_DEMO_TIERS_THREE}        onTierCtaClick={(name) =>          setLog((prev) =>            [`${new Date().toLocaleTimeString()} · ${name}`, ...prev].slice(0, 5),          )        }      />      <aside className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-4 text-xs text-muted-foreground">        <div className="mb-2 font-medium text-foreground">          External state · billing = {billing}        </div>        {log.length === 0 ? (          <p>Click a tier CTA to log analytics events here.</p>        ) : (          <ul className="flex flex-col gap-1 font-mono">            {log.map((entry, idx) => (              <li key={`${entry}-${idx}`}>{entry}</li>            ))}          </ul>        )}      </aside>    </div>  );} export default function PricingTableDemo() {  return (    <Tabs defaultValue="cards" className="w-full">      <SwipeTabsList>        <TabsTrigger value="cards">Cards · toggle</TabsTrigger>        <TabsTrigger value="two-tier">Free / Paid</TabsTrigger>        <TabsTrigger value="table">Comparison table</TabsTrigger>        <TabsTrigger value="controlled">Controlled + analytics</TabsTrigger>        <TabsTrigger value="i18n">Custom labels (TR)</TabsTrigger>        <TabsTrigger value="tones">Tones</TabsTrigger>      </SwipeTabsList>       <TabsContent value="cards" className="mt-6">        <PricingTable          heading="Plans for every team"          subheading="Start free, scale when you're ready."          billingToggle="monthly-annual"          tiers={PRICING_DEMO_TIERS_THREE}        />      </TabsContent>       <TabsContent value="two-tier" className="mt-6">        <PricingTable          heading="Simple, transparent pricing"          tiers={PRICING_DEMO_TIERS_TWO}        />      </TabsContent>       <TabsContent value="table" className="mt-6">        <PricingTable          heading="Compare plans"          subheading="Every feature, side by side."          layout="table"          billingToggle="monthly-annual"          tiers={PRICING_DEMO_TIERS_THREE}        />      </TabsContent>       <TabsContent value="controlled" className="mt-6">        <ControlledDemo />      </TabsContent>       <TabsContent value="i18n" className="mt-6">        <PricingTable          heading="Her ekip için planlar"          subheading="Ücretsiz başlayın, ihtiyacınız olduğunda büyüyün."          billingToggle="monthly-annual"          labels={PRICING_DEMO_LABELS_TR}          tiers={PRICING_DEMO_TIERS_THREE}        />      </TabsContent>       <TabsContent value="tones" className="mt-6 flex flex-col gap-12">        <PricingTable          heading="Primary"          tone="primary"          tiers={PRICING_DEMO_TIERS_TWO}        />        <PricingTable          heading="Accent"          tone="accent"          tiers={PRICING_DEMO_TIERS_TWO}        />        <PricingTable          heading="Muted"          tone="muted"          tiers={PRICING_DEMO_TIERS_TWO}        />      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for PricingTablefor any marketing-page pricing block: 2–4 tier cards with an optional monthly/annual billing toggle and a highlighted "Most popular" tier. Switch to layout="table" for a feature-comparison grid when buyers scroll deep before deciding.

Minimal example

import { PricingTable } from "@/components/pricing-table";

<PricingTable
  heading="Plans"
  billingToggle="monthly-annual"
  tiers={[starter, pro, enterprise]}
  onTierCtaClick={(name) =>
    analytics.track("pricing_cta_click", { tier: name })
  }
/>;

CTA shape

Each tier's cta accepts either a ReactNode (load-bearing — pass your router primitive, e.g. Next <Link>) or a CtaSpecconvenience overload. Registry code can't import next/*, so for SPA navigation you style your own element with buttonVariants:

import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";

// CtaSpec — renders a plain <a> styled with buttonVariants
cta: { label: "Start free", href: "/signup", variant: "outline" }

// ReactNode — load-bearing, consumer styles the router primitive directly
// (no <Button asChild> — asChild is Radix-only and breaks Base UI installs)
cta: (
  <Link href="/signup?plan=pro" className={cn(buttonVariants(), "w-full")}>
    Start Pro trial
  </Link>
)

onTierCtaClick(tierName) auto-fires for CtaSpec tiers. For ReactNode tiers, wire your own click handler — analytics on a custom router primitive is your call.

Billing toggle (controlled or uncontrolled)

// Uncontrolled (default)
<PricingTable
  billingToggle="monthly-annual"
  defaultBilling="annual"
  tiers={tiers}
/>;

// Controlled — useful when toggle state lives in a CMS editor preview
const [billing, setBilling] = useState<BillingPeriod>("monthly");
<PricingTable
  billingToggle="monthly-annual"
  billing={billing}
  onBillingChange={setBilling}
  tiers={tiers}
/>;

priceAnnual is the per-month rate when billed annually (not a yearly lump sum). When the toggle is in annual mode and priceAnnual < priceMonthly, the original monthly price renders alongside with strikethrough. A small yearly-lump label (derived as priceAnnual * 12) renders below the period string via labels.yearlyHint.

Comparison table

<PricingTable
  heading="Compare plans"
  layout="table"
  billingToggle="monthly-annual"
  tiers={tiers}
/>;

Renders a real semantic <table> with <th scope="col"> per tier and <th scope="row"> per feature label. The first column is sticky-start on horizontal overflow.

Free-tier label

<PricingTable
  labels={{ freeLabel: "Free" }}
  tiers={[{ ...starter, priceMonthly: 0 }, pro]}
/>;

Opt-in: when priceMonthly === 0 and labels.freeLabel is set, the free label renders instead of the currency-formatted 0.

Localization

<PricingTable
  labels={{
    monthlyLabel: "Aylık",
    annualLabel: "Yıllık",
    toggleGroupLabel: "Ödeme dönemi",
    popularBadge: "En popüler",
    periodMonthly: "/ ay",
    periodAnnual: "/ ay (yıllık ödeme)",
    freeLabel: "Ücretsiz",
    yearlyHint: "{amount} / yıl",
    featureIncluded: "Dahil",
    featureExcluded: "Dahil değil",
  }}
  tiers={tiers}
/>;

Three tones

  • primary (default) — signal-lime accent on highlighted tier ring + badge. Matches newsletter-signup's primary tone.
  • accent — accent-tone framing when primary is already in play elsewhere on the page.
  • muted — neutral, low-noise placement (docs / API products).

Accessibility

  • Root wraps content in a single <section> with aria-labelledby; works for both layouts.
  • Billing toggle is a WAI-ARIA role="radiogroup"; segments are role="radio"; arrow keys, Home, and End move focus + selection.
  • Tier cards are <article role="region"> with aria-labelledbypointing at the tier name. The "Most popular" badge announces via aria-label.
  • Feature rows are an <ul role="list">; check/x icons are aria-hidden; state is announced via sr-only labels.featureIncluded / featureExcluded.
  • Tooltips use shadcn Tooltip (Radix-backed) — open on focus + hover, dismiss on Escape and blur.

Development warnings

  • tiers.length < 2 or > 4 logs a dev-only warning (layout is designed for 2–4 tiers).
  • More than one tier marked highlighted logs a dev-only warning.
  • A CtaSpec with neither href nor onClick renders a disabled button + dev warn.

Features

  • 2 layout variants — cards (default, 2–4 tier grid) and table (feature-comparison grid, one column per tier)
  • Optional monthly/annual billing toggle (radiogroup, arrow-key + Home/End navigable)
  • 2–4 tiers via ReadonlyArray<PricingTier> with dev-mode length warning
  • Highlighted tier — signal-lime (`--primary`) accent border + ring + 'Most popular' badge (label overridable per-tier)
  • Per-feature included/excluded rows with optional shadcn Tooltip hint
  • Intl.NumberFormat price formatting pinned to en-US, ISO 4217 currencyCode, symbol-or-code display
  • Annual mode renders the per-month equivalent with strikethrough monthly when priceAnnual < priceMonthly + optional yearly-lump label via labels.yearlyHint
  • ReactNode CTAs (load-bearing) OR CtaSpec convenience overload ({ label, href?, onClick?, variant?, ariaLabel? })
  • Controlled-or-uncontrolled billing-period state (mirrors newsletter-signup's input convention)
  • Localizable labels bag with English defaults — toggle labels, sr-only a11y strings, period strings, optional freeLabel + yearlyHint
  • 3 tones — primary / accent / muted, palette aligned with newsletter-signup
  • Analytics hook (onTierCtaClick) — auto-fires for CtaSpec; consumer wires their own for ReactNode CTAs
  • Real <table> with scope=col/row semantics for the comparison layout; sticky-start first column on overflow scroll
  • React.memo wrapped

Tags

pricing-tablemarketingpricingconversioncms-blockform

Dependencies

shadcn primitives: button, tooltip
npm peer deps: lucide-react@^1.11.0