Monthly/Yearly Pricing Toggle in React and Tailwind

6 min read

TL;DR Use two sr-only radio inputs in a fieldset for Monthly and Yearly and slide a pill behind the labels. Stack both prices in one grid cell with tabular-nums and cross-fade them so the layout never moves. Work out the savings badge from your real prices, show what gets billed yearly, keep ?billing= in the URL, and pick the Stripe price on the server from a plan id and period.

On this page
  1. Use two radio buttons, not a checkbox
  2. Stop the price from moving the layout
  3. Show the per-month price, say what gets billed
  4. The component
  5. Read the period on the server
  6. Pick the Stripe price on the server
  7. What AI tools get wrong in pricing toggles
  8. A prompt for Cursor, v0 or Lovable
  9. Free and paid pieces
  10. FAQ
  11. How do I make a monthly/yearly pricing toggle in React?
  12. Should a pricing toggle be a switch or radio buttons?
  13. How do I stop the price from jumping when it changes?
  14. How do I pass the billing period to Stripe Checkout?

Most pricing pages Cursor, v0 or Lovable build for you have the same monthly/yearly toggle. It's a checkbox styled as a switch, the price swaps with a jump, the card gets wider when "$190" replaces "$19", the yearly badge says "2 months free" whether that's true or not, and the button sends the price from the browser straight to checkout. A screen reader hears "checkbox, not checked" and has to guess what checked means.

You don't need a library to fix any of this. This post builds a monthly/yearly pricing toggle in React and Tailwind with a sliding pill, prices that cross-fade without moving the layout, a savings badge worked out from your real numbers, the period kept in the URL, and a server action that picks the Stripe price on the server.

I make qodesign. Our Pricing Switch is one of the 5 open-source (MIT) pieces on GitHub, with a stretchy pill and prices that roll like an odometer using Framer Motion. The code below is a smaller version with no animation library, written for this post, not that piece's source.

Use two radio buttons, not a checkbox

A billing toggle picks one of two named options. That is what a radio group is for. Two native radio inputs give you the arrow keys, a group name and a "1 of 2" announcement without any extra code, and each option has a visible label, so nobody has to work out what "on" means.

The trick is to hide the inputs with sr-only, keep them focusable, and draw the pill yourself. A fieldset with a hidden legend names the group "Billing period". The white pill is an aria-hidden span that slides with translate-x-full. Because it's absolutely positioned at half the width minus the padding, translating it by its own width lands it exactly on the second option.

Focus is the part generated code usually loses. The input is invisible, so the ring has to go on the label. Tailwind's has-[:focus-visible]:ring-2 does that: the label shows a ring only when its input has keyboard focus.

Receipt Morph Checkout running live. Your card becomes the receipt: it splits, unrolls into paper and gets stamped PAID.

Stop the price from moving the layout

When "$9" becomes "$7.50", the text gets wider and everything next to it shifts. Two small things stop that.

First, tabular-nums, so every digit takes the same width. Second, render both prices at once and stack them in the same grid cell with [grid-area:1/1]. The cell is always as wide as the wider price, so nothing moves. The visible one is fully opaque, the other one is transparent, shifted down a little and aria-hidden, and a CSS transition on opacity and transform makes the swap look like a short roll.

Turn that transition off with motion-reduce:transition-none and people who asked for less motion get an instant swap.

Show the per-month price, say what gets billed

Showing "$190/year" next to "$19/month" makes people do math. Most pricing pages show the yearly plan as a monthly figure, which is fine as long as the line under it says what the card is charged. So the yearly row reads "$15.83/mo" with "$190 billed yearly" under it. Hiding the yearly total is a good way to get refund requests.

The badge comes from the same numbers. Starter at $9 a month or $90 a year saves 17%, so the badge says -17%. If you change a price, the badge changes with it. A hard-coded "2 months free" is only true when the yearly price is exactly ten months, and generated pricing pages get that wrong a lot.

Format money with Intl.NumberFormat and set both minimumFractionDigits and maximumFractionDigits. Setting only the maximum to 0 for a currency can throw a range error in older JavaScript engines, because the currency's default minimum is 2.

The component

tsx
// components/pricing.tsx
"use client";

import { useId, useState } from "react";

export type Plan = {
  id: "starter" | "pro";
  name: string;
  monthly: number; // price per month on monthly billing
  yearly: number; // price for a whole year on yearly billing
  features: string[];
  featured?: boolean;
};

type Period = "month" | "year";

function money(amount: number) {
  const whole = Number.isInteger(amount);
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: whole ? 0 : 2,
    maximumFractionDigits: whole ? 0 : 2,
  }).format(amount);
}

function savingsPercent(plan: Plan) {
  const full = plan.monthly * 12;
  if (full <= 0) return 0;
  return Math.round((1 - plan.yearly / full) * 100);
}

