Pricing card
A plan card with a sliding monthly and yearly switch; the price rolls only the digits that change.
CardsInteractive
Interactive, try it
Pro
For people shipping every week
$$19
per month
billed yearly
billed yearly
Save 20%
- Unlimited projects
- Offline previews
- Custom domains
- Priority support
pricing-card.tsx
130 lines"use client";
import { useRef, useState, type KeyboardEvent } from "react";
import { Check } from "lucide-react";
type Cycle = "monthly" | "yearly";
const PRICE: Record<Cycle, number> = { monthly: 24, yearly: 19 };
const FEATURES = ["Unlimited projects", "Offline previews", "Custom domains", "Priority support"];
/** One digit that rolls vertically to its value. */
function Digit({ value }: { value: number }) {
return (
<span className="relative inline-block h-[1em] w-[0.58em] overflow-hidden leading-none">
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-500 ease-[cubic-bezier(.3,.75,.1,1)] motion-reduce:transition-none"
style={{ transform: `translateY(${-value}em)` }}
>
{Array.from({ length: 10 }, (_, d) => (
<span key={d} className="h-[1em] text-center" aria-hidden>
{d}
</span>
))}
</span>
</span>
);
}
export default function PricingCard() {
const [cycle, setCycle] = useState<Cycle>("yearly");
const refs = useRef<Record<Cycle, HTMLButtonElement | null>>({ monthly: null, yearly: null });
const price = PRICE[cycle];
const yearly = cycle === "yearly";
const onKeyDown = (e: KeyboardEvent) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) return;
e.preventDefault();
const next: Cycle = yearly ? "monthly" : "yearly";
setCycle(next);
refs.current[next]?.focus();
};
return (
<div className="@container grid h-full w-full place-items-center bg-[linear-gradient(160deg,#f5f5f4,#e7e5e4)] p-4 text-stone-900 dark:bg-[linear-gradient(160deg,#1c1917,#0c0a09)] dark:text-stone-50">
<div className="w-full max-w-[480px] rounded-3xl bg-white px-6 py-5 shadow-[0_1px_2px_rgba(0,0,0,.05),0_24px_48px_-20px_rgba(0,0,0,.25)] ring-1 ring-black/5 dark:bg-stone-900 dark:ring-white/10">
<div className="flex items-start justify-between gap-4 @max-[480px]:flex-col @max-[480px]:items-stretch @max-[480px]:gap-3">
<div>
<div className="text-[17px] font-semibold">Pro</div>
<div className="text-[13px] text-stone-500 dark:text-stone-400">For people shipping every week</div>
</div>
<div
role="radiogroup"
aria-label="Billing cycle"
onKeyDown={onKeyDown}
className="relative grid flex-none grid-cols-2 rounded-full bg-stone-100 p-1 text-[13px] font-medium dark:bg-stone-800"
>
<span
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[cubic-bezier(.3,.75,.1,1)] dark:bg-stone-600 dark:ring-white/10"
style={{ transform: yearly ? "translateX(100%)" : "none" }}
aria-hidden
/>
{(["monthly", "yearly"] as const).map((c) => (
<button
key={c}
ref={(el) => {
refs.current[c] = el;
}}
type="button"
role="radio"
aria-checked={cycle === c}
tabIndex={cycle === c ? 0 : -1}
onClick={() => setCycle(c)}
className={`relative h-8 rounded-full px-3.5 capitalize transition-colors outline-offset-2 focus-visible:outline-2 focus-visible:outline-sky-500 ${
cycle === c ? "text-stone-900 dark:text-white" : "text-stone-500 dark:text-stone-400"
}`}
>
{c}
</button>
))}
</div>
</div>
<div className="mt-3 flex items-end gap-3">
{/* The sign hangs outside the digits so the number reads as the anchor */}
<div className="relative pl-[0.4em] text-[48px] font-semibold tracking-tight tabular-nums">
<span className="absolute top-[0.14em] left-0 text-[0.45em] text-stone-400">$</span>
<span className="sr-only">${price}</span>
<span aria-hidden className="inline-flex">
<Digit value={Math.floor(price / 10)} />
<Digit value={price % 10} />
</span>
</div>
<div className="pb-2 text-[13px]/5 text-stone-500 dark:text-stone-400">
per month
<br />
{yearly ? "billed yearly" : "billed monthly"}
</div>
<div
className={`mb-2.5 ml-auto grid transition-[grid-template-columns,opacity] duration-300 ${
yearly ? "grid-cols-[1fr] opacity-100" : "grid-cols-[0fr] opacity-0"
}`}
>
<span className="overflow-hidden">
<span className="block rounded-full bg-emerald-50 px-2.5 py-1 text-[12px] font-semibold whitespace-nowrap text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
Save 20%
</span>
</span>
</div>
</div>
<ul className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 @max-[480px]:grid-cols-1 border-t border-black/5 pt-4 text-[13px] dark:border-white/10">
{FEATURES.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check className="size-4 text-emerald-600 dark:text-emerald-400" aria-hidden />
{f}
</li>
))}
</ul>
<button
type="button"
className="mt-4 h-11 w-full rounded-xl bg-stone-900 text-[14px] font-medium text-white transition-colors hover:bg-stone-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 active:bg-stone-950 dark:bg-white dark:text-stone-900 dark:hover:bg-stone-200"
>
Start 14-day trial
</button>
</div>
</div>
);
}