Blog//7 min read
Pricing section in React with a monthly and yearly toggle
Build a React pricing section with a monthly and yearly toggle, animated price digits, a highlighted plan, a savings badge and an accessible switch.
MSMarco Schulz, founder of Facade UI
A pricing section has one job: let the visitor find their plan and the price in under five seconds. The monthly and yearly toggle makes that harder, because now there are two prices per plan and the visitor has to understand which one they are looking at. Done well, the toggle is a small moment of clarity: flip it, watch the numbers roll down, see the savings. Done badly, it is a confusing switch with a tooltip. The Pricing section in Facade and the free Price toggle are my versions. This article covers the data shape, the animated digits, the featured plan, the savings badge and the accessibility of the toggle.
The tier data shape
Keep the data boring. Each tier is an object, and the component decides how to render it:
export interface PricingTier {
id: string;
name: string;
description: string;
monthly: number;
yearly: number;
features: string[];
ctaLabel?: string;
ctaHref?: string;
highlighted?: boolean;
badge?: string;
}The one decision that matters is what yearly means. In Facade it is the price per month when billed yearly, not the annual total. So a tier with monthly: 49 and yearly: 41 shows "$49 per month" in monthly mode and "$41 per month" in yearly mode with a line underneath that says what the year costs, computed as yearly * 12. Visitors compare per month numbers, so both modes show a per month number. The annual total is there for the people who want to know what hits the card.
Three tiers is the right count for a local service business. A cleaning company has a weekly, a biweekly and a monthly plan. A gym has a basic, a standard and an all access membership. More than three and the visitor starts reading instead of choosing.
Animated price digits
When the toggle flips, the price should change in a way the eye can follow. A hard swap from 49 to 41 is a blink. A number that rolls from 49 to 41 over half a second is a transition the visitor registers as "the price went down".
There are two ways to do it, and Facade uses both in different places. The pricing section tweens the number: it runs a requestAnimationFrame loop for about 550 milliseconds, eases the progress with a cubic ease-out and rounds to the nearest whole number on each frame, formatted with toLocaleString. The standalone AnimatedPrice export from the Price toggle does the odometer version: every digit sits in a one line tall container with the digits 0 to 9 stacked inside, and changing the digit animates the stack vertically. The digits are keyed from the right, so when the price goes from 99 to 120 the ones place stays the same element and only the new leading digit enters.
Both set tabular-nums so the width stays stable, and both read useReducedMotion() and skip straight to the final value when the visitor asks for it. The price lives inside an aria-live="polite" region, so a screen reader hears the new number after the flip without the intermediate frames.
The featured plan
One tier should look like the default choice. The usual trick is a brand colored border and a "Most popular" badge. Facade goes further and inverts the whole card: the highlighted tier renders with bg-foreground text-background, so on a light page it is a dark card between two light ones, and in dark mode it is the light card. The call to action on it uses the brand color, the others stay neutral. No gradient, no border, just contrast.
The badge prop sits at the top of the card and is free text: "Most popular", "Recommended for families", "Best for weekly cleans" all work. Pick the tier based on what the business actually sells most, not on which one has the highest price.
The yearly savings badge
Next to the yearly option in the toggle sits a small label, yearlyBadge, which defaults to "Save 17%" in the Pricing section (the standalone Price toggle defaults its badge to "Save 20%"). Seventeen percent matches two months free, which is a common yearly discount, but it is a string, so set it to whatever your tiers actually save. If the discount differs per tier, drop the badge from the toggle and show the saving under each price instead. A badge that says 17 percent next to a tier that saves 10 is the kind of mismatch visitors notice and remember.
import { PricingSection } from "@/components/facade/pricing-section";
<PricingSection
eyebrow="Plans"
heading="Simple pricing for a clean home"
subheading="Every plan includes supplies, insured cleaners and a satisfaction guarantee."
defaultPeriod="yearly"
yearlyBadge="2 months free"
currency="$"
perLabel="/mo"
tiers={[
{ id: "monthly", name: "Monthly", description: "One deep clean a month.", monthly: 149, yearly: 124, features: ["3 hour visit", "Kitchen and bathrooms", "Supplies included"] },
{ id: "biweekly", name: "Every two weeks", description: "The plan most families pick.", monthly: 259, yearly: 216, highlighted: true, badge: "Most popular", features: ["2 hour visit", "Same team every time", "Priority rescheduling", "Supplies included"] },
{ id: "weekly", name: "Weekly", description: "For busy households.", monthly: 449, yearly: 374, features: ["90 minute visit", "Same team every time", "Fridge and oven monthly", "Supplies included"] },
]}
comparisonNote="All prices before tax. Cancel any time."
faqLabel="Questions about plans?"
faqHref="#faq"
/>Accessibility of the toggle
The toggle looks like a pill with two options, and it is tempting to build it as two buttons. The right pattern is a radio group: a container with role="radiogroup" and an aria-label, two children with role="radio" and aria-checked, and arrow keys that move the selection. That is what both the Price toggle and the toggle inside the Pricing section do.
The details that matter:
- Only the checked option is in the tab order. The unchecked one has
tabIndex={-1}, so Tab lands on the group once, and arrows move within it. This is the native radio behavior and what screen reader users expect. - Arrow keys in all four directions switch the option and move focus to it.
- The sliding thumb behind the active option is a
motion.divwith alayoutId, animated with a spring, and markedaria-hidden. It is decoration; the state lives inaria-checked. - The label text is real text, not an icon, so "Monthly" and "Yearly" read as such.
The standalone toggle is free and works on its own, for example above a comparison table you already have:
npx shadcn@latest add https://facadeui.com/r/price-toggle.jsonimport { PriceToggle, AnimatedPrice, type BillingPeriod } from "@/components/facade/price-toggle";
const [period, setPeriod] = React.useState<BillingPeriod>("monthly");
<PriceToggle value={period} onChange={setPeriod} badge="Save 17%" />
<AnimatedPrice value={period === "monthly" ? 49 : 41} />The full Pricing section is a Pro block. It installs from the private registry after the one time Pro setup:
npx shadcn@latest add @facade/pricing-sectionWhere it goes on the page
For a cleaning service, pricing goes high on the page, right after the hero and the process steps. Visitors who land on a cleaning site are comparing quotes, and a business that shows its prices before the competitor does wins the comparison by default. For a gym it sits after the stats band. For an agency or law firm, I usually leave it out and let the contact section do that work, because the price depends on the engagement.
FAQ
How do I handle a plan with no yearly option?
Set yearly equal to monthly for that tier. The price stays put when the toggle flips and the annual line shows twelve times the monthly price, which is honest. If you have several such tiers, the toggle is probably the wrong pattern for that business.
Can the prices come from Stripe or a CMS?
Yes. tiers is a plain array, so build it from whatever source you have in a server component and pass it down. Keep prices as numbers, not formatted strings, so the animation and the yearly total still compute.
What if my currency symbol goes after the number?
The currency prop is rendered before the number and perLabel after it. For a symbol that belongs after the amount, pass an empty currency and put the symbol into perLabel, for example perLabel=" CHF/mo". The animated digits do not care where the symbol sits.