Feedback & Data
PricingSwitch
A monthly and yearly switch. The pill slides across while every price counts over to the new number, and a saving badge worked out from the two prices pops in.
Download
- React
- Tailwind CSS v4
- motion
components/ui/pricing-switch.tsx
"use client";
import { useEffect, useId, useState, type CSSProperties } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type Plan = {
name: string;
/** Price for one month. */
monthly: number;
/** Price for the whole year. The saving badge is worked out from these two. */
yearly: number;
/** Small line under the price. */
note?: string;
features?: string[];
/** Marks this plan with the accent colour. */
featured?: boolean;
};
type PricingSwitchProps = {
plans: Plan[];
/** Start on the yearly side. */
defaultYearly?: boolean;
/** Text on the two sides of the switch. */
labels?: [string, string];
/** Text after the price on each side. */
units?: [string, string];
/** Text before the price, like "$" or "₹". */
prefix?: string;
/** Digits after the point. */
decimals?: number;
/** Number format, like "en-IN" for lakh commas. */
locale?: string;
/** Called when the switch moves. */
onChange?: (yearly: boolean) => void;
/** Called when a plan button is pressed. Leave out to hide the buttons. */
onSelect?: (plan: Plan, yearly: boolean) => void;
/** Text on the plan buttons. */
buttonLabel?: string;
/** Switch, featured plan and tick colour. Text on it stays dark. */
accent?: string;
/** Replaces the plan card look. */
className?: string;
};
function Price({ value, format, reduce }: { value: number; format: (n: number) => string; reduce: boolean | null }) {
const count = useMotionValue(value);
const text = useTransform(count, format);
useEffect(() => {
if (reduce) {
count.set(value);
return;
}
const run = animate(count, value, { duration: 0.6, ease: [0.16, 1, 0.3, 1] });
return () => run.stop();
}, [count, reduce, value]);
return (
<>
<motion.span aria-hidden>{text}</motion.span>
<span className="sr-only">{format(value)}</span>
</>
);
}
/* PricingSwitch: a monthly and yearly switch whose pill slides across while every price counts over to the new number. */
export function PricingSwitch({
plans,
defaultYearly = false,
labels = ["Monthly", "Yearly"],
units = ["/mo", "/yr"],
prefix = "$",
decimals = 0,
locale = "en-US",
onChange,
onSelect,
buttonLabel = "Choose plan",
accent = "#a78bfa",
className = "rounded-2xl border border-white/10 bg-white/5 p-5 text-white",
}: PricingSwitchProps) {
const reduce = useReducedMotion();
const id = useId();
const [yearly, setYearly] = useState(defaultYearly);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 32 };
const side = yearly ? 1 : 0;
const format = (n: number) => `${prefix}${n.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
const pick = (next: boolean) => {
setYearly(next);
onChange?.(next);
};
return (
<div style={{ "--ps": accent } as CSSProperties} className="grid w-full justify-items-center gap-5">
<div role="radiogroup" aria-label="Billing period" className="relative grid w-fit grid-cols-2 rounded-full border border-white/10 bg-white/5 p-1 text-sm text-white">
<motion.span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-(--ps)" initial={false} animate={{ x: yearly ? "100%" : "0%" }} transition={spring} />
{labels.map((label, i) => (
<label key={label} className="relative cursor-pointer rounded-full px-5 py-1.5 text-center font-medium transition-colors has-checked:text-[#101014] has-focus-visible:ring-2 has-focus-visible:ring-white/70">
<input type="radio" name={id} className="sr-only" checked={side === i} onChange={() => pick(i === 1)} />
{label}
</label>
))}
</div>
<span role="status" className="sr-only">
{labels[side]} prices shown
</span>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,13rem),1fr))] gap-4">
{plans.map((plan) => {
const saving = plan.monthly > 0 ? Math.round((1 - plan.yearly / (plan.monthly * 12)) * 100) : 0;
return (
<div key={plan.name} className={`flex flex-col ${plan.featured ? "ring-2 ring-(--ps)" : ""} ${className}`}>
<div className="flex min-h-6 items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{plan.name}</h3>
<AnimatePresence initial={false}>
{yearly && saving > 0 && (
<motion.span
className="rounded-full bg-(--ps)/15 px-2 py-0.5 text-xs font-medium whitespace-nowrap text-(--ps)"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={spring}
>
Save {saving}%
</motion.span>
)}
</AnimatePresence>
</div>
<p className="mt-3 flex items-baseline gap-1">
<span className="text-3xl font-bold tracking-tight tabular-nums">
<Price value={yearly ? plan.yearly : plan.monthly} format={format} reduce={reduce} />
</span>
<span className="text-sm opacity-60">{units[side]}</span>
</p>
{plan.note && <p className="mt-1 text-xs opacity-60">{plan.note}</p>}
{plan.features && (
<ul className="mt-4 grid gap-2 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<svg aria-hidden viewBox="0 0 16 16" className="mt-0.5 size-4 shrink-0 fill-none stroke-(--ps) stroke-2">
<path d="M3 8.5l3.2 3.2L13 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="opacity-80">{feature}</span>
</li>
))}
</ul>
)}
{onSelect && (
<button
type="button"
aria-label={`${buttonLabel}: ${plan.name}`}
onClick={() => onSelect(plan, yearly)}
className={`mt-5 w-full rounded-full px-4 py-2 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${plan.featured ? "bg-(--ps) text-[#101014]" : "border border-white/15 hover:bg-white/10"}`}
>
{buttonLabel}
</button>
)}
</div>
);
})}
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/pricing-switch.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/pricing-switch-demo.tsx
"use client"; import { useState } from "react"; import { PricingSwitch } from "@/components/ui/pricing-switch"; const PLANS = [ { name: "Starter", monthly: 0, yearly: 0, note: "Free for one person", features: ["3 projects", "Community help"] }, { name: "Team", monthly: 24, yearly: 240, note: "Per seat", features: ["Unlimited projects", "Shared workspaces"], featured: true }, { name: "Studio", monthly: 59, yearly: 468, note: "Up to 20 seats", features: ["Everything in Team", "Priority help"] }, ]; export default function PricingSwitchExample() { const [chosen, setChosen] = useState(""); return ( <div className="flex h-full overflow-y-auto bg-[#0b0a14] p-4"> <div className="m-auto w-full max-w-3xl"> <PricingSwitch plans={PLANS} onSelect={(plan, yearly) => setChosen(`${plan.name}, ${yearly ? "yearly" : "monthly"}`)} /> <p className="mt-3 min-h-5 text-center text-sm text-white/60">{chosen && `Picked: ${chosen}`}</p> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| plans | { name, monthly, yearly, note?, features?, featured? }[] | — | Yearly is the price for the whole year. Names must be different. |
| defaultYearly | boolean | false | Start on the yearly side. |
| labels | [string, string] | ["Monthly", "Yearly"] | Text on the two sides of the switch. |
| units | [string, string] | ["/mo", "/yr"] | Text after the price on each side. |
| prefix | string | "$" | Text before the price. |
| decimals | number | 0 | Digits after the point. |
| locale | string | "en-US" | Number format. en-IN gives lakh commas. |
| onChange | (yearly) => void | — | Called when the switch moves. |
| onSelect | (plan, yearly) => void | — | Called when a plan button is pressed. Leave out to hide the buttons. |
| buttonLabel | string | "Choose plan" | Text on the plan buttons. |
| accent | string | "#a78bfa" | Switch, featured plan and tick colour. Text on it stays dark, so pick a light colour. |
| className | string | glass card | Replaces the plan card look. |