Monthly and yearly plans. Prices roll down to the yearly rate and back up, the yearly total rolls down from a year of monthly bills, and Starter's $0 never moves.
@keyframeui
registry/demos/odometer-pricing-demo.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Odometer } from "@/components/ui/odometer";
const usd: Intl.NumberFormatOptions = { style: "currency", currency: "USD", maximumFractionDigits: 0 };
const plans = [
{
name: "Starter",
blurb: "For side projects and trying things out.",
monthly: 0,
yearly: 0,
features: ["3 projects", "Community support", "7-day history"],
cta: "Start for free",
featured: false,
},
{
name: "Pro",
blurb: "For teams shipping every week.",
monthly: 24,
yearly: 19,
features: ["Unlimited projects", "Priority support", "1-year history"],
cta: "Start a trial",
featured: true,
},
{
name: "Team",
blurb: "For companies that need control.",
monthly: 59,
yearly: 47,
features: ["SSO and audit logs", "Custom roles", "Unlimited history"],
cta: "Talk to sales",
featured: false,
},
];
const periods = ["Monthly", "Yearly"] as const;
// The prices roll down to the yearly rate and back up. Starter's $0 never
// moves: only digits that change turn.
export function OdometerPricingDemo() {
const [yearly, setYearly] = React.useState(false);
return (
<div className="@container w-full max-w-4xl">
<div className="flex justify-center">
<div className="relative">
<div
role="radiogroup"
aria-label="Billing period"
className="relative grid grid-cols-2 rounded-full border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm ring-1 shadow-zinc-950/10 ring-zinc-950/5 dark:bg-zinc-700 dark:ring-white/10"
initial={false}
animate={{ x: yearly ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
/>
{periods.map((period) => {
const active = (period === "Yearly") === yearly;
return (
<button
key={period}
type="button"
role="radio"
aria-checked={active}
onClick={() => setYearly(period === "Yearly")}
className={`relative min-w-24 rounded-full px-4 py-1.5 text-sm font-medium transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 ${
active
? "text-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{period}
</button>
);
})}
</div>
<div className="absolute top-1/2 left-full ml-3 -translate-y-1/2">
<AnimatePresence initial={false}>
{yearly ? (
<motion.span
initial={{ opacity: 0, x: -8, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -4, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 420, damping: 26 }}
className="block rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium whitespace-nowrap text-emerald-700 ring-1 ring-emerald-600/20 ring-inset dark:text-emerald-400 dark:ring-emerald-400/20"
>
Save 20%
</motion.span>
) : null}
</AnimatePresence>
</div>
</div>
</div>
<div className="mt-8 grid gap-4 @2xl:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.name}
className={
plan.featured
? "flex flex-col rounded-2xl bg-zinc-950 p-6 text-zinc-50 shadow-xl shadow-zinc-950/20 dark:bg-zinc-50 dark:text-zinc-950 dark:shadow-black/40"
: "flex flex-col rounded-2xl border border-zinc-200 bg-white p-6 text-zinc-950 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50"
}
>
<div className="flex items-center justify-between">
<p className="font-mono text-xs tracking-widest uppercase opacity-60">{plan.name}</p>
{plan.featured ? (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[11px] font-medium dark:bg-zinc-950/10">
Popular
</span>
) : null}
</div>
<p className="mt-5 flex items-baseline gap-1.5">
<Odometer
value={yearly ? plan.yearly : plan.monthly}
format={usd}
className="text-5xl leading-none font-semibold tracking-[-0.04em]"
/>
<span className="text-sm opacity-60">/ month</span>
</p>
{/* Both lines share one cell, so switching never moves the card. */}
<p className="mt-2 grid text-sm opacity-60">
{plan.monthly === 0 ? (
<span>Free forever</span>
) : (
<>
<motion.span
aria-hidden={yearly}
className="[grid-area:1/1]"
initial={false}
animate={{ opacity: yearly ? 0 : 1, y: yearly ? -6 : 0 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
billed monthly
</motion.span>
<motion.span
aria-hidden={!yearly}
className="[grid-area:1/1]"
initial={false}
animate={{ opacity: yearly ? 1 : 0, y: yearly ? 0 : 6 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
{/* Rolls down from a year of monthly bills as it appears. */}
billed <Odometer value={(yearly ? plan.yearly : plan.monthly) * 12} format={usd} /> yearly
</motion.span>
</>
)}
</p>
<p className="mt-5 text-sm opacity-75">{plan.blurb}</p>
<ul className="mt-5 space-y-2.5 border-t border-current/10 pt-5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2.5">
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 opacity-50"
>
<path d="M3 8.5 6.5 12 13 4.5" />
</svg>
{feature}
</li>
))}
</ul>
<button
type="button"
className={
plan.featured
? "mt-7 h-10 rounded-xl bg-white text-sm font-medium text-zinc-950 transition-[background-color,scale] duration-150 hover:bg-zinc-200 active:scale-[0.97] dark:bg-zinc-950 dark:text-zinc-50 dark:hover:bg-zinc-800"
: "mt-7 h-10 rounded-xl border border-zinc-200 text-sm font-medium transition-[background-color,scale] duration-150 hover:bg-zinc-100 active:scale-[0.97] dark:border-zinc-700 dark:hover:bg-zinc-800"
}
>
{plan.cta}
</button>
</div>
))}
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-pricing-demo






















