# Odometer: Pricing

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/odometer-pricing-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/odometer-pricing-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/odometer-pricing-demo-light-0cda534d45dbe18d.png
- Registry item: https://keyframeui.com/r/odometer-pricing-demo.json
- Categories: number, data, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/odometer-pricing-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/odometer.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/odometer#odometer-pricing-demo

## Files

### `registry/demos/odometer-pricing-demo.tsx`

```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>
  );
}
```
