デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
価格の数字は 1 桁ずつ小さな窓に入れ、窓の中に 0〜9 を縦に並べた列を置きます。切り替えでは、この列を上下に送って目当ての数字を窓に合わせます。途中の数字も通るので、スロットのように回って見え、変わらない桁は止まったままです。3 桁ごとのカンマは窓の外の動かない文字にし、窓の上下 16% を mask-image のグラデーションで透かして、回る数字が窓の縁でふっと消えるようにしています。
押すと、まずつまみが選んだ文字の後ろへ 0.45 秒で滑り、「2か月分無料」の札が左から開きます。数字は 1 桁が 0.9 秒の power3.inOut で回り、プランごとに 0.08 秒、桁ごとに 0.06 秒ずつ遅らせます。年払いの価格は、数字が回り終わるころに成功の緑へ変えて着地を強調し、月払いへ戻すときは押してすぐ元の色に戻します。
途中で押し直しても跳ばないように、押すたびに今の値からトゥイーンを作り直し、前のものは overwrite: true ですぐ止めます。遅れて始まる動きが多いので、auto にすると、待っていた前の切り替えの色があとから始まり、月払いに戻したのに価格が緑のまま残ることがあります。
トグルは本物の radio のまとまりで、クリックでも矢印キーでも選べます。数字の列は 0〜9 がそのまま読み上げられてしまうので aria-hidden で外し、いまの価格を見えない文字で添えます。請求の注記も、隠れている行は読み上げから外します。動きを減らす設定では、回さずにその場で入れ替えます。このデモでは、月払い・年払いの文字を押すと切り替わります。
数字は tabular-nums で幅をそろえ、月額と年額で桁の数をそろえておきます。回る時間を短くするとカチッと切り替わり、ずらし幅を広げると、波のように順に回ります。料金表やプランの比較、通貨や期間の切り替えに向いています。
コード
<section class="pricing" aria-labelledby="pricing-title">
<p class="pricing__eyebrow">Pricing</p>
<h2 class="pricing__title" id="pricing-title">チームに合わせて選べるプラン</h2>
<!-- 月払いと年払いは radio のまとまり。クリックでも矢印キーでも選べる -->
<fieldset class="billing">
<legend class="sr-only">お支払いの周期</legend>
<div class="billing__track">
<span class="billing__knob" aria-hidden="true"></span>
<label class="billing__option"><input type="radio" name="billing" value="monthly" checked>月払い</label>
<label class="billing__option"><input type="radio" name="billing" value="yearly">年払い</label>
<span class="billing__badge">2か月分無料</span>
</div>
</fieldset>
<div class="pricing__plans">
<article class="plan">
<div class="plan__head"><h3 class="plan__name">ライト</h3></div>
<p class="plan__text">ここに説明文が入ります。</p>
<!-- 切り替える価格は data-* に書く。月額と年額で桁の数をそろえる -->
<p class="plan__price" data-monthly="1,980" data-yearly="1,650">
<span class="plan__yen">¥</span><span class="plan__amount">1,980</span><span class="plan__unit">/月</span>
</p>
<p class="plan__note"><span>毎月のお支払い</span><span>年額19,800円(一括払い)</span></p>
<a class="plan__cta" href="#">無料で試す</a>
<ul class="plan__features">
<li>プロジェクト3件まで</li>
<li>基本の分析</li>
<li>メールサポート</li>
</ul>
</article>
<!-- おすすめのプランは明暗を反転し、CTA だけを塗りのボタンにする -->
<article class="plan plan--featured">
<div class="plan__head"><h3 class="plan__name">スタンダード</h3><span class="plan__tag">おすすめ</span></div>
<p class="plan__text">ここに説明文が入ります。</p>
<p class="plan__price" data-monthly="4,980" data-yearly="4,150">
<span class="plan__yen">¥</span><span class="plan__amount">4,980</span><span class="plan__unit">/月</span>
</p>
<p class="plan__note"><span>毎月のお支払い</span><span>年額49,800円(一括払い)</span></p>
<a class="plan__cta" href="#">無料で試す</a>
<ul class="plan__features">
<li>プロジェクト無制限</li>
<li>詳しい分析</li>
<li>優先サポート</li>
</ul>
</article>
<article class="plan">
<div class="plan__head"><h3 class="plan__name">ビジネス</h3></div>
<p class="plan__text">ここに説明文が入ります。</p>
<p class="plan__price" data-monthly="7,980" data-yearly="6,650">
<span class="plan__yen">¥</span><span class="plan__amount">7,980</span><span class="plan__unit">/月</span>
</p>
<p class="plan__note"><span>毎月のお支払い</span><span>年額79,800円(一括払い)</span></p>
<a class="plan__cta" href="#">お問い合わせ</a>
<ul class="plan__features">
<li>SSOと監査ログ</li>
<li>権限の細かな設定</li>
<li>専任の担当者</li>
</ul>
</article>
</div>
</section>
.pricing {
--bg: #111111;
--surface: #262626;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0;
--line: rgb(238 236 230 / 0.14);
--save: #4f8a64; /* 年額の間の価格の色(成功の色) */
--save-text: #6ba37f; /* 小さい文字の成功の色(4.5:1 に届く) */
--digit: 60px; /* 価格の数字の大きさ */
padding: 64px 24px 96px;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.pricing__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.pricing__title { margin: 16px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: auto-phrase; }
.billing { margin: 24px 0 0; padding: 0; border: 0; }
.billing__track { position: relative; display: inline-flex; padding: 4px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); }
/* つまみは選んだ文字の後ろへ滑る。JS が文字から位置と幅を測る(JS の前は、左の月払いの後ろに置いておく) */
.billing__knob { position: absolute; left: 4px; top: 4px; bottom: 4px; width: calc(50% - 4px); border-radius: 999px; background: var(--ink); }
.billing__option { position: relative; padding: 0 24px; line-height: 40px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; color: var(--mute); cursor: pointer; transition: color 0.3s linear 0.05s; }
.billing__option:has(:checked) { color: var(--bg); }
.billing__option:has(:focus-visible) { outline: 2px solid var(--ink); outline-offset: 6px; border-radius: 999px; }
/* 本物の radio は見えなくするだけ(display: none にすると、キーボードで選べなくなる) */
.billing__option input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
/* 年払いのときだけ、左から開く(閉じるときは右から) */
.billing__badge {
position: absolute; left: calc(100% + 16px); top: 50%; margin-top: -14px; padding: 0 12px;
border-radius: 999px; box-shadow: inset 0 0 0 1px var(--save); color: var(--save-text);
font-size: 12px; font-weight: 700; line-height: 28px; letter-spacing: 0.06em; white-space: nowrap;
clip-path: inset(0 100% 0 0 round 99px); transition: clip-path 0.3s cubic-bezier(0.32, 0, 0.67, 0);
}
.billing:has([value="yearly"]:checked) .billing__badge { clip-path: inset(0 0 0 0 round 99px); transition: clip-path 0.5s cubic-bezier(0.25, 1, 0.5, 1) 0.15s; }
.pricing__plans { display: grid; grid-template-columns: repeat(3, minmax(0, 320px)); justify-content: center; align-items: center; gap: 24px; margin-top: 24px; text-align: left; }
.plan { display: flex; flex-direction: column; padding: 32px; border-radius: 4px; background: var(--surface); color: var(--ink); }
/* おすすめのプランは明暗を反転し、上下に少し大きくする */
.plan--featured { --surface: #eeece6; --ink: #111111; --mute-small: #6b6b6b; --line: rgb(17 17 17 / 0.12); padding-block: 48px; }
.plan__head { display: flex; justify-content: space-between; align-items: center; height: 24px; }
.plan__name { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: 0.04em; white-space: nowrap; }
.plan__tag { padding: 0 8px; border-radius: 999px; background: var(--ink); color: var(--surface); font-size: 12px; font-weight: 700; line-height: 24px; letter-spacing: 0.06em; }
.plan__text { margin: 8px 0 0; font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; color: var(--mute-small); }
.plan__price { display: flex; align-items: flex-start; margin: 16px 0 0; font-variant-numeric: tabular-nums; }
.plan__yen { margin: calc(var(--digit) * 0.23) 4px 0 0; font-size: 28px; font-weight: 700; line-height: 1; }
.plan__amount { display: flex; font-size: var(--digit); font-weight: 800; line-height: 1.2; }
/* 1 桁ごとの窓。上下を透かすと、回る数字が窓の縁でふっと消える */
.plan__digit { height: 1.2em; overflow: hidden; mask-image: linear-gradient(transparent, #000 16%, #000 84%, transparent); }
.plan__strip { display: block; white-space: pre-line; text-align: center; }
.plan__unit { align-self: flex-end; margin: 0 0 calc(var(--digit) * 0.24) 8px; font-size: 16px; color: var(--mute-small); }
.plan__note { flex: none; height: 24px; margin: 0; overflow: hidden; font-size: 14px; line-height: 24px; letter-spacing: 0.04em; color: var(--mute-small); }
.plan__note span { display: block; }
.plan__cta { margin-top: 16px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); color: inherit; font-size: 15px; font-weight: 600; line-height: 48px; letter-spacing: 0.04em; text-align: center; text-decoration: none; }
.plan--featured .plan__cta { background: var(--ink); color: var(--surface); }
.plan__cta:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.plan__features { display: grid; gap: 8px; margin: 16px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); list-style: none; }
.plan__features li { position: relative; padding-left: 30px; font-size: 14px; line-height: 22px; letter-spacing: 0.04em; }
/* チェックは線の丸(::before)とかぎ(::after) */
.plan__features li::before { content: ""; position: absolute; left: 0; top: 2px; width: 18px; height: 18px; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--mute); }
.plan__features li::after { content: ""; position: absolute; left: 6px; top: 7px; width: 6px; height: 3px; border: solid currentColor; border-width: 0 0 1.5px 1.5px; transform: rotate(-45deg); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 1040px) {
.pricing__plans { grid-template-columns: minmax(0, 400px); }
}
/* 狭い画面では、バッジをトグルの下に置く(右に置くと画面からはみ出す) */
@media (max-width: 520px) {
.billing { margin-bottom: 60px; }
.billing__badge { left: 50%; top: calc(100% + 8px); margin: 0; translate: -50% 0; }
}
@media (prefers-reduced-motion: reduce) {
.billing__option,
.billing__badge { transition: none !important; }
}
import { gsap } from "gsap";
const ROLL = 0.9; // 1 桁が回る時間(秒)
const STAGGER = 0.08; // プランごとのずれ(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 0〜9 を縦に並べた列を、1 桁ぶんの窓に入れる
function digitWindow() {
const cell = document.createElement("span");
const strip = document.createElement("span");
cell.className = "plan__digit";
strip.className = "plan__strip";
strip.textContent = "0123456789".split("").join("\n");
cell.append(strip);
return { cell, strip };
}
document.querySelectorAll(".pricing").forEach((section) => {
const knob = section.querySelector(".billing__knob");
const options = [...section.querySelectorAll(".billing__option")];
const inputs = options.map((option) => option.querySelector("input"));
const save = getComputedStyle(section).getPropertyValue("--save").trim();
const plans = [...section.querySelectorAll(".plan")].map((plan) => {
const price = plan.querySelector(".plan__price");
const amount = price.querySelector(".plan__amount");
// 数字の列は読み上げから外し、いまの価格を見えない文字で添える
const said = document.createElement("span");
said.className = "sr-only";
amount.before(said);
amount.setAttribute("aria-hidden", "true");
const strips = [];
amount.replaceChildren(...[...price.dataset.monthly].map((char) => {
if (!/\d/.test(char)) return char; // カンマは窓の外の動かない文字
const { cell, strip } = digitWindow();
strips.push(strip);
return cell;
}));
// 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
return { price, said, strips, ink: getComputedStyle(price).color, notes: [...plan.querySelectorAll(".plan__note span")] };
});
// 今の値から補間し直すので、途中で押し直しても跳ばない。k が 0 なら動かさずに置く
const setBilling = (yearly, k = reduceMotion ? 0 : 1) => {
const on = options[yearly ? 1 : 0];
gsap.to(knob, { x: on.offsetLeft - knob.offsetLeft, width: on.offsetWidth, duration: 0.45 * k, ease: "power3.inOut", overwrite: true });
plans.forEach((plan, p) => {
const text = plan.price.dataset[yearly ? "yearly" : "monthly"];
const digits = text.replace(/\D/g, "");
plan.said.textContent = text;
// 桁ごとに少しずつ遅らせ、途中の数字を通って回す。変わらない桁は同じ値へ向かうだけ
plan.strips.forEach((strip, d) => {
gsap.to(strip, { yPercent: -10 * digits[d], duration: ROLL * k, ease: "power3.inOut", delay: (0.1 + p * STAGGER + d * 0.06) * k, overwrite: true });
});
// 年額へは数字が回り終わるころに色を付け、月額へは押してすぐ戻す
gsap.to(plan.price, {
color: yearly ? save : plan.ink,
duration: (yearly ? 0.35 : 0.3) * k,
ease: "power1.inOut",
delay: (yearly ? 0.1 + p * STAGGER + ROLL * 0.75 : p * STAGGER) * k,
overwrite: true,
});
gsap.to(plan.notes, { yPercent: yearly ? -100 : 0, duration: 0.5 * k, ease: "power3.inOut", delay: (0.2 + p * STAGGER) * k, overwrite: true });
plan.notes.forEach((note, i) => note.setAttribute("aria-hidden", String(i !== Number(yearly))));
});
};
const isYearly = () => inputs[1].checked;
// クリックでも矢印キーでも、選ばれた radio から change が届く
inputs.forEach((input) => input.addEventListener("change", () => setBilling(isYearly())));
// つまみの位置は文字の幅で決まるので、書体が読み込まれたときと画面の幅が変わったときに置き直す
const place = () => setBilling(isYearly(), 0);
place();
document.fonts.ready.then(place);
window.addEventListener("resize", place);
});