本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

ステッパーが進むたびに丸が塗られ、線が次のステップへ伸びる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 の変数で変えられます。このデモでは「次へ」と「戻る」を押して進め、最後まで進めると、少し置いてから最初の段階へ戻ります。購入の手続き、会員登録、設定のウィザードに向いています。

コード

HTML
<!-- 今の段階は 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>

CSS
.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; }
}

JavaScript
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));
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。