export function BillingSwitch({
  yearly,
  onChange,
  badge,
}: {
  yearly: boolean;
  onChange: (yearly: boolean) => void;
  badge?: string;
}) {
  const name = useId();
  const options = [
    { value: false, label: "Monthly" },
    { value: true, label: "Yearly" },
  ];

  return (
    <fieldset className="relative grid w-64 grid-cols-2 rounded-full bg-zinc-900 p-1 text-sm">
      <legend className="sr-only">Billing period</legend>
      <span
        aria-hidden
        className={`absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white transition-transform duration-300 ease-out motion-reduce:transition-none ${
          yearly ? "translate-x-full" : "translate-x-0"
        }`}
      />
      {options.map((o) => (
        <label
          key={o.label}
          className={`relative z-10 flex cursor-pointer items-center justify-center gap-1.5 rounded-full py-2 font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-lime-400 ${
            yearly === o.value ? "text-zinc-950" : "text-zinc-400 hover:text-white"
          }`}
        >
          <input
            type="radio"
            name={name}
            className="sr-only"
            checked={yearly === o.value}
            onChange={() => onChange(o.value)}
          />
          {o.label}
          {o.value && badge ? (
            <span className="rounded-full bg-lime-400/20 px-1.5 text-xs text-lime-600">{badge}</span>
          ) : null}
        </label>
      ))}
    </fieldset>
  );
}

function Price({ plan, yearly }: { plan: Plan; yearly: boolean }) {
  const perMonthOnYearly = Math.round((plan.yearly / 12) * 100) / 100;
  const rows = [
    { on: !yearly, amount: plan.monthly, note: "billed monthly" },
    { on: yearly, amount: perMonthOnYearly, note: `${money(plan.yearly)} billed yearly` },
  ];

  return (
    <div className="grid">
      {rows.map((r, i) => (
        <div
          key={i}
          aria-hidden={!r.on}
          className={`[grid-area:1/1] transition duration-300 ease-out motion-reduce:transition-none ${
            r.on ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-2 opacity-0"
          }`}
        >
          <p className="text-4xl font-semibold tabular-nums text-white">
            {money(r.amount)}
            <span className="text-base font-normal text-zinc-400">/mo</span>
          </p>
          <p className="mt-1 text-sm text-zinc-400">{r.note}</p>
        </div>
      ))}
    </div>
  );
}

export function Pricing({
  plans,
  defaultYearly = false,
  onSelect,
}: {
  plans: Plan[];
  defaultYearly?: boolean;
  onSelect: (planId: Plan["id"], period: Period) => void;
}) {
  const [yearly, setYearly] = useState(defaultYearly);
  const best = Math.max(...plans.map(savingsPercent));

  function change(next: boolean) {
    setYearly(next);
    const url = new URL(window.location.href);
    url.searchParams.set("billing", next ? "yearly" : "monthly");
    window.history.replaceState(null, "", url);
  }

  return (
    <section className="mx-auto max-w-3xl px-4 py-16">
      <div className="flex justify-center">
        <BillingSwitch yearly={yearly} onChange={change} badge={best > 0 ? `-${best}%` : undefined} />
      </div>
      <div className="mt-10 grid gap-6 sm:grid-cols-2">
        {plans.map((plan) => (
          <article
            key={plan.id}
            className={`rounded-2xl border p-6 ${plan.featured ? "border-lime-400/60 bg-zinc-900" : "border-white/10"}`}
          >
            <h3 className="text-lg font-medium text-white">{plan.name}</h3>
            <div className="mt-4">
              <Price plan={plan} yearly={yearly} />
            </div>
            <ul className="mt-6 space-y-2 text-sm text-zinc-300">
              {plan.features.map((f) => (
                <li key={f}>{f}</li>
              ))}
            </ul>
            <button
              type="button"
              onClick={() => onSelect(plan.id, yearly ? "year" : "month")}
              className="mt-8 w-full rounded-full bg-white py-2.5 text-sm font-medium text-zinc-950 hover:bg-zinc-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lime-400"
            >
              Choose {plan.name}
            </button>
          </article>
        ))}
      </div>
    </section>
  );
}

The period goes into the URL with window.history.replaceState, so ?billing=yearly survives a refresh and you can link straight to yearly pricing from an email. Next.js has supported calling the native history methods from the App Router since 14.1, and it doesn't trigger a navigation.

Read the period on the server

The page reads billing from the search params and passes a default to the client component, so the first paint already shows the right period. No flash of monthly prices before yearly kicks in.

tsx
// app/pricing/page.tsx
import { Pricing, type Plan } from "@/components/pricing";
import { startCheckout } from "./actions";

const plans: Plan[] = [
  { id: "starter", name: "Starter", monthly: 9, yearly: 90, features: ["1 project", "Email support"] },
  { id: "pro", name: "Pro", monthly: 19, yearly: 190, features: ["Unlimited projects", "Priority support"], featured: true },
];

