本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

入力フォームが 3 つのステップに分かれ、次へで横に滑って進む

デモ

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

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

3つの段を横に並べて1本の帯にし、「次へ」で帯ごと1段ぶん左へ滑らせます。戻るときは同じ帯を右へ戻すだけなので、進む動きと戻る動きを別々に書かずに済みます。進捗の点だけを動かすのではなく、入力欄そのものが送られるので、どちらへ動いたかが体で分かります。

帯は0.5秒の power3.inOut で滑らせます。入ってきた段の欄は、滑り始めから0.12秒遅れて、14px下から0.07秒ずつずれて持ち上がります。点をつなぐ線の塗りも帯と同じ時間で伸ばすので、画面の中の動きが1つにまとまって見えます。済んだ段の点は、番号を薄くしながらチェックを0.3秒で描きます。

段を並べた入れ物には overflow: clip を付けます。付けないと、横で控えている段のぶんだけページが横に伸びます。hidden にすると、Tabで画面の外の欄へ焦点が移ったときに、ブラウザが入れ物ごと横にスクロールして段の位置がずれます。読み上げ用に隠した見出しも、位置を持つ段の中に置かないと入れ物に切られず、同じように横へ伸びます。

見えていない段は inert にして、Tabが外の欄へ入らないようにします。段が移ったら最初の欄へ焦点を送り、見えない role="status" の領域に「ステップ2/3」と書いて読み上げます。「次へ」を押した時点で、その段の欄だけをブラウザに確かめさせるので、最後まで進んでから前の段へ戻される、ということが起きません。

つまみは、滑る時間と欄のずれの幅です。ずれを広げると1つずつ置かれていく感じになり、詰めると面ごと入れ替わって見えます。動きを減らす設定では、滑りも点の切り替えもほぼ一瞬で済ませます。申し込み、会員登録、注文や予約の手続きなど、聞くことが多くて1画面に収まらないフォームに向いています。

コード

HTML
<!-- 3 段に分けた申し込みフォーム。段は横に並べ、帯ごと滑らせて入れ替える -->
<form class="wizard" action="/apply/" method="post">
  <h2 class="wizard__title">参加のお申し込み</h2>
  <ol class="wizard__steps">
    <li class="wizard__step" data-state="current" aria-current="step">
      <span class="wizard__dot">
        <span class="wizard__num">1</span>
        <svg class="wizard__check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 8.4 6.4 11.6 12.8 4.6" pathLength="1" /></svg>
      </span>
      <span class="wizard__name">お客様情報</span>
    </li>
    <li class="wizard__step" data-state="todo">
      <span class="wizard__dot">
        <span class="wizard__num">2</span>
        <svg class="wizard__check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 8.4 6.4 11.6 12.8 4.6" pathLength="1" /></svg>
      </span>
      <span class="wizard__name">ご住所</span>
    </li>
    <li class="wizard__step" data-state="todo">
      <span class="wizard__dot">
        <span class="wizard__num">3</span>
        <svg class="wizard__check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 8.4 6.4 11.6 12.8 4.6" pathLength="1" /></svg>
      </span>
      <span class="wizard__name">ご確認</span>
    </li>
  </ol>

  <!-- はみ出しは clip で隠す(hidden だと、焦点が外の段へ移ったとき枠ごと横にずれる) -->
  <div class="wizard__viewport">
    <div class="wizard__track">
      <fieldset class="wizard__panel">
        <legend class="wizard__legend">お客様情報</legend>
        <p class="field rise"><label for="wz-name">お名前</label><input id="wz-name" name="name" autocomplete="name" required></p>
        <p class="field rise"><label for="wz-mail">メールアドレス</label><input id="wz-mail" name="email" type="email" autocomplete="email" pattern="[^@\s]+@[^@\s]+\.[^@\s]+" required></p>
      </fieldset>
      <fieldset class="wizard__panel" inert>
        <legend class="wizard__legend">ご住所</legend>
        <p class="field rise"><label for="wz-zip">郵便番号</label><input id="wz-zip" name="zip" inputmode="numeric" autocomplete="postal-code" required></p>
        <p class="field rise"><label for="wz-addr">市区町村・番地</label><input id="wz-addr" name="address" autocomplete="street-address" required></p>
      </fieldset>
      <div class="wizard__panel" inert>
        <h3 class="wizard__legend">ご確認</h3>
        <dl class="review">
          <div class="review__row rise"><dt>お名前</dt><dd data-from="wz-name"></dd></div>
          <div class="review__row rise"><dt>メールアドレス</dt><dd data-from="wz-mail"></dd></div>
          <div class="review__row rise"><dt>ご住所</dt><dd data-from="wz-addr"></dd></div>
        </dl>
      </div>
      <div class="wizard__panel wizard__done" inert>
        <span class="wizard__mark rise"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 8.4 6.4 11.6 12.8 4.6" pathLength="1" /></svg></span>
        <h3 class="rise" tabindex="-1">お申し込みが完了しました</h3>
        <p class="rise">確認のメールをお送りしました。</p>
      </div>
    </div>
  </div>

  <p class="wizard__foot">
    <button class="wizard__back" type="button" disabled>戻る</button>
    <button class="wizard__next" type="button" data-first="次へ" data-last="申し込む"><span class="wizard__label">次へ</span></button>
  </p>
  <p class="wizard__status" role="status"></p>
