Browse the library
Components / Forms
Price toggle
FreeMonthly or yearly segmented switch with a sliding thumb, plus an animated price that rolls its digits.
View as Markdownmotion
components/facade/price-toggle.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------
PriceToggle
------------------------------------------------------------------- */
export type BillingPeriod = "monthly" | "yearly";
export interface PriceToggleProps {
value?: BillingPeriod;
defaultValue?: BillingPeriod;
onChange?: (value: BillingPeriod) => void;
/** Badge next to the yearly option. Set to null to hide. */
badge?: string | null;
labels?: { monthly: string; yearly: string };
className?: string;
}
export function PriceToggle({
value: valueProp,
defaultValue = "monthly",
onChange,
badge = "Save 20%",
labels = { monthly: "Monthly", yearly: "Yearly" },
className,
}: PriceToggleProps) {
const reduce = useReducedMotion();
const [inner, setInner] = React.useState<BillingPeriod>(defaultValue);
const value = valueProp ?? inner;
const uid = React.useId();
const refs = React.useRef<Record<BillingPeriod, HTMLButtonElement | null>>({ monthly: null, yearly: null });
const select = (v: BillingPeriod) => {
if (valueProp === undefined) setInner(v);
onChange?.(v);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) {
e.preventDefault();
const next: BillingPeriod = value === "monthly" ? "yearly" : "monthly";
select(next);
refs.current[next]?.focus();
}
};
const options: BillingPeriod[] = ["monthly", "yearly"];
return (
<div className={cn("inline-flex items-center gap-3", className)}>
<div
role="radiogroup"
aria-label="Billing period"
className="relative inline-grid grid-cols-2 rounded-full border border-border bg-muted p-1"
>
{options.map((o) => {
const active = value === o;
return (
<button
key={o}
ref={(el) => {
refs.current[o] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => select(o)}
onKeyDown={onKeyDown}
className={cn(
"relative h-8 min-w-24 rounded-full px-4 text-sm font-medium transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId={`${uid}-thumb`}
className="absolute inset-0 rounded-full bg-card shadow-sm shadow-foreground/10 ring-1 ring-border"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 450, damping: 34 }}
aria-hidden
/>
)}
<span className="relative">{labels[o]}</span>
</button>
);
})}
</div>
{badge && (
<motion.span
animate={
value === "yearly"
? { opacity: 1, scale: 1 }
: { opacity: 0.75, scale: reduce ? 1 : 0.96 }
}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className={cn(
"inline-flex h-6 items-center rounded-full px-2.5 text-xs font-semibold transition-colors",
value === "yearly" ? "bg-success/15 text-success" : "bg-muted text-muted-foreground",
)}
>
{badge}
</motion.span>
)}
</div>
);
}
/* ------------------------------------------------------------------
AnimatedPrice: rolls each digit when the value changes
------------------------------------------------------------------- */
export interface AnimatedPriceProps {
value: number;
currency?: string;
/** Decimal places. Defaults to 0 for whole numbers, 2 otherwise. */
fractionDigits?: number;
locale?: string;
className?: string;
}
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
function Digit({ digit, reduce }: { digit: number; reduce: boolean | null }) {
return (
<span className="relative inline-block h-[1em] overflow-hidden leading-none tabular-nums" aria-hidden>
<span className="invisible">0</span>
<motion.span
className="absolute inset-x-0 top-0 flex flex-col"
initial={false}
animate={{ y: `${-digit * 10}%` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 32, mass: 0.9 }}
>
{DIGITS.map((d) => (
<span key={d} className="block h-[1em] leading-none">
{d}
</span>
))}
</motion.span>
</span>
);
}
export function AnimatedPrice({
value,
currency = "USD",
fractionDigits,
locale = "en-US",
className,
}: AnimatedPriceProps) {
const reduce = useReducedMotion();
const digits = fractionDigits ?? (Number.isInteger(value) ? 0 : 2);
const formatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}),
[locale, currency, digits],
);
const parts = formatter.formatToParts(value);
const full = formatter.format(value);
// Key digits from the right so the ones place stays the same element as the number grows.
const chars: Array<{ ch: string; isDigit: boolean }> = [];
for (const p of parts) {
for (const ch of p.value) chars.push({ ch, isDigit: /\d/.test(ch) });
}
let digitIndexFromRight = chars.filter((c) => c.isDigit).length;
return (
<span className={cn("inline-flex items-baseline", className)}>
<span className="sr-only">{full}</span>
<span className="inline-flex items-baseline" aria-hidden>
<AnimatePresence initial={false} mode="popLayout">
{chars.map((c, i) => {
if (c.isDigit) {
digitIndexFromRight -= 1;
return (
<motion.span
key={`d-${digitIndexFromRight}`}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: "-0.3em" }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: "0.3em" }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
className="inline-block"
>
<Digit digit={Number(c.ch)} reduce={reduce} />
</motion.span>
);
}
return (
<motion.span
key={`s-${c.ch}-${i - chars.length}`}
layout={!reduce}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
className="inline-block"
>
{c.ch}
</motion.span>
);
})}
</AnimatePresence>
</span>
</span>
);
}
export default PriceToggle;app/page.tsx
"use client";
import * as React from "react";
import { ArrowRight, Check, Sparkles } from "lucide-react";
import { AnimatedPrice, PriceToggle, type BillingPeriod } from "@/components/facade/price-toggle";
const features = [
"Two professional cleanings per year",
"Annual exam and digital X-rays",
"20% off fillings, crowns and whitening",
"Same-week emergency visits",
"No insurance, no deductibles, no waiting periods",
];
export default function Demo() {
const [period, setPeriod] = React.useState<BillingPeriod>("monthly");
const monthly = 39;
const yearlyPerMonth = 31;
const price = period === "monthly" ? monthly : yearlyPerMonth;
return (
<div className="mx-auto flex w-full max-w-md flex-col items-center gap-5 p-4">
<PriceToggle value={period} onChange={setPeriod} />
<article className="relative w-full overflow-hidden rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-sm shadow-foreground/[0.04]">
<div
className="pointer-events-none absolute -right-16 -top-16 size-48 rounded-full bg-brand/10 blur-3xl"
aria-hidden
/>
<div className="relative flex items-start justify-between gap-3">
<div>
<h3 className="font-display text-lg font-semibold tracking-tight">Smile Club Membership</h3>
<p className="mt-1 text-sm text-muted-foreground">Bayside Family Dental, San Diego</p>
</div>
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-brand/10 px-2.5 text-[11px] font-semibold text-brand">
<Sparkles className="size-3" aria-hidden />
Most popular
</span>
</div>
<div className="relative mt-6 flex items-baseline gap-1.5">
<AnimatedPrice value={price} className="font-display text-5xl font-semibold tracking-tight" />
<span className="text-sm text-muted-foreground">/ month</span>
</div>
<p className="relative mt-1 h-5 text-xs text-muted-foreground">
{period === "yearly" ? (
<>
Billed <span className="font-mono text-foreground">$372</span> yearly. You save{" "}
<span className="font-mono text-success">$96</span>.
</>
) : (
"Billed monthly. Cancel anytime."
)}
</p>
<ul className="relative mt-6 space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm">
<span className="mt-0.5 grid size-4 shrink-0 place-items-center rounded-full bg-success/15 text-success">
<Check className="size-3" strokeWidth={3} aria-hidden />
</span>
{f}
</li>
))}
</ul>
<a
href="#join"
className="relative mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Join Smile Club
<ArrowRight className="size-4" aria-hidden />
</a>
</article>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/price-toggle.jsonUsage
import { PriceToggle } from "@/components/facade/price-toggle";
export default function Page() {
return <PriceToggle />;
}Props
PriceToggleProps is exported, so you can extend or wrap it. Every prop has a default; <PriceToggle /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| value | BillingPeriod | |
| defaultValue | BillingPeriod | |
| onChange | (value: BillingPeriod) => void | |
| badge | string | null | Badge next to the yearly option. Set to null to hide. |
| labels | { monthly: string; yearly: string } | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade price toggle to this page and adapt the copy to our business.