Three dark pricing cards with a monthly and yearly switch. When the period changes, every digit of every price rolls to its new value like a mechanical counter, and the line under the price swaps to show what the visitor keeps. The middle plan is wrapped in a light that travels round its border with a soft halo behind it. The switch flips by itself until someone uses it, so the page looks alive on load. On phones the cards stack and the glow card keeps its place in the order.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
const plans = [
{ name: "Solo", monthly: 19, yearly: 15, blurb: "One maker, unlimited drafts.", features: ["3 live projects", "1 seat", "Custom domain", "Version history, 30 days", "Email support"] },
{ name: "Studio", monthly: 49, yearly: 39, blurb: "For a team that ships weekly.", features: ["25 live projects", "8 team seats", "Client review links", "Version history, 1 year", "Priority support"], popular: true },
{ name: "Agency", monthly: 129, yearly: 103, blurb: "Many clients, one bill.", features: ["Unlimited projects", "Unlimited seats", "White-label handoff", "Audit log and SSO", "Dedicated manager"] },
];
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
/** Each digit is a column of 0-9 that slides to the right row on a spring. */
function Odometer({ value, still }: { value: number; still: boolean }) {
const chars = String(value).split("");
return (
<span className="inline-flex tabular-nums leading-none" aria-hidden="true">
{chars.map((c, i) => {
const fromRight = chars.length - 1 - i;
return (
<span key={fromRight} className="inline-block h-[1em] overflow-hidden">
<motion.span
className="flex flex-col"
initial={false}
animate={{ y: `${-Number(c) * 10}%` }}
transition={still ? { duration: 0 } : { type: "spring", stiffness: 110, damping: 17, delay: fromRight * 0.07 }}
>
{DIGITS.map((d) => (
<span key={d} className="block h-[1em] leading-none">{d}</span>
))}
</motion.span>
</span>
);
})}
</span>
);
}
export default function OdometerPricingCardsWithOrbitingGlow() {
const [yearly, setYearly] = useState(true);
const [touched, setTouched] = useState(false);
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.3 });
const still = !!reduce;
// Auto demo: flip the billing period until the visitor uses the switch.
useEffect(() => {
if (touched || still || !inView) return;
const id = window.setInterval(() => setYearly((y) => !y), 2200);
return () => window.clearInterval(id);
}, [touched, still, inView]);
const choose = (y: boolean) => {
setTouched(true);
setYearly(y);
};
return (
<section ref={ref} className="relative overflow-hidden bg-stone-950 px-5 py-16 text-stone-100 sm:px-6 sm:py-24">
<style>{`
@keyframes odometer-pricing-orbit { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } }
.odometer-pricing-orbit { animation: odometer-pricing-orbit 5s linear infinite; }
@media (prefers-reduced-motion: reduce) { .odometer-pricing-orbit { animation: none; transform: translate(-50%, -50%) rotate(40deg); } }
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-0 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full opacity-60 blur-3xl" style={{ background: "radial-gradient(closest-side, rgba(245,158,11,0.22), transparent)" }} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-[0.07]" style={{ backgroundImage: "linear-gradient(to right, #fff 1px, transparent 1px), linear-gradient(to bottom, #fff 1px, transparent 1px)", backgroundSize: "56px 56px", maskImage: "radial-gradient(ellipse at 50% 30%, black, transparent 70%)", WebkitMaskImage: "radial-gradient(ellipse at 50% 30%, black, transparent 70%)" }} />
<div className="relative mx-auto max-w-5xl">
<div className="mx-auto max-w-2xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-400">Pricing</p>
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">Pick a plan. Change it any day.</h2>
<p className="mt-4 text-stone-400">Every plan starts with 14 days free. No card needed until you publish.</p>
<div className="mt-9 inline-flex items-center rounded-full border border-white/10 bg-white/5 p-1 text-sm" role="group" aria-label="Billing period">
{[
{ label: "Monthly", value: false },
{ label: "Yearly", value: true },
].map((o) => {
const on = yearly === o.value;
return (
<button
key={o.label}
type="button"
aria-pressed={on}
onClick={() => choose(o.value)}
className={`relative min-h-[40px] rounded-full px-5 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-amber-400 ${on ? "text-stone-950" : "text-stone-300 hover:text-white"}`}
>
{on && <motion.span layoutId="odometer-pricing-thumb" className="absolute inset-0 rounded-full bg-amber-400" transition={still ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 }} />}
<span className="relative flex items-center gap-2">
{o.label}
{o.value && <span className={`rounded-full px-1.5 py-0.5 text-[11px] font-semibold ${on ? "bg-stone-950/15 text-stone-950" : "bg-amber-400/15 text-amber-300"}`}>-20%</span>}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-14 grid items-center gap-6 md:grid-cols-3">
{plans.map((p, i) => {
const price = yearly ? p.yearly : p.monthly;
const saved = (p.monthly - p.yearly) * 12;
const body = (
<>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{p.name}</h3>
{p.popular && <span className="rounded-full bg-amber-400/15 px-2.5 py-1 text-xs font-semibold text-amber-300">Most popular</span>}
</div>
<p className="mt-1 text-sm text-stone-400">{p.blurb}</p>
<p className="mt-7 flex items-end gap-1.5">
<span className="sr-only">{`$${price} per month`}</span>
<span aria-hidden="true" className="flex items-start text-6xl font-semibold tracking-tight text-white">
<span className="mr-0.5 mt-1.5 text-2xl font-medium text-stone-400">$</span>
<Odometer value={price} still={still} />
</span>
<span aria-hidden="true" className="pb-1.5 text-sm text-stone-400">/ month</span>
</p>
<div className="mt-3 h-6 text-sm">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={yearly ? "y" : "m"}
initial={still ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={still ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.2 }}
className="text-stone-400"
>
{yearly ? (
<>
Billed ${(p.yearly * 12).toLocaleString("en-US")} a year <span className="ml-1 font-medium text-amber-300">you keep ${saved}</span>
</>
) : (
"Billed every month"
)}
</motion.p>
</AnimatePresence>
</div>
<a
href="#"
className={`mt-7 flex min-h-[44px] items-center justify-center rounded-xl px-4 text-sm font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 ${p.popular ? "bg-amber-400 text-stone-950 hover:bg-amber-300" : "bg-white/10 text-white hover:bg-white/15"}`}
>
Start with {p.name}
</a>
<ul className="mt-7 space-y-3 border-t border-white/10 pt-7 text-sm text-stone-300">
{p.features.map((f) => (
<li key={f} className="flex items-start gap-2.5">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={`mt-0.5 h-4 w-4 flex-none ${p.popular ? "text-amber-400" : "text-stone-500"}`}>
<path d="m4.5 10.5 3.5 3.5 7.5-8" />
</svg>
{f}
</li>
))}
</ul>
</>
);
return (
<motion.div
key={p.name}
initial={still ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={still ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 18, delay: 0.08 * i }}
className="relative"
>
{p.popular ? (
<div className="relative">
{/* Soft halo: the same rotating gradient, blurred, behind the card. */}
<div aria-hidden="true" className="absolute -inset-4 overflow-hidden rounded-[2.5rem] opacity-50 blur-2xl">
<div className="odometer-pricing-orbit absolute left-1/2 top-1/2 aspect-square w-[260%]" style={{ background: "conic-gradient(from 0deg, transparent 0deg, transparent 200deg, #f59e0b 280deg, #fde68a 320deg, transparent 360deg)" }} />
</div>
{/* Crisp border: a 1px ring showing the gradient as it sweeps round. */}
<div aria-hidden="true" className="absolute -inset-px overflow-hidden rounded-3xl bg-amber-400/20">
<div className="odometer-pricing-orbit absolute left-1/2 top-1/2 aspect-square w-[260%]" style={{ background: "conic-gradient(from 0deg, transparent 0deg, transparent 200deg, #f59e0b 290deg, #fff7d6 335deg, transparent 360deg)" }} />
</div>
<div className="relative rounded-3xl p-7 sm:p-8 md:py-10" style={{ background: "linear-gradient(180deg, #1f1a14 0%, #131110 55%)" }}>
{body}
</div>
</div>
) : (
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-7 sm:p-8">{body}</div>
)}
</motion.div>
);
})}
</div>
<p className="mt-10 text-center text-sm text-stone-500">Prices in US dollars. Taxes are added at checkout where they apply.</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/odometer-pricing-cards-with-orbiting-glow.json
Each digit is a box one line tall with `overflow-hidden`. Inside is a column holding the digits 0 to 9. To show a 7 the column is moved up by 70 percent of its own height with a spring. Only `transform` is animated, so the roll stays smooth.
A square much larger than the card sits behind it, painted with a `conic-gradient` that is transparent except for one bright wedge. The square rotates with a CSS keyframe. A wrapper one pixel larger than the card clips it, so only a thin ring shows. A second blurred copy makes the halo.
It works. Digits are keyed from the right, so the ones and tens keep rolling and the extra digit appears on the left. If you want it to grow in smoothly, wrap the leftmost digit in an `AnimatePresence` width animation.
It stops for good the first time someone presses Monthly or Yearly, and it pauses while the section is off screen. It never runs for people who ask for reduced motion.
Yes. Change `bg-stone-950` on the section, the `text-white` and `text-stone-` classes, and the card gradient in the inline `background` style. The glow reads best with a darker accent on light pages, for example `#d97706`.
Related: Pricing Table with Monthly and Yearly Toggle, Single Price Card with Included List and Guarantee, Two-Plan Pricing with Comparison Lists.
More pricing or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.