</form>

CSS
/* 3 段のフォーム。点と線の進み具合は CSS の遷移、段の入れ替えは JS が受け持つ */
.wizard {
  --ink: #262626;                 /* 文字と塗り */
  --paper: #ffffff;               /* カードと欄の地 */
  --mute: #6b6b6b;                /* 欄の名前など 16px 未満の補足 */
  --line: rgba(38, 38, 38, 0.12); /* 罫線 */
  --green: #4f8a64;               /* 完了の印 */
  --fill: 0;                      /* 点をつなぐ線の塗り(0〜1。JS が動かす) */
  box-sizing: border-box;
  max-width: 544px;
  margin: 0 auto;
  padding: 40px;
  border-radius: 4px;
  background: var(--paper);
  color: var(--ink);
}

.wizard__title { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.01em; }

.wizard__steps { position: relative; display: flex; justify-content: space-between; margin: 32px 0 0; padding: 0; list-style: none; }
.wizard__steps::before, .wizard__steps::after { content: ""; position: absolute; top: 13px; left: 14px; right: 14px; height: 2px; }
.wizard__steps::before { background: var(--line); }
/* 塗りは clip-path で伸ばす(scaleX だと線の端までつぶれる) */
.wizard__steps::after { background: var(--ink); clip-path: inset(0 calc(100% - var(--fill) * 100%) 0 0); }

.wizard__step { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.wizard__step:first-child { align-items: flex-start; }
.wizard__step:last-child { align-items: flex-end; }

.wizard__dot {
  position: relative;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--paper);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--mute);
  font: inherit; font-weight: 700; font-size: 13px; line-height: 1;
  transition: background-color 0.25s, box-shadow 0.25s, color 0.25s;
}

.wizard__step[data-state="current"] .wizard__dot,
.wizard__step[data-state="done"] .wizard__dot { background: var(--ink); box-shadow: none; color: var(--paper); }
.wizard__num, .wizard__check { grid-area: 1 / 1; transition: opacity 0.2s; }
.wizard__step[data-state="done"] .wizard__num { opacity: 0; }
.wizard__check { width: 16px; height: 16px; }
/* 線の長さを 1 にしておき、隠す量(dashoffset)を 1 → 0 と縮めて描く */
.wizard__check path, .wizard__mark path {
  fill: none; stroke: var(--paper); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
  stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s;
}
.wizard__step[data-state="done"] .wizard__check path { stroke-dashoffset: 0; }

.wizard__name { color: var(--mute); font-size: 12px; font-weight: 700; letter-spacing: 0.06em; white-space: nowrap; transition: color 0.3s; }
.wizard__step[data-state="current"] .wizard__name, .wizard__step[data-state="done"] .wizard__name { color: var(--ink); }

/* はみ出しは clip(hidden だと、外の段へ焦点が移ったとき枠が横にスクロールする) */
.wizard__viewport { overflow: clip; margin-top: 32px; }
.wizard__track { display: flex; }
.wizard__panel { position: relative; flex: 0 0 100%; min-width: 0; margin: 0; padding: 0; border: 0; background: var(--paper); }
/* 読み上げ用の見出し。位置を持つ段の中に置く(外に出ると枠で切られず、ページが横に伸びる) */
.wizard__legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

