Billed monthly
- Unlimited projects
- Version history
- Priority support
Billed monthly
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardPricing(el) on the mounted element instead.
<article class="card-pricing card-pricing--monthly">
<div class="card-pricing__switch" role="group" aria-label="Billing period">
<span class="card-pricing__knob" aria-hidden="true"></span>
<button type="button" class="card-pricing__opt" aria-pressed="true">Monthly</button>
<button type="button" class="card-pricing__opt" aria-pressed="false">Yearly</button>
</div>
<div class="card-pricing__row">
<div class="card-pricing__price">
<span class="card-pricing__sr">$12 per month</span>
<span class="card-pricing__cur" aria-hidden="true">$</span>
<span class="card-pricing__digits" aria-hidden="true">
<span class="card-pricing__reel" style="transform:translateY(-40px);transition-delay:0ms">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
</span>
<span class="card-pricing__reel" style="transform:translateY(-80px);transition-delay:70ms">
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
</span>
</span>
</div>
<span class="card-pricing__per" aria-hidden="true">/mo</span>
<span class="card-pricing__badge">Save 17%</span>
</div>
<p class="card-pricing__note">Billed monthly</p>
<ul class="card-pricing__list">
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"></path>
</svg>
Unlimited projects
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"></path>
</svg>
Version history
</li>
<li>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"></path>
</svg>
Priority support
</li>
</ul>
</article>.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; }
}function initCardPricing(root) {
var PRICE = { monthly: 12, yearly: 10 };
var NOTE = { monthly: 'Billed monthly', yearly: 'Billed $120 yearly' };
var opts = root.querySelectorAll('.card-pricing__opt');
// One reel per digit; both prices have two digits, so the reels stay and only roll.
var reels = root.querySelectorAll('.card-pricing__reel');
var sr = root.querySelector('.card-pricing__sr');
var note = root.querySelector('.card-pricing__note');
var plan = 'monthly';
function set(next) {
if (next === plan) return;
root.classList.replace('card-pricing--' + plan, 'card-pricing--' + next);
plan = next;
opts[0].setAttribute('aria-pressed', String(plan === 'monthly'));
opts[1].setAttribute('aria-pressed', String(plan === 'yearly'));
var digits = String(PRICE[plan]).split('');
reels.forEach(function (reel, i) {
reel.style.transform = 'translateY(' + -digits[i] * 40 + 'px)';
});
sr.textContent = '$' + PRICE[plan] + ' per month';
// A new note replays its fade in.
var fresh = note.cloneNode(false);
fresh.textContent = NOTE[plan];
note.replaceWith(fresh);
note = fresh;
}
function onMonthly() {
set('monthly');
}
function onYearly() {
set('yearly');
}
opts[0].addEventListener('click', onMonthly);
opts[1].addEventListener('click', onYearly);
return function () {
opts[0].removeEventListener('click', onMonthly);
opts[1].removeEventListener('click', onYearly);
};
}
document.querySelectorAll('.card-pricing').forEach(initCardPricing);Goal: Create a "Pricing" card.
Visual behavior: 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.
Technique:
- Each price digit is a column of 0 to 9 in a window one digit tall, moved with translateY to its digit. The second digit has a 70ms transition delay, so they roll one after the other.
- The switch is two buttons with aria-pressed over a knob that slides by translateX on an overshooting curve; the badge drops from above and scales up with the yearly class.
- The billing note is replaced on each change, so it fades up anew.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pricing" card.
Visual behavior: 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.
Technique:
- Each price digit is a column of 0 to 9 in a window one digit tall, moved with translateY to its digit. The second digit has a 70ms transition delay, so they roll one after the other.
- The switch is two buttons with aria-pressed over a knob that slides by translateX on an overshooting curve; the badge drops from above and scales up with the yearly class.
- The billing note is replaced on each change, so it fades up anew.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.
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.