export default async function PricingPage({
  searchParams,
}: {
  searchParams: Promise<{ billing?: string }>;
}) {
  const { billing } = await searchParams;
  return <Pricing plans={plans} defaultYearly={billing === "yearly"} onSelect={startCheckout} />;
}

Since Next.js 15 searchParams is a promise, so you await it. Reading it makes the page render per request. If you'd rather keep the page static, drop the prop and read the URL in an effect instead, and accept that yearly links paint monthly for a moment.

Pick the Stripe price on the server

The client sends a plan id and a period, never an amount or a price id. The server action checks both values against a fixed list and looks up the Stripe price itself, so nobody can edit the request and buy Pro at the Starter price.

ts
// app/pricing/actions.ts
"use server";

import { redirect } from "next/navigation";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const PRICE_IDS = {
  starter: { month: process.env.STRIPE_PRICE_STARTER_MONTH!, year: process.env.STRIPE_PRICE_STARTER_YEAR! },
  pro: { month: process.env.STRIPE_PRICE_PRO_MONTH!, year: process.env.STRIPE_PRICE_PRO_YEAR! },
} as const;

export async function startCheckout(planId: string, period: string) {
  if (planId !== "starter" && planId !== "pro") throw new Error("Unknown plan");
  if (period !== "month" && period !== "year") throw new Error("Unknown period");

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: PRICE_IDS[planId][period], quantity: 1 }],
    success_url: "https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/pricing",
  });

  redirect(session.url!);
}

Create one monthly and one yearly recurring price per plan in Stripe and put their ids in your environment variables. Server actions are public endpoints, which is why the action validates its arguments instead of trusting the TypeScript types.

What AI tools get wrong in pricing toggles

These are the things I keep fixing in generated pricing sections:

  • A checkbox or a div with an onClick as the toggle, with no group name, so keyboard and screen reader users get nothing useful.
  • The focus ring on a hidden input, which means no visible focus at all.
  • A price swap that changes the card width and pushes the button down.
  • "2 months free" or "Save 20%" typed by hand and never updated after a price change.
  • The yearly total hidden behind a per-month number.
  • The amount or the price id sent from the browser to checkout.
  • Billing period kept only in state, so refreshing the page or sharing the link resets it to monthly.
  • transition-all on the card, which animates borders and colors nobody meant to animate.
  • Animation that ignores reduced motion.

A prompt for Cursor, v0 or Lovable

text
Build a Pricing component in React and Tailwind with no libraries.
- Billing toggle: a fieldset with a sr-only legend "Billing period" and two sr-only radio inputs (Monthly, Yearly) inside labels. A white aria-hidden pill slides under the active label with translate-x-full, transition-transform duration-300 motion-reduce:transition-none. Focus ring on the label with has-[:focus-visible]:ring-2.
- Plans have monthly (per month) and yearly (per year) prices. Compute the savings percent as round((1 - yearly / (monthly * 12)) * 100) and show it as a badge on Yearly only if it is above 0.
- Price: render the monthly and the yearly-per-month price in the same grid cell with [grid-area:1/1], tabular-nums, cross-fade with opacity and translate-y, aria-hidden on the hidden one. Under the yearly price show "$X billed yearly".
- Format money with Intl.NumberFormat, setting both minimumFractionDigits and maximumFractionDigits.
- On change, write ?billing=monthly|yearly with window.history.replaceState. Accept defaultYearly from the server page.
- The button calls onSelect(planId, "month" | "year"). Never send amounts or price ids from the client.

Free and paid pieces

If you want the finished version with the odometer prices and the stretchy pill, the Pricing Switch installs with one shadcn command and is MIT licensed. The free page has the one free piece on the site and a link to the 5 open-source (MIT) ones on GitHub.

After someone picks a plan, the next screen matters too. The Receipt Morph Checkout is a paid piece ($3) where the card splits, unrolls into a paper receipt and gets stamped PAID.

FAQ

How do I make a monthly/yearly pricing toggle in React?

Use two radio inputs in a fieldset for Monthly and Yearly, keep the choice in state, and show each plan's price for that period. Hide the inputs visually with sr-only, draw a sliding pill behind the labels, and put the focus ring on the label.

Should a pricing toggle be a switch or radio buttons?

Radio buttons. A billing toggle picks one of two named options, which is exactly what a radio group announces. A switch reads as on or off and leaves people guessing which period "on" is.

How do I stop the price from jumping when it changes?

Use tabular-nums so digits share one width, and render both prices stacked in the same grid cell. The cell keeps the width of the wider price, so swapping them with opacity doesn't move anything around them.

How do I pass the billing period to Stripe Checkout?

Send only the plan id and the period to a server action. On the server, check both values and look up the matching recurring Stripe price, then create a Checkout Session in subscription mode with that price and redirect to its URL.