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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.