Build a SaaS pricing page in React and Tailwind
Build a React pricing page with Tailwind: plan data, a monthly and yearly toggle with animated prices, a featured plan, a comparison table, FAQ and a11y.
By MiniDev14 min read
A good SaaS pricing page in React is three to four plan cards driven by one data array, a monthly and yearly switch that changes the price in place, one visually featured plan, a comparison table for the details, and an FAQ that answers billing objections. With Tailwind CSS v4 and the free MiniDev UI blocks you can have all of it on screen in a few minutes, then spend your time on the numbers and the copy instead of the markup.
This guide builds the page from the real components: the PricingPlans block, PricingToggle, NumberRoll, ComparisonTable and FaqSection. Every prop shown below is the actual prop in the source you get when you install them.
Install the pieces
MiniDev UI is a shadcn-compatible registry, so each component is copied into components/ui in your project and you own the code. The pricing-plans block pulls in button, number-roll and segmented-control as registry dependencies.
npx shadcn@latest add https://ui.minidev.pro/r/pricing-plans.json
npx shadcn@latest add https://ui.minidev.pro/r/comparison-table.json
npx shadcn@latest add https://ui.minidev.pro/r/faq-section.json
If you prefer a package, npm i minidev-ui-kit and import from minidev-ui-kit/blocks/pricing-plans. Either way, add the token stylesheet once (@import "minidev-ui-kit/styles.css" or a copy of styles.css) so classes like bg-ink and text-fg-muted resolve. The rest of this guide assumes the copied files, imported from @/components/ui/*.
Structure the plans as data
Keep plans in one typed array and render everything from it. That way the cards, the comparison table and your checkout route all agree on names and prices. PricingPlans exports its Plan type:
import type { Plan } from "@/components/ui/pricing-plans"
export const PLANS: Plan[] = [
{
name: "Hobby",
blurb: "For side projects and trying things out.",
monthly: 0,
yearly: 0,
cta: "Start free",
features: ["1 project", "Community support", { label: "Custom domains", included: false }],
},
{
name: "Pro",
blurb: "For small teams shipping every week.",
monthly: 24,
yearly: 19,
cta: "Start 14-day trial",
featured: true,
features: ["Unlimited projects", "Custom domains", "Email support", "Usage analytics"],
},
{
name: "Business",
blurb: "For companies with a security review.",
monthly: 79,
yearly: 64,
cta: "Talk to sales",
features: ["Everything in Pro", "SSO and audit log", "99.9% uptime SLA"],
},
]
A few conventions that make the numbers read correctly:
- Store
yearlyas the per month price when billed yearly. The block shows it large and computes the annual total (yearly * 12) for the line underneath. - A price of
0renders the word Free and swaps the billing line for "Free forever for one person", so edit that string in your copy of the file if your free tier is different. - Excluded features are objects:
{ label, included: false }. They render with a minus icon, muted text and a visually hidden "(not included)" so screen readers do not read them as perks. - Three plans is the default grid (
lg:grid-cols-3). Four works if you change that class; five or more belongs in a comparison table instead.
import { PricingPlans } from "@/components/ui/pricing-plans"
import { PLANS } from "@/lib/plans"
export default function PricingPage() {
return (
<PricingPlans
title="Simple pricing that scales with you."
description="Per seat, per month. Change plans any time."
plans={PLANS}
currency="USD"
yearlyBadge="-20%"
unit="/ seat / mo"
per="per seat"
/>
)
}
PricingPlans is a client component because it holds the billing period in state. Your page.tsx can stay a server component and export metadata; it only passes plain data down.
The monthly and yearly toggle
The switch inside PricingPlans is a SegmentedControl with role="radiogroup", a roving tab index and arrow key, Home and End support. The yearly option carries a badge, which is where the yearlyBadge prop ends up. The block defaults to yearly, since that is usually the option you want people to see first.
If you are composing your own layout, PricingToggle is the same control as a standalone piece with a controlled value and onChange typed as "monthly" | "yearly":
"use client"
import * as React from "react"
import { PricingToggle } from "@/components/ui/pricing-toggle"
export function BillingSwitch() {
const [period, setPeriod] = React.useState<"monthly" | "yearly">("yearly")
return <PricingToggle value={period} onChange={setPeriod} />
}
PricingToggle prints a "Save 20% with yearly" hint under the control. Compute that figure from your plans rather than hard coding it, so the hint stays true when prices change: Math.round((1 - pro.yearly / pro.monthly) * 100).
Animate the price without hurting readability
When the period changes, the price should change in place so the eye can compare. NumberRoll is an odometer: each digit is a strip of 0 to 9 that springs to its new position, and only the digits that changed move. It takes standard Intl.NumberFormat options, so currency, grouping and decimals come from the platform rather than string concatenation.
import { NumberRoll } from "@/components/ui/number-roll"
const fmt = { style: "currency", currency: "USD", maximumFractionDigits: 0 } as Intl.NumberFormatOptions
<span className="text-5xl font-medium tabular-nums">
<NumberRoll value={period === "yearly" ? plan.yearly : plan.monthly} format={fmt} />
</span>
Two details matter here. The animated digits are aria-hidden and the formatted value is rendered once in an sr-only span, so assistive technology hears "$19", not a column of digits. And the animation reads useReducedMotion() from Motion, so users who ask for less motion get an instant swap. Use tabular-nums (NumberRoll already sets it) so a price going from 19 to 24 does not shift the layout.
Highlight one plan
Set featured: true on exactly one plan. In PricingPlans the featured card switches to the ink surface (bg-ink text-on-ink shadow-ink), gets a thin accent line along its top edge, extends slightly above and below its neighbors on large screens (lg:-my-4 lg:py-11), shows a "Most teams pick this" pill and uses the accent button variant. Change the pill text in your copy of the file to something specific to your product.
- Feature the plan most customers should buy, not the most expensive one. Buyers read the featured card as a recommendation.
- Do not rely on color alone. The pill text and the larger card carry the meaning for people who cannot see the contrast difference.
- Keep the CTA verbs different per plan ("Start free", "Start 14-day trial", "Talk to sales") so each button says what happens next.
For a denser grid, for example inside account settings, PricingTable renders PlanCard items. Each card takes a preformatted price string, a period, features, cta, onCta and a highlighted flag that adds an accent border.
import { PricingTable } from "@/components/ui/pricing-table"
<PricingTable
plans={[
{ name: "Hobby", price: "$0", features: ["1 project"], cta: "Current plan" },
{ name: "Pro", price: "$24", features: ["Unlimited projects", "Custom domains"], highlighted: true, onCta: () => upgrade("pro") },
{ name: "Business", price: "$79", features: ["SSO", "Audit log"], cta: "Contact sales" },
]}
/>
Add a comparison table
Cards should list the five or six features that decide the purchase. Everything else goes in a comparison table below them. ComparisonTable takes a list of features and, per plan, a values array aligned by index. A boolean renders a check or a minus icon; a string renders as text, which is what you want for limits.
import { ComparisonTable } from "@/components/ui/comparison-table"
<ComparisonTable
features={["Projects", "Team members", "Custom domains", "SSO", "Support"]}
plans={[
{ name: "Hobby", values: ["1", "1", false, false, "Community"] },
{ name: "Pro", values: ["Unlimited", "10", true, false, "Email"] },
{ name: "Business", values: ["Unlimited", "Unlimited", true, true, "Priority"] },
]}
/>
The table scrolls horizontally inside its rounded frame on narrow screens, which keeps the page itself from overflowing. Lucide icons are hidden from screen readers by default, so give the boolean cells a text alternative. Because the component lives in your repo, it is a two-line change in the cell renderer:
{typeof v === "boolean" ? (
<>
{v ? <CheckIcon className="size-4 text-success" /> : <MinusIcon className="size-4 text-fg-subtle" />}
<span className="sr-only">{v ? "Included" : "Not included"}</span>
</>
) : v}
PlanComparison is a smaller, fixed two-column Free versus Pro table. It is a good starting point to edit when you only sell one paid tier.
Answer billing questions in an FAQ
Pricing FAQs exist to remove the last reasons not to click. Cover trials, what happens when a trial ends, proration, refunds, taxes and invoices, discounts, and data ownership. FaqSection is a two-column block with a heading and an accordion; answers accept React nodes, so links work.
import { FaqSection } from "@/components/ui/faq-section"
<FaqSection
title="Billing questions"
description="Anything else? Email billing@yourapp.com."
items={[
{ q: "What happens after the trial?", a: "You choose a plan or stay on Hobby. We never charge without asking." },
{ q: "Can I switch plans later?", a: "Yes. Upgrades apply immediately and are prorated to the day." },
{ q: "Do you offer invoices?", a: <>Yes, on every paid plan. See <a href="/help/billing">billing help</a>.</> },
]}
/>
Each question is a real button inside an h3 with aria-expanded and aria-controls, answers are role="region" labelled by their question, one item is open at a time, and Up and Down arrows move focus between questions. The answer opens by animating grid-template-rows from 0fr to 1fr, so there is no height measuring and no layout jump. For a plain list without the heading column, use FaqList, which takes items of { q, a } strings.
Connect the buttons to checkout
PricingPlans calls onSelect(planName, period) when a plan button is pressed. Because it is a function prop, wire it from a small client wrapper and route to your checkout with both values:
"use client"
import { useRouter } from "next/navigation"
import { PricingPlans } from "@/components/ui/pricing-plans"
import { PLANS } from "@/lib/plans"
export function PlansClient() {
const router = useRouter()
return (
<PricingPlans
plans={PLANS}
onSelect={(plan, period) => {
const q = new URLSearchParams({ plan: plan.toLowerCase(), period })
router.push("/checkout?" + q.toString())
}}
/>
)
}
Never trust the price from the client. The checkout route should look up the plan and period on the server and create the payment session from your own price IDs.
Accessibility checklist
- The billing switch is a labelled radiogroup (
aria-label="Billing period"), operable with arrow keys, with a visible focus ring. - Prices are announced once as formatted text; the animated digits are hidden from assistive technology.
- Motion respects
prefers-reduced-motionin the price roll and the switch thumb. - Excluded features say "not included" to screen readers, not only with an icon.
- Every CTA is a real button with a distinct label. Avoid three buttons that all say "Get started".
- Check contrast of muted text on the ink card in both themes. The tokens are tuned for it, but custom accent colors can break it.
- The page has one
h1. The block renders its title as anh2, so put a page heading above it or pass the pricing title as the pageh1in your own wrapper.
Components used in this guide
Everything here is free and MIT licensed. The full Lumen template at /templates/lumen shows PricingPlans and FaqSection in a complete landing page, and the landing page guide covers where pricing sits in that flow. If you would rather hand off the whole product, the MiniDev studio builds complete SaaS products with this kit.
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
Components used in this guide
Frequently asked questions
How do I make a monthly and yearly pricing toggle in React?
Hold the period in state as "monthly" | "yearly", render a radiogroup such as PricingToggle or SegmentedControl to change it, and derive each displayed price from that state. Store both prices on each plan so nothing is recalculated from a discount percentage.
Should the yearly price show the monthly equivalent or the annual total?
Show the monthly equivalent large, since that is what people compare, and the annual total in a smaller line underneath. PricingPlans does both: it displays yearly and writes "Billed $X yearly" using yearly * 12.
How many plans should a SaaS pricing page have?
Three is the common default: a free or entry plan, the plan you want most people on, and a plan for larger companies. Put extra detail in a comparison table instead of adding more cards.
Is the MiniDev pricing block free to use in commercial projects?
Yes. MiniDev UI is MIT licensed, and the shadcn install copies the source into your project so you can change anything.