Monthly Yearly Toggle
Switching the billing period slides one thumb and rolls every plan price together.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Monthly Yearly Toggle
*
* Switching the billing period moves one thumb across the control and
* rolls every price on the page at the same instant, so the plans stay
* comparable while they change. The savings badge arrives a beat later,
* once the new numbers are already legible.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `plans`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BillingPlan = {
id: string;
name: string;
/** Small qualifier under the plan name. */
detail: string;
/** Formatted monthly price when billed monthly. */
monthly: string;
/** Formatted monthly price when billed yearly. */
yearly: string;
};
export type MonthlyYearlyToggleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Plans priced on both periods. */
plans?: BillingPlan[];
/** Label of the shorter period. */
monthlyLabel?: string;
/** Label of the longer period. */
yearlyLabel?: string;
/** Badge revealed on the longer period. */
savingLabel?: string;
/** Whether the longer period is chosen on first render. */
initialYearly?: boolean;
/** Accent for the thumb and the badge. */
accent?: string;
/** Fires with the chosen period. */
onChange?: (yearly: boolean) => void;
};
type VariantConfig = {
/** Spring the thumb rides across the control. */
thumb: { type: "spring"; stiffness: number; damping: number };
/** Spring a price rolls on. */
roll: { type: "spring"; stiffness: number; damping: number };
/** Gap between one price rolling and the following one. */
stagger: number;
/** Crossfade length for the labels and the badge. */
fadeSeconds: number;
};
// Prices being compared have to be readable the instant they land, so
// nothing here overshoots far: damping ratios (damping / 2√stiffness)
// sit at or above 0.86. Variants differ in the size of the cascade and
// the pace of the thumb, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Every price at once, thumb almost instant. For a dense pricing grid.
subtle: {
thumb: { type: "spring", stiffness: 620, damping: 48 },
roll: { type: "spring", stiffness: 620, damping: 48 },
stagger: 0,
fadeSeconds: 0.14,
},
// A short cascade down the plans. All-purpose.
default: {
thumb: { type: "spring", stiffness: 440, damping: 38 },
roll: { type: "spring", stiffness: 440, damping: 38 },
stagger: 0.05,
fadeSeconds: 0.2,
},
// A longer cascade for a pricing page where the annual saving is the
// headline.
playful: {
thumb: { type: "spring", stiffness: 300, damping: 31 },
roll: { type: "spring", stiffness: 320, damping: 33 },
stagger: 0.085,
fadeSeconds: 0.24,
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_PLANS: BillingPlan[] = [
{
id: "starter",
name: "Starter",
detail: "One workspace",
monthly: "$12",
yearly: "$9",
},
{
id: "team",
name: "Team",
detail: "Up to twelve seats",
monthly: "$29",
yearly: "$23",
},
{
id: "scale",
name: "Scale",
detail: "Unlimited seats",
monthly: "$64",
yearly: "$51",
},
];
export default function MonthlyYearlyToggle({
variant = "default",
plans = DEFAULT_PLANS,
monthlyLabel = "Monthly",
yearlyLabel = "Yearly",
savingLabel = "Save 20%",
initialYearly = false,
accent = "#7C7CF0",
onChange,
}: MonthlyYearlyToggleProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Scoped so two of these on one page cannot share a travelling thumb.
const thumbId = `${useId()}-thumb`;
const [yearly, setYearly] = useState(initialYearly);
const choose = (nextYearly: boolean) => {
if (nextYearly === yearly) return;
setYearly(nextYearly);
onChange?.(nextYearly);
};
// Yearly is cheaper per month, so its prices arrive from above and
// push the monthly ones out of the bottom of the slot.
const travel = reduceMotion ? 0 : yearly ? -22 : 22;
const periods: { label: string; isYearly: boolean }[] = [
{ label: monthlyLabel, isYearly: false },
{ label: yearlyLabel, isYearly: true },
];
return (
<div style={{ width: 278, fontSize: 13 }}>
<div
role="radiogroup"
aria-label="Billing period"
style={{
position: "relative",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 4,
padding: 4,
borderRadius: 11,
background: tone(7),
}}
>
{periods.map((period) => {
const selected = period.isYearly === yearly;
return (
<button
key={period.label}
type="button"
role="radio"
aria-checked={selected}
onClick={() => choose(period.isYearly)}
style={{
position: "relative",
height: 32,
display: "grid",
placeItems: "center",
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: 0,
borderRadius: 8,
cursor: "pointer",
}}
>
{/* One thumb for the control. Sharing a layout id makes it
slide across rather than blink from one half to the
other; the labels above it never change size. */}
{selected && (
<motion.span
layoutId={thumbId}
transition={reduceMotion ? { duration: 0 } : cfg.thumb}
aria-hidden
style={{
position: "absolute",
inset: 0,
borderRadius: 8,
background: `color-mix(in srgb, ${accent} 16%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 42%, transparent)`,
}}
/>
)}
<motion.span
initial={false}
animate={{ opacity: selected ? 1 : 0.55 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
style={{ position: "relative" }}
>
{period.label}
</motion.span>
</button>
);
})}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
height: 22,
marginTop: 10,
}}
>
{/* The badge fades and lifts a few pixels. It never scales — the
sentence inside it has to stay readable while it arrives. */}
<motion.span
initial={false}
animate={{
opacity: yearly ? 1 : 0,
y: yearly || reduceMotion ? 0 : 4,
}}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: yearly && !reduceMotion ? 0.14 : 0,
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "3px 8px",
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
color: accent,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
>
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 1.6v8.8M6 10.4 2.9 7.3M6 10.4l3.1-3.1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{savingLabel}
</motion.span>
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.5 }}>
{yearly ? "Billed once a year" : "Billed every month"}
</span>
</div>
<div style={{ display: "grid", gap: 6, marginTop: 10 }}>
{plans.map((plan, index) => (
<div
key={plan.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 11,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
<span style={{ display: "grid", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>
{plan.name}
</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>{plan.detail}</span>
</span>
<span
style={{
marginLeft: "auto",
display: "flex",
alignItems: "baseline",
gap: 3,
}}
>
{/* Every price rolls at the same moment, in the same
direction, at one type size — that is what keeps the
plans comparable while the numbers change. */}
<span
style={{
display: "grid",
justifyItems: "end",
height: 24,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={yearly ? plan.yearly : plan.monthly}
initial={{ y: travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -travel, opacity: 0 }}
transition={{
y: reduceMotion
? { duration: 0 }
: { ...cfg.roll, delay: index * cfg.stagger },
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: reduceMotion ? 0 : index * cfg.stagger,
},
}}
style={{
gridArea: "1 / 1",
fontSize: 18,
fontWeight: 650,
lineHeight: "24px",
letterSpacing: -0.2,
fontVariantNumeric: "tabular-nums",
}}
>
{yearly ? plan.yearly : plan.monthly}
</motion.span>
</AnimatePresence>
</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>/mo</span>
</span>
</div>
))}
</div>
</div>
);
}About this pattern
Plans are only comparable if they change together. One thumb slides across the period control on a shared layout animation, and every price rolls in the same direction on the same beat — a short cascade down the list, at one constant type size — so the reader is never comparing a settled number against a moving one. The savings badge arrives a beat after the prices have landed, which is the only order in which it can be read. Nothing scales: a price that grows to make a point is a price nobody trusts.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Billing page
A period control above the plan columns restates every seat price at once.
Related patterns
- Bundle Savings HighlightItems select in turn, a bracket draws down their edge, and the combined saving arrives after it.
- Invoice Line ExpandA charge unfolds into the lines that make it up, with its own amount pinned in place.
- Wishlist Heart FillThe heart fills from its own center on one soft settle, and drains back out on a plain ease.