Monthly or yearly switch whose savings badge swells the moment yearly is chosen.
01Preview
Pro
For growing teams
Billed monthly, cancel any time
Renews
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react03Usage
import { PriceToggle, savingsPercent, type BillingPeriod } from "@/components/ui/price-toggle";
const [period, setPeriod] = useState<BillingPeriod>("monthly");
<PriceToggle
value={period}
onValueChange={setPeriod}
savings={`Save ${savingsPercent(20, 192)}%`}
/>04Source
"use client";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type BillingPeriod = "monthly" | "yearly";
/**
* Whole-number percentage saved by paying yearly. `savingsPercent(20, 192)` → 20.
* Use it to write the badge from real prices instead of hard-coding it.
*/
export function savingsPercent(monthlyPrice: number, yearlyPrice: number) {
if (monthlyPrice <= 0) return 0;
return Math.max(0, Math.round((1 - yearlyPrice / (monthlyPrice * 12)) * 100));
}
export type PriceToggleProps = Omit<RadioGroup.Props<BillingPeriod>, "className" | "value" | "defaultValue" | "onValueChange"> & {
value?: BillingPeriod;
defaultValue?: BillingPeriod;
onValueChange?: (value: BillingPeriod) => void;
/** The badge on the yearly option, like "Save 20%" or "2 months free". Part of its accessible name. */
savings?: React.ReactNode;
monthlyLabel?: React.ReactNode;
yearlyLabel?: React.ReactNode;
size?: "sm" | "md";
className?: string;
};
export function PriceToggle({
value: valueProp,
defaultValue = "monthly",
onValueChange,
savings,
monthlyLabel = "Monthly",
yearlyLabel = "Yearly",
size = "md",
disabled,
className,
onKeyDown,
onPointerDown,
...rest
}: PriceToggleProps) {
const [value, setValue] = useControllableState<BillingPeriod>({ value: valueProp, defaultValue, onChange: onValueChange });
const reduce = useReducedMotion();
const pill = useId();
// Arrow keys switch on the same frame; only a pointer press glides the pill.
const [keyboard, setKeyboard] = useState(false);
const instant = reduce || keyboard;
const options: { value: BillingPeriod; label: React.ReactNode }[] = [
{ value: "monthly", label: monthlyLabel },
{ value: "yearly", label: yearlyLabel },
];
return (
<RadioGroup
aria-label="Billing period"
value={value}
onValueChange={(next) => setValue(next as BillingPeriod)}
disabled={disabled}
data-size={size}
data-value={value}
onKeyDown={(e) => {
setKeyboard(true);
onKeyDown?.(e);
}}
onPointerDown={(e) => {
setKeyboard(false);
onPointerDown?.(e);
}}
className={cn(
"relative inline-flex max-w-full items-stretch border border-line bg-page p-0.5",
size === "sm" ? "h-7 rounded-md" : "h-8 rounded-lg",
disabled && "opacity-50",
className,
)}
{...rest}
>
{options.map((o) => {
const active = value === o.value;
const yearly = o.value === "yearly";
return (
<Radio.Root
key={o.value}
value={o.value}
nativeButton
render={<button type="button" />}
className={cn(
"group/period relative flex min-w-0 select-none items-center justify-center outline-none",
size === "sm" ? "rounded-[5px] px-2 text-[12px]" : "rounded-md px-2.5 text-[12.5px]",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"transition-colors duration-150",
// Touch: the 28–32px segment gets a 44px tall hit area without changing the drawing.
"before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden",
active ? "text-fg" : "text-fg-3 hover:text-fg-2",
"data-disabled:cursor-not-allowed data-disabled:hover:text-fg-3",
)}
>
{active && (
<motion.span
layoutId={pill}
aria-hidden
className={cn(
"absolute inset-0 border border-line-2 bg-raised shadow-[var(--shadow)]",
size === "sm" ? "rounded-[5px]" : "rounded-md",
)}
transition={instant ? { duration: 0 } : spring.snappy}
/>
)}
{/* The label presses; the pill stays put so the press never reads as a jump. */}
<span
className={cn(
"relative flex min-w-0 items-center font-medium tracking-[-0.005em]",
"transition-[scale] duration-100 ease-out motion-safe:group-active/period:scale-[0.96] group-data-disabled/period:scale-100",
size === "sm" ? "gap-1.5" : "gap-2",
)}
>
<span className="truncate">{o.label}</span>
{yearly && savings != null && <SavingsBadge on={active} size={size} reduce={!!reduce}>{savings}</SavingsBadge>}
</span>
</Radio.Root>
);
})}
</RadioGroup>
);
}
function SavingsBadge({ on, size, reduce, children }: { on: boolean; size: "sm" | "md"; reduce: boolean; children: React.ReactNode }) {
return (
<motion.span
data-state={on ? "on" : "off"}
className={cn(
"inline-flex shrink-0 items-center whitespace-nowrap rounded-full border font-medium tabular tracking-normal",
size === "sm" ? "h-4 px-1.5 text-[10px]" : "h-[18px] px-1.5 text-[10.5px]",
"transition-[background-color,border-color,color] duration-200 ease-out",
on ? "border-transparent bg-success-soft text-success" : "border-line-2 text-fg-3",
)}
// Choosing yearly is the moment the saving becomes real: the badge swells once and settles.
initial={false}
animate={on && !reduce ? { scale: [1, 1.14, 1] } : { scale: 1 }}
transition={{ duration: 0.34, ease: ease.out, times: [0, 0.4, 1] }}
>
{children}
</motion.span>
);
}05Props
PriceToggle
| Prop | Type | Default | Description |
|---|---|---|---|
| value | "monthly" | "yearly" | — | The chosen period, controlled. Feed it straight into your prices. |
| defaultValue | "monthly" | "yearly" | "monthly" | Starting period when uncontrolled. |
| onValueChange | (value: BillingPeriod) => void | — | Called with the new period. |
| savings | ReactNode | — | Badge on the yearly option, like "Save 20%". Omit it and no badge is drawn. |
| monthlyLabel | ReactNode | "Monthly" | Label of the first option. |
| yearlyLabel | ReactNode | "Yearly" | Label of the second option. |
| size | "sm" | "md" | "md" | 28px or 32px tall. |
| name | string | — | Submits the period with a surrounding form. |
| disabled | boolean | false | Locks both options, for example while checkout is processing. |
savingsPercent
| Prop | Type | Default | Description |
|---|---|---|---|
| monthlyPrice* | number | — | Price of one month on the monthly plan. |
| yearlyPrice* | number | — | Price of a whole year up front. Returns the whole-number percentage saved, never below 0. |
06Notes
Behavior
- The badge is always drawn, muted while monthly is chosen, so choosing yearly never changes the control's width.
- It is a radio group, so it can submit with a form through name and it has exactly one value at all times.
- Labels truncate before the badge does; the saving is the part people need to read.
Motion
- The selected pill glides between options on the snappy spring (stiffness 520, damping 38) when pressed with a pointer.
- Choosing yearly swells the badge to 1.14 and back over 340ms on the expo ease-out, while it takes on the success tint over 200ms.
- Arrow keys move the pill on the same frame. Reduced motion keeps the color change and drops the glide, the swell and the press scale.
Accessibility
- A Base UI radio group labelled Billing period; Left and Right arrows change the period, Tab leaves the control.
- The badge sits inside the yearly option, so it is read as part of its name: Yearly, Save 20%.
- Touch gets a 44px tall hit area around each option without changing how it is drawn.