Pricing Odometer
NewInstallation
npx shadcn@latest add https://uibrik.com/r/pricing-odometer.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/pricing-odometer/pricing-odometer.tsx"use client"; import { useEffect, useRef, useState, type RefObject } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; import { PLANS, allFeatures, pickPlans, yearlySaving, type Plan, type Tiers } from "../_pricing/plans"; import { Badge, BillingToggle, EASE_OUT, FeatureRow, Motion, PlanButton, PlanCard, PlanPrice, formatMoney, type Billing, type MoneyFormat, } from "../_pricing/parts"; // Pricing odometer. Two or three plans under a monthly / yearly switch. Flip // it and every price rolls digit by digit to its new amount, the old monthly // price stays beside it with a line drawn through, and the note below says // what is actually charged. // // With the spotlight on, every card lists the same features in the same // order, so rows line up across plans and the comparison reads straight // across. Pointing at a plan (or tabbing into it) puts it in the spotlight: // the others ease back and a marker sweeps behind the features that plan // adds. At rest the recommended plan holds it. Once the cards stack into one // column there is nothing to compare side by side, so every card stays lit. // With the spotlight off, each plan lists only what it adds. export type PricingOdometerProps = MoneyFormat & { /** How many plans to show. */ tiers?: Tiers; /** Billing period on mount. */ defaultBilling?: Billing; /** Show the saving beside "Yearly". */ showSaving?: boolean; /** Full aligned feature lists, with the pointed-at plan's own features marked. */ spotlight?: boolean; /** Marker colour behind a plan's own features. Empty follows the theme. */ color?: string; /** How far the other plans fade back while one is in the spotlight, 0–1. */ dim?: number; plans?: readonly Plan[]; onSelect?: (plan: Plan, billing: Billing) => void; className?: string; }; const SWEEP = { duration: 0.4, ease: [0.65, 0, 0.35, 1] } as const; const STAGGER = 0.05; export function PricingOdometer({ tiers = "3", defaultBilling = "yearly", showSaving = true, spotlight = true, color = "", dim = 0.45, plans = PLANS, currency = "USD", locale = "en-US", onSelect, className, }: PricingOdometerProps) { const [billing, setBilling] = useState<Billing>(defaultBilling); const [pointed, setPointed] = useState<number | null>(null); const grid = useRef<HTMLDivElement>(null); const sideBySide = useSideBySide(grid); const shown = pickPlans(tiers, plans); const yearly = billing === "yearly"; const money = { currency, locale }; const features = allFeatures(shown); const comparing = spotlight && sideBySide; const resting = Math.max(0, shown.findIndex((p) => p.featured)); const lit = pointed ?? resting; return ( <Motion> <section className={cn("@container flex w-full flex-col items-center gap-8", className)}> <BillingToggle value={billing} onChange={setBilling} saving={showSaving ? yearlySaving(shown) : 0} /> <div ref={grid} className={cn( "grid w-full gap-3", shown.length === 2 ? "max-w-3xl @2xl:grid-cols-2" : "max-w-5xl @3xl:grid-cols-3", )} onPointerLeave={() => setPointed(null)} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setPointed(null)} > {shown.map((plan, i) => { const on = !comparing || i === lit; const price = yearly ? plan.yearly : plan.monthly; const discounted = yearly && plan.yearly < plan.monthly; const note = plan.monthly === 0 ? "Free forever" : yearly ? `Billed ${formatMoney(plan.yearly * 12, money)} yearly` : "Billed monthly"; return ( <motion.div key={plan.name} className="flex" onPointerEnter={() => comparing && setPointed(i)} onFocus={() => comparing && setPointed(i)} initial={false} animate={{ opacity: on ? 1 : 1 - dim }} transition={{ duration: 0.25, ease: "easeOut" }} > <PlanCard name={plan.name} badge={plan.featured && <Badge>Popular</Badge>} className="w-full"> <p className="min-h-10 text-sm text-muted-foreground">{plan.blurb}</p> <PlanPrice value={price} {...money} className="mt-4" after={<Struck show={discounted}>{formatMoney(plan.monthly, money)}</Struck>} /> {/* swaps in place, one line tall, so nothing below moves */} <div className="relative mt-1 h-5 overflow-hidden text-sm text-muted-foreground"> <AnimatePresence initial={false} mode="popLayout"> <motion.p key={note} initial={{ opacity: 0, y: 8, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -8, filter: "blur(2px)" }} transition={{ duration: 0.3, ease: EASE_OUT }} > {note} </motion.p> </AnimatePresence> </div> <PlanButton primary={plan.featured} className="mt-6" onClick={() => onSelect?.(plan, billing)}> {plan.cta} </PlanButton> <div className="mt-6 border-t border-border pt-5"> {spotlight ? ( <ul className="flex flex-col gap-2.5"> {features.map((f) => ( <FeatureRow key={f.name} included={f.tier <= i}> {f.tier === i ? ( <Marked on={on} color={color} delay={0.08 + plan.features.indexOf(f.name) * STAGGER}> {f.name} </Marked> ) : ( f.name )} </FeatureRow> ))} </ul> ) : ( <> <p className="text-xs font-medium text-muted-foreground">{i === 0 ? "Includes" : `Everything in ${shown[i - 1].name}, plus`}</p> <ul className="mt-3 flex flex-col gap-2.5"> {plan.features.map((f) => ( <FeatureRow key={f}>{f}</FeatureRow> ))} </ul> </> )} </div> </PlanCard> </motion.div> ); })} </div> </section> </Motion> ); } // Whether the cards sit in one row, read off the layout itself rather than a // width, so it agrees with the container query even when an ancestor is zoomed. function useSideBySide(ref: RefObject<HTMLElement | null>) { const [side, setSide] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const check = () => { const [a, b] = el.children; setSide(!!a && !!b && Math.abs(a.getBoundingClientRect().top - b.getBoundingClientRect().top) < 1); }; check(); const ro = new ResizeObserver(check); ro.observe(el); return () => ro.disconnect(); }, [ref]); return side; } // A marker stroke behind the text, swept in from the left while lit and // wiped off to the right when the spotlight moves on. function Marked({ on, color, delay, children }: { on: boolean; color: string; delay: number; children: string }) { return ( <span className="relative inline-block"> <motion.span aria-hidden className={cn("absolute inset-x-[-0.3em] bottom-[0.05em] top-[0.45em] rotate-[-0.6deg]", !color && "bg-zinc-200 dark:bg-zinc-700")} style={{ borderRadius: "0.3em 0.15em 0.35em 0.1em", backgroundColor: color || undefined, originX: on ? 0 : 1 }} initial={false} animate={{ scaleX: on ? 1 : 0 }} transition={{ ...SWEEP, delay: on ? delay : 0 }} /> <span className="relative">{children}</span> </span> ); } // The old price, with a line drawn across it from the left once it applies. function Struck({ show, children }: { show: boolean; children: string }) { return ( <AnimatePresence initial={false}> {show && ( <motion.span className="relative text-sm tabular-nums text-muted-foreground" initial={{ opacity: 0, x: -4 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -4 }} transition={{ duration: 0.25, ease: EASE_OUT }} > <span className="sr-only">was </span> {children} <motion.span aria-hidden className="absolute -inset-x-0.5 top-1/2 h-px origin-left bg-current" initial={{ scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }} /> </motion.span> )} </AnimatePresence> ); }
Adds the files to
components/uibrik/pricing-odometer and installs @number-flow/react, @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/pricing-odometer.Props
PricingOdometer- tiers
- Type "2" | "3"
- "3"
- How many plans to show.
- defaultBilling
- Type "monthly" | "yearly"
- "yearly"
- Billing period on mount.
- showSaving
- Type boolean
- true
- Show the yearly saving beside "Yearly".
- spotlight
- Type boolean
- true
- Full, aligned feature lists; the plan in focus fades the others back and marks what it adds. Off lists only what each plan adds.
- color
- Type string
- ""
- Marker colour behind a plan's own features. Empty follows the theme.
- dim
- Type number
- 0.45
- How far the other plans fade back.
- currency
- Type string
- "USD"
- ISO currency code for the prices.
- locale
- Type string
- "en-US"
- Locale for currency symbols, grouping and decimals.
- plans
- Type Plan[]
- –
- Plans in tier order. Each lists only the features it adds to the one before.
- onSelect
- Type (plan: Plan, billing: Billing) => void
- –
- Called when a plan's button is pressed.
- className
- Type string
- –
- Classes for the section.