デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「次へ」を押すたびに、今の段階の丸が濃いスレートで塗られて白いチェックが描かれ、次の段階への線が左から伸びて、次の丸に同じ色の輪と淡い輪が付いて「今ここ」になります。見出しと説明は、今のものが左へ抜け、次のものが右から入ります。最後の段階ではボタンが「注文を確定する」に替わり、確定するとボタンの列が消えて、完了の見出しが出ます。
段階の間の動きは、全部を 1 本の paused のタイムラインに 0.9 秒ずつ並べ、境目に段階ごとのラベルを打っておきます。進むときも戻るときも、tweenTo でラベルまで動かすだけです。戻るときは同じ動きを逆にたどるので、戻す動きを別に書かずに済みます。線は 0.45 秒の power2.inOut で伸ばし、丸は 0.25 秒で塗り、チェックは DrawSVGPlugin で 0.3 秒かけて描きます。
丸の「まだ」「今ここ」「済んだ」は、どれも box-shadow の「inset の輪と外側の広がり」の 2 つ重ねで書きます。形と数をそろえておくと、GSAP が影どうしを補間でき、淡い輪がふわりと広がります。片方だけ影が 1 つだと補間できず、切り替わりが飛びます。線を伸ばすのは clip-path の inset にしてください。scaleX で伸ばすと、線の丸い端までつぶれます。
段階の列は ol で書き、今の段階の項目に aria-current="step" を付けます。済んだ段階には見えない「(完了)」を添え、進むたびに role="status" の見えない領域へ「ステップ2/4:お届け先の入力」と書いて読み上げます。最初の段階では「戻る」を押せなくし、確定してボタンが消えたら、完了の見出しへ焦点を移します。動きを減らす設定では、タイムラインを速めて、ほぼ一瞬で切り替えます。
1 段ぶんの長さを変えると、全体の速さが一度に変わります。線と丸の色、淡い輪の広がりは CSS の変数で変えられます。このデモでは「次へ」と「戻る」を押して進め、最後まで進めると、少し置いてから最初の段階へ戻ります。購入の手続き、会員登録、設定のウィザードに向いています。
コード
<!-- 今の段階は aria-current="step" で伝え、進むたびに下の status が段階の名前を読み上げる -->
<section class="checkout" aria-label="購入の手続き">
<ol class="stepper">
<li class="stepper__step" aria-current="step">
<span class="stepper__dot" aria-hidden="true">
<span class="stepper__num">1</span>
<svg class="stepper__check" viewBox="0 0 24 24"><path d="M6 12.5 10.5 17 18 8.5" /></svg>
</span>
<span class="stepper__label">カート<span class="stepper__done"></span></span>
<span class="stepper__line" aria-hidden="true"><span class="stepper__fill"></span></span>
</li>
<li class="stepper__step">
<span class="stepper__dot" aria-hidden="true">
<span class="stepper__num">2</span>
<svg class="stepper__check" viewBox="0 0 24 24"><path d="M6 12.5 10.5 17 18 8.5" /></svg>
</span>
<span class="stepper__label">お届け先<span class="stepper__done"></span></span>
<span class="stepper__line" aria-hidden="true"><span class="stepper__fill"></span></span>
</li>
<li class="stepper__step">
<span class="stepper__dot" aria-hidden="true">
<span class="stepper__num">3</span>
<svg class="stepper__check" viewBox="0 0 24 24"><path d="M6 12.5 10.5 17 18 8.5" /></svg>
</span>
<span class="stepper__label">お支払い<span class="stepper__done"></span></span>
<span class="stepper__line" aria-hidden="true"><span class="stepper__fill"></span></span>
</li>
<li class="stepper__step">
<span class="stepper__dot" aria-hidden="true">
<span class="stepper__num">4</span>
<svg class="stepper__check" viewBox="0 0 24 24"><path d="M6 12.5 10.5 17 18 8.5" /></svg>
</span>
<span class="stepper__label">確認<span class="stepper__done"></span></span>
</li>
</ol>
<!-- 段階ごとの中身を同じ升に重ねる(実際には、ここに各段階のフォームを置く)。最後は完了の中身 -->
<div class="checkout__panels">
<div class="checkout__panel">
<h2 class="checkout__title" tabindex="-1">カートの中身</h2>
<p class="checkout__text">カートに3点の商品が入っています。</p>
</div>
<div class="checkout__panel">
<h2 class="checkout__title" tabindex="-1">お届け先の入力</h2>
<p class="checkout__text">お届け先の住所を入力してください。</p>
</div>
<div class="checkout__panel">
<h2 class="checkout__title" tabindex="-1">お支払い方法</h2>
<p class="checkout__text">お支払い情報は暗号化して安全に送信します。</p>
</div>
<div class="checkout__panel">
<h2 class="checkout__title" tabindex="-1">ご注文内容の確認</h2>
<p class="checkout__text">注文を確定する前に、内容をご確認ください。</p>
</div>
<div class="checkout__panel">
<h2 class="checkout__title" tabindex="-1">ご注文が完了しました</h2>
<p class="checkout__text">ご注文ありがとうございます。確認のメールをお送りしました。</p>
</div>
</div>
<div class="checkout__actions">
<button class="checkout__back" type="button" disabled><span aria-hidden="true">←</span> 戻る</button>
<!-- 最後の段階では「注文を確定する」に替わる。2 つの文字を同じ升に重ね、ボタンの幅は長いほうで決まる -->
<button class="checkout__next" type="button">
<span class="checkout__label">次へ <span aria-hidden="true">→</span></span>
<span class="checkout__label">注文を確定する</span>
</button>
</div>
<p class="checkout__status" role="status"></p>
</section>
.checkout {
--ink: #262626; /* 文字・ボタン */
--progress: #4a5b66; /* 済んだ丸・伸びる線・今の丸の輪(墨のスレート) */
--mute: #898989; /* まだの丸の番号 */
--mute-small: #6b6b6b; /* まだの段階の名前(16px 未満なので濃いめ) */
--line: rgba(38, 38, 38, 0.12); /* まだの丸の輪・線の地・区切り */
--surface: #ffffff;
--on-ink: #ffffff; /* 濃い塗りの上のチェックと文字 */
--halo: rgba(74, 91, 102, 0.12); /* 今の丸の外側の淡い輪 */
--dot: 64px; /* 丸の直径 */
--ring: 2px; /* 丸の輪の太さ */
--halo-width: 8px; /* 淡い輪の広がり */
max-width: 1024px;
box-sizing: border-box;
padding: 56px clamp(16px, 7vw, 96px) 48px;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
overflow: clip; /* 横へずれて入る中身で、狭い画面のページが横にはみ出さないように */
}
/* 段階は等しい幅の列に並べ、丸を列の中央に置く */
.stepper { display: grid; grid-template-columns: repeat(4, 1fr); margin: 0; padding: 0 0 40px; border-bottom: 1px solid var(--line); list-style: none; }
.stepper__step { position: relative; display: grid; justify-items: center; gap: 16px; }
/* 輪は inset の影で描く。外側の淡い輪と形をそろえておくと、GSAP で補間できる */
.stepper__dot {
display: grid;
place-items: center;
width: var(--dot);
height: var(--dot);
border-radius: 50%;
background: var(--surface);
box-shadow: inset 0 0 0 var(--ring) var(--line);
}
.stepper__num, .stepper__check { grid-area: 1 / 1; }
.stepper__num { color: var(--mute); font-size: 24px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.stepper__check { width: 56%; height: 56%; overflow: visible; fill: none; stroke: var(--on-ink); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.stepper__label { color: var(--mute-small); font-size: 15px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; white-space: nowrap; }
.stepper__step:first-child :is(.stepper__num, .stepper__label) { color: var(--ink); }
/* 丸と丸の間の線。地の線の上に濃い線を重ね、clip-path で左から伸ばす(scaleX だと角丸がつぶれる) */
.stepper__line {
position: absolute;
top: calc(var(--dot) / 2 - 2px);
left: calc(50% + var(--dot) / 2 + 8px);
width: calc(100% - var(--dot) - 16px);
height: 4px;
border-radius: 4px;
background: var(--line);
overflow: hidden;
}
.stepper__fill { position: absolute; inset: 0; border-radius: inherit; background: var(--progress); clip-path: inset(0 100% 0 0); }
/* 中身は同じ升に重ね、JS が動く前は最初のものだけを見せる */
.checkout__panels { display: grid; margin-top: 48px; text-align: center; }
.checkout__panel { grid-area: 1 / 1; }
.checkout__panel:not(:first-child) { visibility: hidden; }
.checkout__title { margin: 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.checkout__text { margin: 16px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.checkout__actions { display: flex; justify-content: space-between; gap: 16px; margin-top: 64px; }
.checkout__actions button {
height: 48px;
padding: 0 32px;
border: 0;
border-radius: 999px;
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
}
.checkout__back { padding: 0 24px; background: transparent; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); }
.checkout__back:disabled { opacity: 0.4; cursor: default; }
.checkout__next { display: grid; place-items: center; background: var(--ink); color: var(--on-ink); }
.checkout__label { grid-area: 1 / 1; }
.checkout__label + .checkout__label { visibility: hidden; }
.checkout :focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
/* 読み上げだけに届ける */
.stepper__done, .checkout__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 600px) {
.checkout { --dot: 40px; padding-top: 40px; }
.stepper__num { font-size: 16px; }
.stepper__label { font-size: 13px; }
.checkout__title { font-size: 32px; }
.checkout__text { font-size: 16px; }
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const SEG = 0.9; // 1 段ぶんの動きの長さ(秒)
const SHIFT = 40; // 中身が横へずれる量(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".checkout").forEach((root) => {
const steps = [...root.querySelectorAll(".stepper__step")];
const panels = [...root.querySelectorAll(".checkout__panel")];
const actions = root.querySelector(".checkout__actions");
const back = root.querySelector(".checkout__back");
const next = root.querySelector(".checkout__next");
const [toNext, toPlace] = next.querySelectorAll(".checkout__label");
const status = root.querySelector(".checkout__status");
const css = getComputedStyle(root);
const v = (name) => css.getPropertyValue(name).trim();
const part = (k, name) => steps[k].querySelector(name);
// 丸の輪。3 つとも「inset の輪 + 外側の広がり」の同じ形にそろえると、GSAP が影どうしを補間できる
const ring = `inset 0 0 0 ${v("--ring")}`;
const UP = `${ring} ${v("--line")}, 0 0 0 0px ${v("--halo")}`;
const NOW = `${ring} ${v("--progress")}, 0 0 0 ${v("--halo-width")} ${v("--halo")}`;
const DONE = `${ring} ${v("--progress")}, 0 0 0 0px ${v("--halo")}`;
const last = steps.length; // 「全部済んだ」の番号
let current = 0;
let move = null;
// 段階の間の動きを全部 1 本の paused のタイムラインに並べ、境目にラベルを打つ。進む・戻るはラベルまで動かすだけで、
// 戻るときは同じ動きを逆にたどる。同じ要素に 2 本あるものは、先のほうだけが最初の値を描く(immediateRender)
const tl = gsap.timeline({ paused: true });
steps.forEach((step, k) => {
const at = k * SEG;
const first = k === 0;
tl.addLabel(`step${k}`, at)
// 今の丸を墨で塗り、番号を消して、白いチェックを描く
.fromTo(part(k, ".stepper__dot"), { backgroundColor: v("--surface"), boxShadow: NOW }, { backgroundColor: v("--progress"), boxShadow: DONE, duration: 0.25, ease: "power1.out", immediateRender: first }, at)
.fromTo(part(k, ".stepper__num"), { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.12, ease: "none" }, at)
.fromTo(part(k, ".stepper__check path"), { drawSVG: "0%" }, { drawSVG: "100%", duration: 0.3, ease: "power2.out" }, at + 0.1)
// 中身: 今のものが左へ抜け、次のものが右から入る
.fromTo(panels[k], { autoAlpha: 1, x: 0 }, { autoAlpha: 0, x: -SHIFT, duration: 0.25, ease: "power2.in", immediateRender: first }, at + 0.05)
.fromTo(panels[k + 1], { autoAlpha: 0, x: SHIFT }, { autoAlpha: 1, x: 0, duration: 0.4, ease: "power3.out" }, at + 0.35);
if (k === last - 1) return;
// 次への線が左から伸び、次の丸に墨の輪と淡い輪が付いて「今ここ」になる
tl.fromTo(part(k, ".stepper__fill"), { clipPath: "inset(0% 100% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)", duration: 0.45, ease: "power2.inOut" }, at + 0.1)
.fromTo(part(k + 1, ".stepper__dot"), { boxShadow: UP }, { boxShadow: NOW, duration: 0.35, ease: "power2.out" }, at + 0.45)
.fromTo(part(k + 1, ".stepper__num"), { color: v("--mute") }, { color: v("--ink"), duration: 0.3, ease: "none" }, at + 0.45)
.fromTo(part(k + 1, ".stepper__label"), { color: v("--mute-small") }, { color: v("--ink"), duration: 0.3, ease: "none" }, at + 0.45);
});
// 最後の段階に入ったらボタンの文字を替え(前の文字が消えきってから出す)、全部済んだらボタンの列ごと消す
tl.fromTo(toNext, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.12, ease: "none" }, (last - 2) * SEG + 0.45)
.fromTo(toPlace, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.16, ease: "none" }, (last - 2) * SEG + 0.57)
.fromTo(actions, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: 12, duration: 0.3, ease: "power2.in" }, (last - 1) * SEG + 0.3)
.addLabel(`step${last}`, last * SEG);
if (reduceMotion) tl.timeScale(20); // 動きを減らす設定では、ほぼ一瞬で切り替える
function go(n) {
const target = gsap.utils.clamp(0, last, n);
if (target === current) return;
current = target;
// 途中で押し直しても、今の位置からラベルへ向かう。全部済んだら、消えるボタンの代わりに完了の見出しへ焦点を移す
move?.kill();
move = tl.tweenTo(`step${target}`, { onComplete: () => target === last && panels[last].querySelector("h2").focus() });
steps.forEach((step, i) => {
if (i === target) step.setAttribute("aria-current", "step");
else step.removeAttribute("aria-current");
step.querySelector(".stepper__done").textContent = i < target ? "(完了)" : "";
});
if (target === 0 && document.activeElement === back) next.focus(); // 押せなくなるボタンから焦点を移す
back.disabled = target === 0;
const title = panels[target].querySelector("h2").textContent;
status.textContent = target === last ? title : `ステップ${target + 1}/${last}:${title}`;
}
next.addEventListener("click", () => go(current + 1));
back.addEventListener("click", () => go(current - 1));
});