Pricing

$12 per month
Save 17%

Billed monthly

  • Unlimited projects
  • Version history
  • Priority support
No dependencies
Code format
React + Tailwind
import { useState } from 'react';

const PRICE = { monthly: 12, yearly: 10 };
const DIGITS = Array.from({ length: 10 }, (_, i) => i);

const css = `
  .card-pricing { box-sizing: border-box; width: 240px; padding: 14px 16px 16px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  .card-pricing__switch { position: relative; display: grid; grid-template-columns: 1fr 1fr; padding: 3px; border-radius: 999px; background: light-dark(#ebedf0, #232529); }
  .card-pricing__knob { position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc(50% - 3px); border-radius: 999px; background: light-dark(#373a40, #f1f2f3); transition: transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .card-pricing--yearly .card-pricing__knob { transform: translateX(100%); }
  .card-pricing__opt { position: relative; height: 26px; border: 0; border-radius: 999px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; transition: color 0.25s; }
  .card-pricing__opt[aria-pressed='true'] { color: light-dark(#fbfcfd, #121417); }
  .card-pricing__opt:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .card-pricing__row { display: flex; align-items: center; margin-top: 14px; }
  .card-pricing__price { display: flex; align-items: flex-start; color: light-dark(#373a40, #f1f2f3); }
  .card-pricing__cur { margin-top: 5px; font-size: 18px; font-weight: 600; }
  .card-pricing__digits { display: flex; height: 40px; overflow: hidden; font-size: 40px; font-weight: 700; line-height: 40px; font-variant-numeric: tabular-nums; }
  .card-pricing__reel { display: flex; flex-direction: column; transition: transform 0.6s cubic-bezier(0.3, 1.2, 0.5, 1); }
  .card-pricing__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .card-pricing__per { align-self: flex-end; margin: 0 0 6px 3px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
  .card-pricing__badge {
    margin-left: auto;
    padding: 3px 8px;
    border-radius: 999px;
    background: light-dark(#373a40, #f1f2f3);
    color: light-dark(#fbfcfd, #121417);
    font-size: 10px;
    font-weight: 700;
    opacity: 0;
    transform: translateY(-12px) scale(0.6);
    transition: transform 0.45s cubic-bezier(0.3, 1.8, 0.5, 1), opacity 0.2s;
  }
  .card-pricing--yearly .card-pricing__badge { opacity: 1; transform: none; }
  .card-pricing__note { height: 16px; margin: 2px 0 10px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); animation: card-pricing-note 0.35s ease-out; }
  @keyframes card-pricing-note {
    from { opacity: 0; transform: translateY(4px); }
  }
  .card-pricing__list { display: grid; gap: 6px; margin: 0; padding: 10px 0 0; border-top: 1px solid light-dark(#ebedf0, #232529); list-style: none; font-size: 11.5px; color: light-dark(#42454c, #e5e6e9); }
  .card-pricing__list li { display: flex; align-items: center; gap: 8px; }
  .card-pricing__list svg { width: 12px; height: 12px; color: light-dark(#373a40, #f1f2f3); }
  @media (prefers-reduced-motion: reduce) {
    .card-pricing__knob, .card-pricing__reel, .card-pricing__badge { transition: none; }
    .card-pricing__note { animation: none; }
  }
`;

export function PricingCard() {
  const [plan, setPlan] = useState<'monthly' | 'yearly'>('monthly');
  const digits = String(PRICE[plan]).split('').map(Number);
  return (
    <>
      <style>
        {css}
      </style>
      <article className={`card-pricing card-pricing--${plan}`}>
        <div className="card-pricing__switch" role="group" aria-label="Billing period">
          <span className="card-pricing__knob" aria-hidden="true" />
          {(['monthly', 'yearly'] as const).map((p) => (
            <button key={p} type="button" className="card-pricing__opt" aria-pressed={plan === p} onClick={() => setPlan(p)}>
              {p === 'monthly' ? 'Monthly' : 'Yearly'}
            </button>
          ))}
        </div>
        <div className="card-pricing__row">
          <div className="card-pricing__price">
            <span className="card-pricing__sr">${PRICE[plan]} per month</span>
            <span className="card-pricing__cur" aria-hidden="true">
              $
            </span>
            {/* Each digit is a reel of 0 to 9, rolled to its digit, the second a beat after the first. */}
            <span className="card-pricing__digits" aria-hidden="true">
              {digits.map((d, i) => (
                <span key={i} className="card-pricing__reel" style={{ transform: `translateY(${-d * 40}px)`, transitionDelay: `${i * 70}ms` }}>
                  {DIGITS.map((n) => (
                    <span key={n}>{n}</span>
                  ))}
                </span>
              ))}
            </span>
          </div>
          <span className="card-pricing__per" aria-hidden="true">
            /mo
          </span>
          <span className="card-pricing__badge">Save 17%</span>
        </div>
        <p key={plan} className="card-pricing__note">
          {plan === 'monthly' ? 'Billed monthly' : 'Billed $120 yearly'}
        </p>
        <ul className="card-pricing__list">
          {['Unlimited projects', 'Version history', 'Priority support'].map((f) => (
            <li key={f}>
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M5 12.5l4.5 4.5L19 7.5" />
              </svg>
              {f}
            </li>
          ))}
        </ul>
      </article>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-pricing
Source on GitHub

About this component

A plan card with a Monthly and Yearly switch: the knob slides across, the price digits roll to the new amount a beat apart, the billing note changes and a Save badge drops in for yearly.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, card
Techniques
css transition, keyframes, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Monthly and Yearly are buttons in a labelled group with aria-pressed. The rolling digits are aria-hidden and a visually hidden line reads the price, such as "$10 per month". Under prefers-reduced-motion the knob, digits and badge change without moving.

More cards

See all
Inbox
  • Ana: the prints are in
  • Deploy finished in 41s
  • Standup moved to 10:30
Refresh

Paper plane, take 3

Studio reel · 1.2k views

Scrub

Lemon pasta

2 servings
  • Spaghetti200 g
  • Lemons1
  • Parmesan50 g
  • Butter30 g
Servings
Kai Book club

No spoilers, but it was all along.

Click the specks to reveal.

Spoiler