.field { margin: 0 0 24px; }
.field label { display: block; margin-bottom: 8px; color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.field input {
  box-sizing: border-box; width: 100%; height: 56px; padding: 0 16px;
  border: 1px solid var(--line); border-radius: 4px; background: var(--paper);
  color: inherit; font: inherit; font-size: 16px;
}
.field input:focus-visible { border-color: var(--ink); outline: 1px solid var(--ink); outline-offset: 0; }

.review { margin: 0; }
.review__row { display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 56px; border-bottom: 1px solid var(--line); }
.review__row dt { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.review__row dd { margin: 0; font-size: 15px; font-weight: 500; }

.wizard__done { display: grid; place-content: center; justify-items: center; padding: 32px 24px; border-radius: 4px; background: var(--ink); color: #eeece6; text-align: center; }
.wizard__mark { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 24px; border-radius: 50%; background: var(--green); }
.wizard__mark svg { width: 28px; height: 28px; }
.wizard__mark path { stroke: #ffffff; transition-delay: 0.3s; }
.wizard[data-done] .wizard__mark path { stroke-dashoffset: 0; }
.wizard__done h3 { margin: 0; font-size: 20px; font-weight: 700; }
.wizard__done p { margin: 12px 0 0; color: #a8a6a0; font-size: 14px; }

.wizard__foot { display: flex; justify-content: space-between; gap: 16px; margin: 32px 0 0; }
.wizard__back, .wizard__next { height: 48px; padding: 0 32px; border: 0; border-radius: 999px; font: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }
.wizard__back { background: none; box-shadow: inset 0 0 0 1px currentColor; color: var(--ink); }
.wizard__back[disabled] { color: #c8c7c3; cursor: default; }
.wizard__next { min-width: 144px; overflow: hidden; background: var(--ink); color: #ffffff; }
.wizard__label { display: block; }
.wizard :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.wizard__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

/* 動きを減らす設定では、色や線の切り替えもほぼ一瞬にする */
@media (prefers-reduced-motion: reduce) { .wizard * { transition-duration: 0.01s !important; } }
@media (max-width: 480px) { .wizard { padding: 24px 16px; } .wizard__name { font-size: 11px; letter-spacing: 0.04em; } .wizard__back, .wizard__next { padding: 0 20px; } }

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、ほぼ一瞬で切り替える
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const T = reduce ? 0.02 : 1;

document.querySelectorAll(".wizard").forEach((wizard) => {
  const track = wizard.querySelector(".wizard__track");
  const panels = [...wizard.querySelectorAll(".wizard__panel")];
  const steps = [...wizard.querySelectorAll(".wizard__step")];
  const next = wizard.querySelector(".wizard__next");
  const label = wizard.querySelector(".wizard__label");
  const back = wizard.querySelector(".wizard__back");
  const status = wizard.querySelector(".wizard__status");
  const last = steps.length - 1;
  let step = 0;

  // ボタンの文字は、上へ抜いてから下から入れる(同じ場所で重ねると、途中で 2 つの文が重なって読めない)
  const swap = (text) => {
    if (label.textContent === text) return;
    gsap
      .timeline()
      .to(label, { yPercent: -120, duration: 0.16 * T, ease: "power2.in" })
      .add(() => (label.textContent = text))
      .fromTo(label, { yPercent: 120 }, { yPercent: 0, duration: 0.16 * T, ease: "power2.out" });
  };

  const go = (to) => {
    step = to;
    gsap.to(track, { xPercent: -100 * to, duration: 0.5 * T, ease: "power3.inOut" });
    gsap.to(wizard, { "--fill": Math.min(to, last) / last, duration: 0.5 * T, ease: "power3.inOut" });
    // 入ってきた段の中身は、下から順に持ち上げる
    gsap.fromTo(
      panels[to].querySelectorAll(".rise"),
      { y: 14 },
      { y: 0, duration: 0.5 * T, stagger: 0.07 * T, delay: 0.12 * T, ease: "power3.out" }
    );
    steps.forEach((li, i) => {
      li.dataset.state = i < to ? "done" : i === to ? "current" : "todo";
      li.setAttribute("aria-current", i === to ? "step" : "false");
    });
    // 見えていない段は inert にして、Tab で外の欄へ移れないようにする
    panels.forEach((panel, i) => panel.toggleAttribute("inert", i !== to));
    back.disabled = to === 0;
    next.hidden = to > last;
    back.hidden = to > last;
    wizard.toggleAttribute("data-done", to > last);
    swap(to === last ? next.dataset.last : next.dataset.first);
    // 確認の段には、打ち込んだ値を並べる
    panels[to].querySelectorAll("[data-from]").forEach((cell) => {
      cell.textContent = wizard.querySelector(`#${cell.dataset.from}`).value;
    });
    const name = steps[to] ? steps[to].querySelector(".wizard__name").textContent : "";
    status.textContent = to > last ? "お申し込みが完了しました" : `ステップ${to + 1}/${steps.length}:${name}`;
    panels[to].querySelector("input, h3")?.focus({ preventScroll: true });
  };

  next.addEventListener("click", () => {
    // その段の欄がそろっているかを、ブラウザの検証に見てもらう
    const bad = [...panels[step].querySelectorAll("input")].find((input) => !input.reportValidity());
    if (bad) return;
    if (step < last) go(step + 1);
    else wizard.requestSubmit();
  });
  back.addEventListener("click", () => step > 0 && go(step - 1));

  // 実際は、ここで fetch して、返事が返ってきてから完了の段へ送る
  wizard.addEventListener("submit", (event) => {
    event.preventDefault();
    go(last + 1);
  });
});

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

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