# Shadcn Pricing Page: Yearly Toggle, Table, Phone Layout

> Build a shadcn pricing page in React: a Monthly / Yearly toggle whose prices roll, a comparison table with a sticky header, and plans that work on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/shadcn-pricing-page

## TL;DR

A shadcn pricing page needs a Monthly / Yearly switch, plan cards with one featured plan, a comparison table, buttons wired to checkout and an FAQ. shadcn/ui ships no pricing component or block as of October 2026, so you compose one from Card, Tabs and Table or install a ready pricing table. With the Wingo UI Pricing Table you store each plan's yearly total, and the component shows the monthly equivalent, the savings badge, and swipeable cards with a one-column comparison on phones.

A shadcn pricing page looks simple until you build one: a Monthly / Yearly switch, three plan cards, a comparison table, buttons that start a checkout. shadcn/ui itself ships no pricing block as of October 2026, so most people start from a gallery. [shadcnblocks](https://www.shadcnblocks.com/pages/pricing-page) lists five pricing page layouts, [shadcn/studio](https://shadcnstudio.com/pages/pricing-page) lists twenty with one marked free, and 21st.dev hosts community pricing tables such as [Kokonut UI's](https://21st.dev/@kokonutd/components/pricing-table.md), which animates prices with NumberFlow (all checked on October 16, 2026). What those pages rarely spell out is the part that breaks: the yearly math, what the comparison table does on a 390px screen, and what a screen reader hears when the prices change.

This tutorial builds a React pricing page with the [Pricing Table](https://wingo-ui.com/components/pricing-table) from Wingo UI. We build that library, so we are not neutral. Every snippet uses the component's real props, and every outside fact links to its source.

## What does a shadcn pricing page need?

A shadcn pricing page needs five parts, in this order:

1. **A period switch.** Monthly and Yearly, with the yearly saving on the Yearly option. It changes prices already on screen, so it is a radio group; tabs are for swapping panels.
2. **Plan cards.** Two to four plans, one featured. Each card has a name, a one-line "who it is for", a price, a short feature list and one button.
3. **A comparison table.** Every feature in rows, plans in columns. Nielsen Norman Group's [comparison table guidelines](https://www.nngroup.com/articles/comparison-tables/) recommend about five items at most, consistent attributes for every item, and a header that stays visible while long tables scroll.
4. **Buttons wired to checkout.** A button that shows it is working while your server creates the checkout session.
5. **Objection handling.** An FAQ and a closing call to action. These belong to the page around the table.

If you assemble this from shadcn/ui parts, you reach for Card, Toggle Group or Tabs, and Table, then write the price formatting, the savings math, the screen reader announcement and the phone layout yourself. The rest of this post uses a React pricing table component that already does those and installs as source, like any shadcn component. It covers the first four parts in one file.

## How do you install the pricing table?

One CLI command copies this shadcn pricing table and everything it imports into your project:

```bash
npx wingo-ui@latest add pricing-table
```

The CLI copies `components/ui/pricing-table.tsx` into your project as source, plus the items it imports: the [Segmented Control](https://wingo-ui.com/components/segmented-control) for the period switch, Price for the rolling digits, Carousel for phones, Table for the comparison, and the shared tones, motion and locale helpers. It also installs `motion` and `lucide-react`. The file is a client component (`"use client"`), so it works inside an App Router page.

## How do you model plans and yearly prices?

Plans are plain data. A price is a number when it never changes, `{ monthly, yearly }` when it follows the switch, `0` for free and `null` for a custom plan (its button defaults to "Contact us").

The one decision to get right: **`yearly` is what the customer pays per year**, the full annual amount. Store the number your billing provider charges and let the component do the division.

```tsx
// app/pricing/plans.ts
import type { PricingComparisonGroup, PricingPlan } from "@/components/ui/pricing-table";

export const plans: PricingPlan[] = [
  {
    id: "free",
    name: "Free",
    description: "For side projects and trying things out.",
    price: 0,
    features: ["1 project", "Community support", { label: "Custom domain", included: false }],
    cta: { label: "Start for free", href: "/sign-up" },
  },
  {
    id: "starter",
    name: "Starter",
    description: "For freelancers who ship for clients.",
    price: { monthly: 15, yearly: 144 },
    featured: true,
    features: [
      "10 projects",
      "Custom domains",
      { label: "Email support", hint: "We answer within one business day." },
    ],
    note: "14 days free, no card",
  },
  {
    id: "team",
    name: "Team",
    description: "For agencies and product teams.",
    price: { monthly: 40, yearly: 384 },
    featuresTitle: "Everything in Starter, plus:",
    features: ["Unlimited projects", "Roles and permissions", "Audit log"],
  },
];
```

With these numbers, Yearly shows Starter as $12/mo with "Billed $144 yearly" under it, and Team as $32/mo with "Billed $384 yearly". The Yearly option gets a "Save 20%" badge: the component computes `1 - yearly / (monthly * 12)` for every plan, rounds it down and shows the best one. If you prefer the yearly amount on the card, set `yearlyDisplay="per-year"`, and the line under it reads "You save $36" instead.

A feature can be a string (included) or `{ label, included?, hint? }`. `included: false` draws it as missing, and a `hint` adds an (i) button that opens on hover, focus or tap.

Rendering it takes one element:

```tsx
// app/pricing/page.tsx
import { PricingTable } from "@/components/ui/pricing-table";
import { plans } from "./plans";

export default function PricingRoute() {
  return <PricingTable plans={plans} currency="USD" />;
}
```

That page can stay a server component: the plans are plain data, and `PricingTable` is the client boundary.

> Live demo (Pricing table): Switch Monthly to Yearly and watch every price roll to its new value while the billed line changes. Hover or tap an (i) to read its hint, and scroll to the comparison below the plans. Try it at [Pricing table](https://wingo-ui.com/components/pricing-table) and install it with `npx wingo-ui@latest add pricing-table`.

## How does the Monthly / Yearly toggle work?

The pricing toggle is the free Segmented Control, rendered with `aria-label="Billing period"`. It is a radio group with one tab stop: Left and Right move and select, and on touch you can press the thumb and drag it across. We explain the control in depth, including when Tabs or a Toggle Group fit better, in our [React segmented control guide](https://wingo-ui.com/blog/react-segmented-control).

Three details matter on a pricing page:

- **The prices roll.** Each digit rolls to its new value when the period changes. With reduced motion turned on, the new value appears at once. Set `animatePrice={false}` to skip the roll for everyone.
- **A screen reader hears the change once.** A polite live region announces the period and the featured plan's new price, for example "Yearly. Starter: $12 per month, Billed $144 yearly". It stays quiet on the first render, so the page load is not read twice.
- **The switch hides itself** when no plan has a yearly price. Hide it on purpose with `showToggle={false}`.

The component opens on Monthly. To open on Yearly without tracking state, pass `defaultPeriod="yearly"`. To read the period from outside (for checkout or analytics), control it with `period` and `onPeriodChange`, typed with the exported `BillingPeriod`. The checkout section below shows the full controlled version.

## How do you add the comparison table?

Pass `comparison` as groups of rows. Each row has one value per plan id: `true` is a check, `false` a dash, and anything else prints as text. Add it to the same `plans.ts`, keyed by the plan ids from above:

```tsx
// app/pricing/plans.ts (continued)
export const comparison: PricingComparisonGroup[] = [
  {
    label: "Projects",
    rows: [
      { label: "Projects", values: { free: "1", starter: "10", team: "Unlimited" } },
      { label: "Custom domains", values: { free: false, starter: true, team: true } },
    ],
  },
  {
    label: "Support",
    rows: [
      { label: "Email support", values: { free: false, starter: true, team: true } },
      {
        label: "Response time",
        hint: "Business days, from the first message.",
        values: { free: "Community", starter: "1 day", team: "4 hours" },
      },
    ],
  },
];
```

A plan id missing from `values` renders a dash, the same as `false`, so a typo or a renamed plan id silently reads as "not included". Keep the keys in sync with `plans`. On a wide layout this pricing comparison table renders as a real `<table>` whose header row of plan names and buttons sticks while you scroll, which is the sticky header the NN/g article asks for. If your site has a fixed top bar, set the `--topbar-offset` CSS variable on a parent to its height so the sticky header stops under it instead of behind it.

## How should plans stack on a phone?

On a phone, plans should become swipeable cards and the comparison should show one plan at a time. Stacking alone is not enough: three plan cards stacked on a phone push the comparison several screens down, and a comparison table with four columns at 390px either squeezes the labels or scrolls sideways and loses them.

The Pricing Table switches layout at 768px of **its own width**, measured with container queries instead of the viewport. It lays out correctly in a narrow docs column or next to a sidebar, and because it is pure CSS the server HTML already has the right layout, with no flash on hydration.

Below 768px:

- **Plans become swipeable cards** that open on the featured plan, with a peek of the next card and dots to jump between them. Left and Right work with focus inside. With two plans, `mobileLayout="stack"` puts them one under the other instead.
- **The comparison becomes a plan picker** (another Segmented Control) above one column of rows for the picked plan, ending in that plan's button. This is the same move as turning a table into cards, which we cover for data tables in [responsive tables as mobile cards](https://wingo-ui.com/blog/responsive-table-mobile-cards).
- **The switch goes full width**, and on touch screens it is 44px tall.

The narrow column on the docs page shows the phone layout, so you can try both: open the [Pricing Table docs](https://wingo-ui.com/components/pricing-table) on a laptop and on a phone.

## How do you connect the buttons to checkout?

A plan's `cta` takes either an `href` (renders a link) or an `onClick`. When `onClick` returns a promise, that plan's button shows a spinner until it settles, and repeated taps are ignored. Here is the final page, with the period controlled and each paid plan starting a checkout on your own API route:

```tsx
// app/pricing/pricing-plans.tsx
"use client";

import { useState } from "react";
import { PricingTable, type BillingPeriod, type PricingPlan } from "@/components/ui/pricing-table";
import { comparison, plans } from "./plans";

async function startCheckout(planId: string, period: BillingPeriod) {
  // your route creates the checkout session on the server and returns its url
  const res = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ planId, period }),
  });
  // a rejected promise also stops the spinner, so the button can be pressed again
  if (!res.ok) throw new Error(`Checkout failed: ${res.status}`);
  const { url } = (await res.json()) as { url: string };
  window.location.assign(url);
}

export function PricingPlans() {
  const [period, setPeriod] = useState<BillingPeriod>("yearly");

  const withCheckout: PricingPlan[] = plans.map((plan) =>
    plan.cta?.href
      ? plan
      : { ...plan, cta: { label: `Choose ${plan.name}`, onClick: () => startCheckout(plan.id, period) } },
  );

  return (
    <PricingTable
      plans={withCheckout}
      comparison={comparison}
      period={period}
      onPeriodChange={setPeriod}
      currency="USD"
      tone="brand"
    />
  );
}
```

```tsx
// app/pricing/page.tsx
import type { Metadata } from "next";
import { PricingPlans } from "./pricing-plans";

export const metadata: Metadata = { title: "Pricing" };

export default function PricingRoute() {
  return (
    <main className="mx-auto w-full max-w-6xl px-4 py-16">
      <h1 className="mb-10 text-center text-4xl font-semibold tracking-tight">Pricing</h1>
      <PricingPlans />
    </main>
  );
}
```

Functions cannot cross from a server component to a client component, which is why the `onClick` plans live in `pricing-plans.tsx`. If every plan only has an `href`, you can render `PricingTable` straight from the server page.

`tone="brand"` colors the featured plan's badge and button, and any tone other than `neutral` turns the feature checks green. The tone defaults to `neutral`, which keeps the checks in the text color, and `color` takes any CSS color if your brand is not one of the tones. `featuredStyle` picks how the featured plan stands out: `raised` (default), `outline` or `inverted`.

## Do you want the whole pricing page instead?

If you want the full route and not only the table, install the [Pricing Page block](https://wingo-ui.com/components/pricing-page). It puts the table inside a whole page: a marketing header with a phone menu in a bottom sheet, the plans with a Monthly / Yearly switch and a USD / EUR switch, customer logos, three quotes, an FAQ accordion, a closing call to action and a footer.

```bash
npx wingo-ui@latest add pricing-page
```

```tsx
// app/(marketing)/pricing/page.tsx
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { PricingPage } from "@/components/blocks/pricing-page";

export default function PricingRoute() {
  const router = useRouter();
  return (
    <PricingPage
      linkComponent={Link}
      defaultPeriod="yearly"
      onSelectPlan={(planId, period, currency) =>
        router.push(`/sign-up?plan=${planId}&period=${period}&currency=${currency}`)
      }
    />
  );
}
```

It ships with sample data (`PRICING_PAGE_SAMPLE`), so it renders finished with zero props. Pass your own `plans`, `comparison`, `faqs` and `testimonials` to replace it, and turn sections off with `showLogos`, `showTestimonials`, `showFaq` or `showCta`. Euro prices are converted from your base prices at `eurRate` (1.08 by default) and rounded to whole euros. The conversion runs whenever the currency is EUR, so if you pass plans already priced in euros, also set `currency="EUR"`, `currencies={["EUR"]}` (a single entry hides the switch) and `eurRate={1}`, or your euro prices get divided a second time. `design="bold"` swaps the classic look for heavy display type and an inverted featured plan. Set `header={false}` and `footer={false}` when the page sits inside your own site layout.

> Live demo (Pricing Page): Switch the period and the currency, then pick a plan: its button spins while onSelectPlan runs. On a phone, open the menu to get the bottom sheet. Try it at [Pricing Page](https://wingo-ui.com/components/pricing-page) and install it with `npx wingo-ui@latest add pricing-page`.

This block replaces the two files from the previous section: pick one or the other for `/pricing`. Because this `page.tsx` is a client component, it cannot export `metadata`. If you need a page title, move the `PricingPage` element into its own client file and keep `page.tsx` a server component, as in the previous section.

For a pricing band on a landing page instead of a full route, the [Pricing Section](https://wingo-ui.com/components/pricing-section) is the compact version with four layouts and a "Compare all features" link. One difference to watch: its `yearly` price is the monthly equivalent when paid yearly, while the Pricing Table's `yearly` is the yearly total.

## What is the next step?

Install the table with `npx wingo-ui@latest add pricing-table`, paste your plans, and check it at 390px before anything else. The Pricing Table and the Pricing Page are part of [Wingo UI Pro](https://wingo-ui.com/pricing); the Segmented Control that drives the switch is free. For the other screens of a SaaS app, start from our [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) or browse the rest of the [Build it category](https://wingo-ui.com/blog/category/build).

## Components in this post

- [Pricing table](https://wingo-ui.com/components/pricing-table) (Pro): A React pricing table: plans side by side with a monthly / yearly switch whose prices roll, a featured plan and a full comparison table. Install: `npx wingo-ui@latest add pricing-table`
- [Pricing Page](https://wingo-ui.com/components/pricing-page) (Pro): A React SaaS pricing page: plans with monthly or yearly and USD or EUR switches, a comparison table, quotes, an FAQ and a closing call. Install: `npx wingo-ui@latest add pricing-page`
- [Segmented Control](https://wingo-ui.com/components/segmented-control) (Free): A React iOS-style segmented control with a raised thumb that slides and follows your finger. Install: `npx wingo-ui@latest add segmented-control`

## FAQ

### Does shadcn/ui have a pricing page or pricing table?

Not as of October 2026. The official blocks page features dashboard, sidebar, login and signup blocks, and the component list has Card, Table, Tabs and Toggle Group but no pricing component, so pricing pages come from community galleries or from composing those parts yourself.

### Should a yearly plan show the monthly equivalent or the yearly total?

Show the monthly equivalent with the yearly total under it ("$12/mo, Billed $144 yearly"), so the number compares directly with the monthly price. The Wingo UI Pricing Table does this by default and switches to the yearly amount plus what it saves with yearlyDisplay="per-year".

### How is the yearly savings percentage calculated?

It is 1 minus the yearly price divided by twelve monthly payments, rounded down. $144 a year against $15 a month is 1 - 144 / 180, which is 20%. The Pricing Table takes the best saving across all plans for the badge on the Yearly option.

### How do I make a pricing table responsive on mobile?

Turn the plans into a swipeable row of cards that opens on the featured plan, or stack them when there are only two, and replace the comparison table with a plan picker above one column of rows. Wide comparison tables that scroll sideways on a 390px screen lose the row labels.

### Is the Wingo UI pricing table free?

The Segmented Control it uses for the period switch is free. The Pricing Table, the Pricing Section and the full Pricing Page block are part of Wingo UI Pro, and all of them install with npx wingo-ui@latest add.

---

Source: https://wingo-ui.com/blog/shadcn-pricing-page
