01Plan cards — a card row that recomputes its price on every billing switch
Flipping the yearly-or-monthly switch recomputes each plan's displayed price with the discount applied, and the digits roll smoothly from the old value to the new one rather than jumping.
Best forSaaS pricing hero, subscription plan listings
requestAnimationFramearia-checkedtranslateX

<main class="stage">
<section class="panel" id="panel">
<p class="hint" id="hint">Every time the yearly/monthly switch flips, the discount is reapplied to recompute the displayed price.</p>
<div class="switchRow">
<span class="switchLabel" id="switchLabel">Annual billing</span>
<button class="switchCtl" id="billingSwitch" type="button" role="switch" aria-checked="false" aria-label="Switch to annual billing">
<span class="knob" aria-hidden="true"></span>
</button>
</div>
<div class="cards" id="cardsRow">
<article class="card">
<h3 class="planName">Basic</h3>
<p class="price"><span class="priceNum" id="price0">12</span><span class="priceUnit" id="unit0"> / mo</span></p>
<p class="badge" id="badge0" hidden></p>
</article>
<article class="card card--mid">
<h3 class="planName">Pro</h3>
<p class="price"><span class="priceNum" id="price1">29</span><span class="priceUnit" id="unit1"> / mo</span></p>
<p class="badge" id="badge1" hidden></p>
</article>
<article class="card">
<h3 class="planName">Team</h3>
<p class="price"><span class="priceNum" id="price2">59</span><span class="priceUnit" id="unit2"> / mo</span></p>
<p class="badge" id="badge2" hidden></p>
</article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-ink);
.panel {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.hint {
margin: 0;
padding: 0 $sp-2;
text-align: center;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.switchRow {
display: flex;
align-items: center;
gap: $sp-2;
}
.switchLabel {
font-weight: 500;
font-size: 12px;
color: $subject-cream;
}
.switchCtl {
position: relative;
width: 40px;
height: 22px;
border: 0;
border-radius: $r-pill;
background: rgba($subject-cream, .24);
padding: 0;
cursor: pointer;
transition: background $duration $easing;
}
.switchCtl[aria-checked="true"] {
background: $color;
}
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
.switchCtl[aria-checked="true"] .knob {
transform: translateX(18px);
}
.cards {
display: flex;
gap: $sp-3;
width: 100%;
}
.card {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-4 $sp-2;
border-radius: $r-card;
background: rgba($subject-cream, .08);
text-align: center;
}
.card--mid {
background: rgba($color, .30);
}
.planName {
margin: 0;
font-weight: 700;
font-size: 13px;
color: $subject-cream;
}
.price {
margin: 0;
display: flex;
align-items: baseline;
}
.priceNum {
font-weight: 700;
font-size: 26px;
color: $subject-cream;
}
.priceUnit {
font-weight: 400;
font-size: 11px;
color: rgba($subject-cream, .72);
}
.badge {
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $subject-orange-deep;
color: $subject-cream;
font-weight: 700;
font-size: 9px;
opacity: 0;
transform: scale(.6);
transform-origin: center;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.badge:not([hidden]) {
opacity: 1;
transform: scale(1);
}
@media (max-width: 340px) {
.panel { max-width: 300px; gap: $sp-2; }
.hint { font-size: 9px; }
.cards { gap: $sp-1; }
.card { padding: $sp-1 $sp-1; }
.planName { font-size: 11px; }
.priceNum { font-size: 17px; }
.priceUnit { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.switchCtl,
.knob,
.badge {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.hint {
margin: 0;
padding: 0 8px;
text-align: center;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.switchRow {
display: flex;
align-items: center;
gap: 8px;
}
.switchLabel {
font-weight: 500;
font-size: 12px;
color: #fff7e6;
}
.switchCtl {
position: relative;
width: 40px;
height: 22px;
border: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.24);
padding: 0;
cursor: pointer;
transition: background 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.switchCtl[aria-checked=true] {
background: #0a4fe8;
}
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.switchCtl[aria-checked=true] .knob {
transform: translateX(18px);
}
.cards {
display: flex;
gap: 12px;
width: 100%;
}
.card {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 8px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
text-align: center;
}
.card--mid {
background: rgba(10, 79, 232, 0.3);
}
.planName {
margin: 0;
font-weight: 700;
font-size: 13px;
color: #fff7e6;
}
.price {
margin: 0;
display: flex;
align-items: baseline;
}
.priceNum {
font-weight: 700;
font-size: 26px;
color: #fff7e6;
}
.priceUnit {
font-weight: 400;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
.badge {
margin: 0;
padding: 4px 8px;
border-radius: 4px;
background: #c93000;
color: #fff7e6;
font-weight: 700;
font-size: 9px;
opacity: 0;
transform: scale(0.6);
transform-origin: center;
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.badge:not([hidden]) {
opacity: 1;
transform: scale(1);
}
@media (max-width: 340px) {
.panel {
max-width: 300px;
gap: 8px;
}
.hint {
font-size: 9px;
}
.cards {
gap: 4px;
}
.card {
padding: 4px 4px;
}
.planName {
font-size: 11px;
}
.priceNum {
font-size: 17px;
}
.priceUnit {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.switchCtl,
.knob,
.badge {
transition: none;
}
}// 플랜 카드(01) — 연/월 스위치가 눌릴 때마다 플랜별 할인율(discount)을 monthly 가격에 적용해
// mo 환산가를 다시 구하고, 그 값을 rAF 로 이전 숫자에서 새 숫자까지 이징 계산하며 굴린다(계산 한 겹).
// 자동 시연은 스위치를 700ms 마다 반전시켜 세 카드가 매번 다시 계산되는 것을 보여준다.
(function () {
var switchCtl = document.getElementById('billingSwitch');
var cardsRow = document.getElementById('cardsRow');
if (!switchCtl || !cardsRow) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var PLANS = [
{ monthly: 12, discount: .15 },
{ monthly: 29, discount: .20 },
{ monthly: 59, discount: .25 }
];
var ROLL_MS = 600;
function yearlyMonthlyEq(plan) {
return Math.round(plan.monthly * (1 - plan.discount));
}
function animateValue(el, from, to, duration) {
if (!el) return;
if (reduce || from === to) { el.textContent = String(to); return; }
var start = null;
function step(ts) {
if (start === null) start = ts;
var p = Math.min((ts - start) / duration, 1);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = String(Math.round(from + (to - from) * eased));
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
var current = PLANS.map(function (p) { return p.monthly; });
function render(yearly) {
switchCtl.setAttribute('aria-checked', yearly ? 'true' : 'false');
PLANS.forEach(function (plan, i) {
var next = yearly ? yearlyMonthlyEq(plan) : plan.monthly;
var numEl = document.getElementById('price' + i);
var badgeEl = document.getElementById('badge' + i);
animateValue(numEl, current[i], next, ROLL_MS);
current[i] = next;
if (badgeEl) {
if (yearly) {
badgeEl.hidden = false;
badgeEl.textContent = Math.round(plan.discount * 100) + '% off now';
} else {
badgeEl.hidden = true;
}
}
});
}
render(false);
if (!reduce) {
var yearly = false;
setInterval(function () {
yearly = !yearly;
render(yearly);
}, 700);
}
})();// PricingPlanCards — vanilla 판(run/339/demo/01-pricing-plan-cards/index.html) 의 React 포트.
// 연/월 스위치가 눌릴 때마다 플랜별 할인율을 monthly 가격에 적용해 월 환산가를 다시 구하고,
// 그 값을 rAF 로 이전 숫자에서 새 숫자까지 이징 계산하며 굴린다(계산 한 겹).
import { useEffect, useRef } from "react";
import styles from "./PricingPlanCards.module.scss";
export type PricingPlanCardsProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "연·월 전환마다 할인율을 적용해 표시 가격을 다시 계산합니다";
const SWITCH_LABEL = "연간 결제";
const SWITCH_ARIA = "연간 결제로 전환";
type Plan = { name: string; monthly: number; discount: number };
const PLANS: Plan[] = [
{ name: "Basic", monthly: 12, discount: 0.15 },
{ name: "Pro", monthly: 29, discount: 0.2 },
{ name: "Team", monthly: 59, discount: 0.25 }
];
const ROLL_MS = 600;
function yearlyMonthlyEq(plan: Plan): number {
return Math.round(plan.monthly * (1 - plan.discount));
}
export default function PricingPlanCards({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: PricingPlanCardsProps) {
const switchRef = useRef<HTMLButtonElement | null>(null);
const priceRefs = useRef<(HTMLSpanElement | null)[]>([]);
const badgeRefs = useRef<(HTMLParagraphElement | null)[]>([]);
useEffect(() => {
const switchCtl = switchRef.current;
if (!switchCtl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const current = PLANS.map((p) => p.monthly);
function animateValue(el: HTMLElement | null, from: number, to: number, dur: number): void {
if (!el) return;
if (reduce || from === to) {
el.textContent = String(to);
return;
}
let start: number | null = null;
function step(ts: number): void {
if (start === null) start = ts;
const p = Math.min((ts - start) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
el!.textContent = String(Math.round(from + (to - from) * eased));
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function render(yearly: boolean): void {
switchCtl!.setAttribute("aria-checked", yearly ? "true" : "false");
PLANS.forEach((plan, i) => {
const next = yearly ? yearlyMonthlyEq(plan) : plan.monthly;
const numEl = priceRefs.current![i];
const badgeEl = badgeRefs.current![i];
animateValue(numEl, current[i], next, ROLL_MS);
current[i] = next;
if (badgeEl) {
if (yearly) {
badgeEl.hidden = false;
badgeEl.textContent = Math.round(plan.discount * 100) + "% 할인 중";
} else {
badgeEl.hidden = true;
}
}
});
}
render(false);
let timer = 0;
if (!reduce) {
let yearly = false;
timer = window.setInterval(() => {
yearly = !yearly;
render(yearly);
}, 700);
}
return () => {
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.switchRow}>
<span className={styles.switchLabel}>{SWITCH_LABEL}</span>
<button
className={styles.switchCtl}
ref={switchRef}
type="button"
role="switch"
aria-checked="false"
aria-label={SWITCH_ARIA}
>
<span className={styles.knob} aria-hidden="true" />
</button>
</div>
<div className={styles.cards}>
{PLANS.map((plan, i) => (
<article key={plan.name} className={i === 1 ? styles.card + " " + styles.cardMid : styles.card}>
<h3 className={styles.planName}>{plan.name}</h3>
<p className={styles.price}>
<span
className={styles.priceNum}
ref={(el: HTMLSpanElement | null) => {
priceRefs.current![i] = el;
}}
>
{plan.monthly}
</span>
<span className={styles.priceUnit}> / 월</span>
</p>
<p
className={styles.badge}
hidden
ref={(el: HTMLParagraphElement | null) => {
badgeRefs.current![i] = el;
}}
/>
</article>
))}
</div>
</section>
</main>
);
}01 Plan cards — a card row that recomputes its price on every billing switch — Measured area 16.259% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · 9 Pricing Page Design Sections (Copy-Paste)
The post this demo lives in
9 Pricing Page Design Sections (Copy-Paste)Nine pricing sections that compute: the billing switch reworks every plan's price on the spot, the discount badge derive See all demos in this post
If you like this effect - similar demos
Landing sections01 Case hero — a top section that recomputes its headline metric every frame to count it up9 Case Study Page Design Sections (Copy-Paste)
Landing sections01 Billing toggle that rolls the numberPricing Table Design: 9 Live Interactions
Landing sections09 Dual-path final CTALanding Page Conversion: 9 Sections That Convert