デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
料金表の 3 列のカードを、画面に入ったときに左から順に起こして見せます。カードは下の辺を軸に奥へ 90° 寝かせておき、0.15 秒ずつずらして立てていきます。3 枚が立ちそろうと、真ん中のおすすめの列だけ地が上へ 48px 伸び、伸びた所に「おすすめ」の札が左から開いて付きます。並んだ表のなかで、おすすめの列が最後にもう一段せり上がるので、視線がそこへ集まります。
起き上がりは 0.9 秒の back.out で、立ちきる直前に手前へ少し倒れ込んでから戻ります。遠近は列ごとに付け、消失点を列の下の辺に置いています。寝ているカードは真横から見た線になって見えず、起き上がるにつれて高さが出てきます。傾いている間は墨の陰を重ね、濃さを 45% から 0 へ下げて、白いカードでも向きが変わっているのが分かるようにしています。
おすすめの列の地は、カードそのものではなく ::before に塗り、上へ伸びる量を CSS の変数で持たせています。カードの高さを変えると、中の文字やボタンの位置までずれ、ほかの列と下の辺がそろわなくなるためです。札も伸びた地の中に置くので、伸ばす高さと札の位置は同じ変数から決めます。
出すのは画面に入った 1 回だけなので、スクロールの位置を追い続ける仕組みは要らず、IntersectionObserver で、表の上端が画面の下から 20% の線を越えたときに再生します。寝ているカードも読み上げと Tab の移動には残るので、表の中に焦点が入ったら、線を越える前でも出しきります。列に重ねるか、中のボタンに焦点が入ると、その列が 8px 浮きます。動きを減らす設定では、出そろった表をそのまま見せます。
つまみは、起き上がる時間とずらす間隔、行き過ぎの強さ、地の伸びる高さです。行き過ぎを強くしすぎると、カードが手前へ大きく倒れ込み、隣の列に迫って見えます。月額と年額の切り替えで数字を回すような料金表と違い、表そのものの現れ方でおすすめを印象づける形です。サービスの料金表や、プランの比べ、会員の種類の紹介に向いています。
コード
<section class="pricing" aria-labelledby="pricing-title">
<p class="pricing__eyebrow">Pricing</p>
<h2 class="pricing__title" id="pricing-title">サイトの保守プラン</h2>
<ul class="pricing__plans">
<li class="pricing__plan">
<article class="pricing__card">
<h3 class="pricing__name">ライト</h3>
<p class="pricing__text">ここに説明文が入ります。</p>
<p class="pricing__price"><strong>¥9,800</strong>/月</p>
<ul class="pricing__features">
<li>サーバーの監視</li>
<li>月1回のバックアップ</li>
<li>メールでの相談</li>
</ul>
<a class="pricing__cta" href="/contact/?plan=lite">申し込む</a>
</article>
</li>
<!-- おすすめの列。地が上へ一段伸び、伸びた所に札が付く -->
<li class="pricing__plan is-featured">
<article class="pricing__card">
<p class="pricing__badge">おすすめ</p>
<h3 class="pricing__name">スタンダード</h3>
<p class="pricing__text">ここに説明文が入ります。</p>
<p class="pricing__price"><strong>¥29,800</strong>/月</p>
<ul class="pricing__features">
<li>月4回までの更新</li>
<li>アクセスの月次レポート</li>
<li>チャットでの相談</li>
</ul>
<a class="pricing__cta" href="/contact/?plan=standard">申し込む</a>
</article>
</li>
<li class="pricing__plan">
<article class="pricing__card">
<h3 class="pricing__name">プレミアム</h3>
<p class="pricing__text">ここに説明文が入ります。</p>
<p class="pricing__price"><strong>¥59,800</strong>/月</p>
<ul class="pricing__features">
<li>更新の回数は無制限</li>
<li>新しいページを月1本</li>
<li>専任の担当者</li>
</ul>
<a class="pricing__cta" href="/contact/?plan=premium">相談する</a>
</article>
</li>
</ul>
</section>
.pricing {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #6b6b6b;
--line: rgba(38, 38, 38, 0.12);
--step: 48px; /* おすすめの列が上へ伸びる高さ */
padding: 96px 24px;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.pricing__eyebrow { margin: 0 0 12px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.pricing__title { margin: 0 0 32px; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
.pricing__plans {
display: grid;
grid-template-columns: repeat(3, minmax(0, 320px));
justify-content: center;
gap: 24px;
margin: 0;
padding: var(--step) 0 0;
list-style: none;
text-align: left;
}
/* 列ごとに遠近を持ち、消失点を下の辺に置く(寝ているカードは線になる) */
.pricing__plan { perspective: 1200px; perspective-origin: 50% 100%; transition: transform 0.3s ease; }
.pricing__plan:hover,
.pricing__plan:focus-within { transform: translateY(-8px); }
/* 地は ::before に塗り、--rise の分だけ上へ伸ばす。::after は起き上がる間の陰 */
.pricing__card {
--rise: 0px;
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
box-sizing: border-box;
height: 100%;
padding: 32px;
}
.pricing__card::before,
.pricing__card::after { content: ""; position: absolute; inset: 0; border-radius: 4px; }
.pricing__card::before { top: calc(-1 * var(--rise)); z-index: -1; background: var(--surface); }
.pricing__card::after { background: rgb(38 38 38 / var(--shade, 0)); pointer-events: none; }
/* おすすめは明暗を反転する */
.is-featured .pricing__card {
--rise: var(--step);
--surface: #262626;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
color: var(--ink);
}
.pricing__badge {
position: absolute;
top: calc(10px - var(--rise)); /* 伸びる地の上端に付いて一緒に上がる */
left: 32px;
margin: 0;
padding: 7px 14px;
border-radius: 999px;
background: var(--ink);
color: #111111;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
}
.pricing__name { margin: 0; font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.pricing__text { margin: 8px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute); }
.pricing__price { margin: 24px 0 0; font-size: 14px; font-weight: 600; line-height: 1; color: var(--mute); }
.pricing__price strong { margin-right: 4px; font-size: 44px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; color: var(--ink); }
.pricing__features { display: grid; gap: 10px; margin: 24px 0 32px; padding: 16px 0 0; border-top: 1px solid var(--line); list-style: none; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
.pricing__features li { display: flex; align-items: center; gap: 12px; }
.pricing__features li::before { content: ""; flex: none; width: 10px; height: 5px; margin: 0 2px 3px; border: solid currentColor; border-width: 0 0 2px 2px; rotate: -45deg; }
.pricing__cta { display: flex; align-items: center; justify-content: center; height: 48px; margin-top: auto; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
.is-featured .pricing__cta { background: var(--ink); color: #111111; }
.pricing :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
@media (max-width: 800px) {
.pricing__plans { grid-template-columns: minmax(0, 400px); padding-top: 0; }
.is-featured { margin-top: var(--step); }
}
@media (prefers-reduced-motion: reduce) {
.pricing__plan { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".pricing").forEach((section) => {
// 動きを減らす設定では、出そろった表のまま見せる
if (reduceMotion) return;
const list = section.querySelector(".pricing__plans");
const cards = section.querySelectorAll(".pricing__card");
const featured = section.querySelector(".is-featured .pricing__card");
const badge = section.querySelector(".pricing__badge");
const step = getComputedStyle(section).getPropertyValue("--step").trim();
const tl = gsap
.timeline({ paused: true })
// 下の辺を軸に、奥へ寝た所から左の列から順に起き上がる。寝ているほど陰を濃くする
.fromTo(
cards,
{ rotationX: 90, "--shade": 0.45 },
{ rotationX: 0, "--shade": 0, transformOrigin: "50% 100%", duration: 0.9, ease: "back.out(1.3)", stagger: 0.15 }
)
// 立ちそろったら、おすすめの地を上へ一段伸ばし、伸びる途中で札を左から開く
.fromTo(featured, { "--rise": "0px" }, { "--rise": step, duration: 0.6, ease: "power3.out" }, ">")
.fromTo(
badge,
{ clipPath: "inset(0% 100% 0% 0% round 99px)" },
{ clipPath: "inset(0% 0% 0% 0% round 99px)", duration: 0.3, ease: "power3.out" },
"<0.15"
);
// 表の上端が画面の下から 20% の線を越えたら、1 回だけ出す
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
tl.play();
},
{ rootMargin: "0px 0px -20% 0px" }
);
io.observe(list);
// Tab で表の中のボタンに来たら、線を越える前でも出しきる(寝たカードに焦点が当たったままにしない)
section.addEventListener("focusin", () => tl.play());
});