ステップインジケーター / ステッパー(Stepper)— クリック可能・エラー表示

表示・インジケーター 初級

このコンポーネントについて

ステッパー(stepper)は、複数のステップで構成されるフローの「現在地」を視覚的に示すUIコンポーネントです。このページでは、会員登録・申請フォームを題材に、業務アプリで求められる2つの実務機能を追加したステッパーを紹介します。1つは訪問済みのステップへステッパー自体をクリックして直接戻れる「クリックナビゲーション」、もう1つは必須項目が未入力のまま次へ進もうとした際にステップ自体へエラー表示を出し進行をブロックする「エラー状態」です。

単純な進捗表示にとどまらず、入力ミスの発見・修正までをステッパー上で完結させたい業務フォームに向いています。アンケートウィザードを題材にした display-stepper-1(非クリック型・エラー表示なし)の連番として、より実務的な設計判断を盛り込んだバリエーションです。

  • クリック可能なステップナビゲーション — 訪問済み(一度でも到達したことがある)ステップはステッパーのドットをクリックして直接移動できる。未訪問のステップはクリック不可
  • エラー状態の表示 — 必須項目が未入力のまま「次へ」を押すと、そのステップのドットが赤い×アイコンに変化し、フィールド下にエラーメッセージを表示。すべて入力すると自動的に完了状態へ切り替わる
  • 4種類のステップ状態管理 — 未訪問(pending)・現在地(current)・完了(complete)・エラー(error)をデータ側で一元管理し、ステッパーの見た目に反映する
  • 入力内容の保持 — ステップ間を行き来しても、各ステップの入力値は保持される
  • 完了後のナビゲーション無効化 — 最終ステップ(完了)に到達すると、登録済みとして扱いステッパーのクリック移動を無効化する

実装のポイント・注意点

このステッパーの核心は「見た目の状態をDOMから逆算せず、必ずJavaScript側のデータを正として持つ」設計です。訪問済みかどうかは maxReachedStep(到達した最大のステップ番号)で判定し、各ステップの完了・エラー状態は stepStates 配列で管理しています。こうすることで、エラーになったステップにクリックで戻って直しても、状態が壊れない実装になります。

また、最終ステップ(完了)に到達した後はステッパーのクリックを無効化しています。登録が完了したのに前のステップへ戻って編集できてしまうと、「もう送信済みなのに変更できるように見える」矛盾したUXになるためです。エラーメッセージ用の <p> 要素は hidden 属性の付け外しで表示制御しており、CSS側で display を明示的に指定しないことで、ブラウザ標準の非表示動作をそのまま活かしています。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

    基本情報の入力

    サンプルソース

    3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
    ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>ステップインジケーター サンプル(クリック可能・エラー表示)</title>
      <link rel="stylesheet" href="./style.css">
    </head>
    <body>
    
    <div class="stp2-root">
    
      <!-- ステップインジケーター(JSで動的生成) -->
      <ol class="stp2-indicator" id="stp2Indicator" aria-label="進捗"></ol>
    
      <!-- ステップコンテンツ -->
      <div class="stp2-body">
    
        <!-- Step 1: 基本情報 -->
        <div id="stp2Step1">
          <h2 class="stp2-step-title">基本情報の入力</h2>
          <div class="stp2-field">
            <label for="stp2Name">お名前</label>
            <input type="text" id="stp2Name" placeholder="山田 太郎">
            <p class="stp2-field-error" id="stp2ErrorName" hidden>お名前を入力してください</p>
          </div>
          <div class="stp2-field">
            <label for="stp2Email">メールアドレス</label>
            <input type="text" id="stp2Email" placeholder="[email protected]">
            <p class="stp2-field-error" id="stp2ErrorEmail" hidden>メールアドレスを入力してください</p>
          </div>
          <div class="stp2-actions">
            <button class="stp2-btn-primary" type="button" onclick="stp2GoNext()">次へ</button>
          </div>
        </div>
    
        <!-- Step 2: 詳細情報 -->
        <div id="stp2Step2" hidden>
          <h2 class="stp2-step-title">詳細情報の入力</h2>
          <div class="stp2-field">
            <label for="stp2Address">住所</label>
            <input type="text" id="stp2Address" placeholder="東京都〇〇区〇〇 1-2-3">
            <p class="stp2-field-error" id="stp2ErrorAddress" hidden>住所を入力してください</p>
          </div>
          <div class="stp2-field">
            <label for="stp2Tel">電話番号</label>
            <input type="text" id="stp2Tel" placeholder="090-1234-5678">
            <p class="stp2-field-error" id="stp2ErrorTel" hidden>電話番号を入力してください</p>
          </div>
          <div class="stp2-actions">
            <button class="stp2-btn-secondary" type="button" onclick="stp2GoPrev()">戻る</button>
            <button class="stp2-btn-primary"   type="button" onclick="stp2GoNext()">次へ</button>
          </div>
        </div>
    
        <!-- Step 3: 確認 -->
        <div id="stp2Step3" hidden>
          <h2 class="stp2-step-title">入力内容の確認</h2>
          <div class="stp2-confirm" id="stp2Confirm"></div>
          <div class="stp2-actions">
            <button class="stp2-btn-secondary" type="button" onclick="stp2GoPrev()">戻る</button>
            <button class="stp2-btn-primary"   type="button" onclick="stp2GoNext()">この内容で登録する</button>
          </div>
        </div>
    
        <!-- Step 4: 完了 -->
        <div id="stp2Step4" hidden>
          <div class="stp2-complete-icon" aria-hidden="true">✓</div>
          <p class="stp2-complete-message">登録が完了しました</p>
          <div class="stp2-actions">
            <button class="stp2-btn-secondary" type="button" onclick="stp2Reset()">最初からやり直す</button>
          </div>
        </div>
    
      </div>
    </div>
    
    <script src="./script.js"></script>
    </body>
    </html>
    /* ステップインジケーター サンプル — style.css
       色を変えたいときは :root の変数を書き換えるだけでOK */
    :root {
      --stp2-color:   #2563EB; /* 現在・完了ステップの色 */
      --stp2-muted:   #E5E7EB; /* 未訪問のドット・ライン色 */
      --stp2-text:    #9AA5B4; /* 未訪問のラベル色 */
      --stp2-error:   #DC2626; /* エラー色 */
      --stp2-success: #22C55E; /* 完了アイコン背景 */
    }
    
    *, *::before, *::after { box-sizing: border-box; }
    
    body {
      font-family: sans-serif;
      padding: 24px;
      max-width: 480px;
      margin: 0 auto;
      color: #1A2332;
    }
    
    /* ===== ステップインジケーター ===== */
    .stp2-indicator {
      display: flex;
      list-style: none;
      margin: 0 0 32px;
      padding: 0;
    }
    
    .stp2-item {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      position: relative;
    }
    
    /* ステップ間の接続ライン(::before で前のステップとつなぐ) */
    .stp2-item + .stp2-item::before {
      content: '';
      position: absolute;
      top: 13px;
      right: 50%;
      width: 100%;
      height: 2px;
      background: var(--stp2-muted);
      z-index: 0;
    }
    
    .stp2-item.is-complete + .stp2-item::before,
    .stp2-item.is-current + .stp2-item::before {
      background: var(--stp2-color);
    }
    
    /* ステップの丸(ドット) */
    .stp2-dot {
      width: 28px;
      height: 28px;
      border-radius: 50%;
      background: #fff;
      border: 2px solid var(--stp2-muted);
      color: var(--stp2-text);
      font-size: 12px;
      font-weight: 700;
      display: flex;
      align-items: center;
      justify-content: center;
      position: relative;
      z-index: 1;
      transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.15s;
    }
    
    .stp2-item.is-current .stp2-dot,
    .stp2-item.is-complete .stp2-dot {
      background: var(--stp2-color);
      border-color: var(--stp2-color);
      color: #fff;
    }
    
    .stp2-item.is-error .stp2-dot {
      background: var(--stp2-error);
      border-color: var(--stp2-error);
      color: #fff;
    }
    
    .stp2-item.is-clickable .stp2-dot { cursor: pointer; }
    .stp2-item.is-clickable:hover .stp2-dot {
      transform: scale(1.08);
      box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
    }
    .stp2-item:not(.is-clickable) .stp2-dot { cursor: not-allowed; }
    
    /* 完了後ロック状態:クリックできないことを見た目にも反映する */
    .stp2-indicator.is-locked .stp2-item .stp2-dot { cursor: default; }
    .stp2-indicator.is-locked .stp2-item.is-clickable:hover .stp2-dot {
      transform: none;
      box-shadow: none;
    }
    
    .stp2-label {
      font-size: 11px;
      color: var(--stp2-text);
      margin-top: 5px;
      text-align: center;
    }
    
    .stp2-item.is-current .stp2-label { color: var(--stp2-color); font-weight: 700; }
    .stp2-item.is-complete .stp2-label { color: #5A6A7A; }
    .stp2-item.is-error .stp2-label { color: var(--stp2-error); font-weight: 700; }
    
    /* ===== ステップコンテンツ ===== */
    .stp2-step-title {
      font-size: 17px;
      font-weight: 700;
      margin: 0 0 16px;
    }
    
    /* ===== 入力フィールド ===== */
    .stp2-field { margin-bottom: 16px; }
    
    .stp2-field label {
      display: block;
      font-size: 13px;
      font-weight: 700;
      color: #5A6A7A;
      margin-bottom: 6px;
    }
    
    .stp2-field input {
      width: 100%;
      padding: 10px 12px;
      border: 1.5px solid #E5E7EB;
      border-radius: 8px;
      font-size: 14px;
      font-family: sans-serif;
    }
    
    .stp2-field input:focus {
      outline: none;
      border-color: var(--stp2-color);
      box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
    }
    
    /* エラー時のフィールド枠 */
    .stp2-field.has-error input { border-color: var(--stp2-error); }
    
    /* エラーメッセージ(display は指定しない。hidden属性の標準動作を活かすため) */
    .stp2-field-error {
      color: var(--stp2-error);
      font-size: 12px;
      margin: 6px 0 0;
    }
    
    /* ===== 確認画面 ===== */
    .stp2-confirm {
      background: #F4F6F9;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 20px;
    }
    
    .stp2-confirm-item { margin-bottom: 14px; }
    .stp2-confirm-item:last-child { margin-bottom: 0; }
    
    .stp2-confirm-label {
      font-size: 11px;
      font-weight: 700;
      color: #5A6A7A;
      margin: 0 0 3px;
    }
    
    .stp2-confirm-value {
      font-size: 14px;
      color: #1A2332;
      margin: 0;
      word-break: break-all;
    }
    
    /* ===== ボタン ===== */
    .stp2-actions { display: flex; gap: 10px; margin-top: 8px; }
    
    .stp2-btn-primary {
      padding: 10px 24px;
      background: var(--stp2-color);
      color: #fff;
      border: none;
      border-radius: 6px;
      font-size: 14px;
      font-family: sans-serif;
      cursor: pointer;
      transition: background 0.15s;
    }
    .stp2-btn-primary:hover { background: #1D4ED8; }
    
    .stp2-btn-secondary {
      padding: 10px 24px;
      background: #fff;
      color: #5A6A7A;
      border: 1.5px solid #D0D7E0;
      border-radius: 6px;
      font-size: 14px;
      font-family: sans-serif;
      cursor: pointer;
      transition: background 0.15s;
    }
    .stp2-btn-secondary:hover { background: #F4F6F9; }
    
    /* ===== 完了画面 ===== */
    .stp2-complete-icon {
      width: 56px;
      height: 56px;
      background: var(--stp2-success);
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 0 auto 16px;
      color: #fff;
      font-size: 28px;
    }
    
    .stp2-complete-message {
      text-align: center;
      font-size: 18px;
      font-weight: 700;
      margin: 0 0 24px;
    }
    
    /* ===== スマホ対応 ===== */
    @media (max-width: 480px) {
      .stp2-label { display: none; }
      .stp2-dot { width: 24px; height: 24px; font-size: 11px; }
      .stp2-item + .stp2-item::before { top: 11px; }
      .stp2-actions { flex-wrap: wrap; }
      .stp2-btn-primary, .stp2-btn-secondary { flex: 1; min-width: 120px; }
    }
    // ステップ定義(ラベルはインジケーターに表示される)
    var STEPS = [
      { label: '基本情報' },
      { label: '詳細情報' },
      { label: '確認' },
      { label: '完了' },
    ];
    
    var currentStep    = 1;                                    // 現在表示中のステップ番号
    var maxReachedStep = 1;                                    // これまでに到達した最大のステップ番号(=訪問済み範囲)
    var stepStates     = ['pending', 'pending', 'pending', 'pending']; // 各ステップの実体状態
    var formData        = {};                                   // 入力内容の保持
    var isLocked         = false;                                 // 完了後、ステッパーのクリックを封じるフラグ
    
    // =========================================
    // ステップインジケーターを描画する
    // 状態はすべて stepStates / currentStep / maxReachedStep から算出する
    // =========================================
    function renderIndicator() {
      var list = document.getElementById('stp2Indicator');
      list.innerHTML = '';
      list.classList.toggle('is-locked', isLocked);
    
      STEPS.forEach(function(step, i) {
        var n = i + 1;
        var li = document.createElement('li');
        li.className = 'stp2-item';
    
        var visualState;
        if (n === currentStep) {
          visualState = 'current';
        } else if (stepStates[i] === 'error') {
          visualState = 'error';
        } else if (n <= maxReachedStep) {
          visualState = 'complete';
        } else {
          visualState = 'pending';
        }
        li.classList.add('is-' + visualState);
    
        // クリック可能なのは「訪問済み」かつ「現在地ではない」かつ「ロック中でない」場合のみ
        var clickable = !isLocked && n !== currentStep && n <= maxReachedStep;
        if (clickable) {
          li.classList.add('is-clickable');
          li.addEventListener('click', (function(stepNumber) {
            return function() { stp2ClickStep(stepNumber); };
          })(n));
        }
    
        var dot = document.createElement('span');
        dot.className = 'stp2-dot';
        if (visualState === 'complete') {
          dot.textContent = '✓';
        } else if (visualState === 'error') {
          dot.textContent = '×';
        } else {
          dot.textContent = String(n);
        }
    
        var label = document.createElement('span');
        label.className = 'stp2-label';
        label.textContent = step.label;
    
        li.appendChild(dot);
        li.appendChild(label);
        list.appendChild(li);
      });
    }
    
    // =========================================
    // 指定ステップだけを表示する
    // =========================================
    function showStep(n) {
      for (var i = 1; i <= STEPS.length; i++) {
        var el = document.getElementById('stp2Step' + i);
        if (el) el.hidden = (i !== n);
      }
    }
    
    // =========================================
    // フィールドのエラー表示を切り替える
    // =========================================
    function toggleFieldError(fieldId, errorId, hasError) {
      var field = document.getElementById(fieldId);
      var error = document.getElementById(errorId);
      var wrapper = field.closest('.stp2-field');
      error.hidden = !hasError;
      if (wrapper) wrapper.classList.toggle('has-error', hasError);
    }
    
    // =========================================
    // バリデーション(true = 通過)。必須項目の空欄チェックのみ行う
    // =========================================
    function validate(step) {
      if (step === 1) {
        var name  = document.getElementById('stp2Name').value.trim();
        var email = document.getElementById('stp2Email').value.trim();
        toggleFieldError('stp2Name',  'stp2ErrorName',  name === '');
        toggleFieldError('stp2Email', 'stp2ErrorEmail', email === '');
        return name !== '' && email !== '';
      }
      if (step === 2) {
        var address = document.getElementById('stp2Address').value.trim();
        var tel     = document.getElementById('stp2Tel').value.trim();
        toggleFieldError('stp2Address', 'stp2ErrorAddress', address === '');
        toggleFieldError('stp2Tel',     'stp2ErrorTel',     tel === '');
        return address !== '' && tel !== '';
      }
      return true; // Step 3(確認)・Step 4(完了)はバリデーション不要
    }
    
    // =========================================
    // 入力値を formData に保存する
    // =========================================
    function saveStepData(step) {
      if (step === 1) {
        formData.name  = document.getElementById('stp2Name').value.trim();
        formData.email = document.getElementById('stp2Email').value.trim();
      }
      if (step === 2) {
        formData.address = document.getElementById('stp2Address').value.trim();
        formData.tel      = document.getElementById('stp2Tel').value.trim();
      }
    }
    
    // =========================================
    // 次へ(Step3では「この内容で登録する」ボタンとして機能する)
    // =========================================
    function stp2GoNext() {
      if (isLocked || currentStep >= STEPS.length) return;
    
      if (!validate(currentStep)) {
        stepStates[currentStep - 1] = 'error';
        renderIndicator();
        return;
      }
    
      saveStepData(currentStep);
      stepStates[currentStep - 1] = 'complete';
    
      currentStep++;
      maxReachedStep = Math.max(maxReachedStep, currentStep);
    
      if (currentStep === 3) renderConfirm();
      if (currentStep === STEPS.length) {
        stepStates[STEPS.length - 1] = 'complete';
        isLocked = true; // 完了ステップ到達後はステッパーのクリックナビゲーションを無効化する
      }
    
      showStep(currentStep);
      renderIndicator();
    }
    
    // =========================================
    // 戻る
    // =========================================
    function stp2GoPrev() {
      if (currentStep <= 1) return;
      currentStep--;
      showStep(currentStep);
      renderIndicator();
    }
    
    // =========================================
    // ステッパーのドットをクリックしたときの移動
    // 訪問済み(maxReachedStep以下)のステップにのみ移動できる
    // =========================================
    function stp2ClickStep(n) {
      if (isLocked) return;
      if (n === currentStep) return;
      if (n > maxReachedStep) return;
    
      currentStep = n;
      if (n === 3) renderConfirm(); // 確認画面は表示のたびに最新の入力内容で再構築する
      showStep(currentStep);
      renderIndicator();
    }
    
    // =========================================
    // XSS対策: HTMLエスケープ
    // ユーザー入力を innerHTML に渡す前に必ず通す
    // =========================================
    function escapeHtml(str) {
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g,  '&lt;')
        .replace(/>/g,  '&gt;')
        .replace(/"/g,  '&quot;');
    }
    
    // =========================================
    // 確認画面(Step3)を描画する
    // =========================================
    function renderConfirm() {
      var container = document.getElementById('stp2Confirm');
      container.innerHTML =
        '<div class="stp2-confirm-item">' +
          '<p class="stp2-confirm-label">お名前</p>' +
          '<p class="stp2-confirm-value">' + escapeHtml(formData.name || '') + '</p>' +
        '</div>' +
        '<div class="stp2-confirm-item">' +
          '<p class="stp2-confirm-label">メールアドレス</p>' +
          '<p class="stp2-confirm-value">' + escapeHtml(formData.email || '') + '</p>' +
        '</div>' +
        '<div class="stp2-confirm-item">' +
          '<p class="stp2-confirm-label">住所</p>' +
          '<p class="stp2-confirm-value">' + escapeHtml(formData.address || '') + '</p>' +
        '</div>' +
        '<div class="stp2-confirm-item">' +
          '<p class="stp2-confirm-label">電話番号</p>' +
          '<p class="stp2-confirm-value">' + escapeHtml(formData.tel || '') + '</p>' +
        '</div>';
    }
    
    // =========================================
    // デモをリセットする
    // =========================================
    function resetDemo() {
      currentStep    = 1;
      maxReachedStep = 1;
      stepStates      = ['pending', 'pending', 'pending', 'pending'];
      formData         = {};
      isLocked         = false;
    
      ['stp2Name', 'stp2Email', 'stp2Address', 'stp2Tel'].forEach(function(id) {
        var el = document.getElementById(id);
        if (el) el.value = '';
      });
      document.querySelectorAll('.stp2-field-error').forEach(function(el) { el.hidden = true; });
      document.querySelectorAll('.stp2-field.has-error').forEach(function(el) { el.classList.remove('has-error'); });
    
      showStep(1);
      renderIndicator();
    }
    
    // Step4(完了)の「最初からやり直す」ボタンも resetDemo() を共用する
    function stp2Reset() {
      resetDemo();
    }
    
    // =========================================
    // 初期化
    // =========================================
    renderIndicator();
    showStep(1);

    AI用プロンプト

    以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

    ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ステップ数や入力項目の変更など、要件を追記して使うのがおすすめです。

    ※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

    💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

    # ステップインジケーター(クリック可能・エラー表示)作成依頼
    
    ## 概要
    会員登録・申請フォームを題材にした4ステップのウィザードで、
    「訪問済みステップへのクリック移動」と「必須未入力時のエラー表示・進行ブロック」に対応したステッパー(ステップインジケーター)を実装してください。
    
    ## 要件
    - 全4ステップ構成(基本情報 → 詳細情報 → 確認 → 完了)
    - 画面上部にステップインジケーターを表示(ドット+接続ライン)
    - ステップの状態は「未訪問(グレー)」「現在地(青・強調)」「完了(青・チェックマーク)」「エラー(赤・×マーク)」の4種類で管理する
    - 「次へ」ボタン押下時に必須項目が未入力なら、そのステップをエラー状態にしてエラーメッセージを表示し、次のステップへ進めない
    - 訪問済み(一度でも到達したことがある)ステップは、ステッパーのドットをクリックすると直接そのステップへ移動できる。未訪問のステップはクリックできない
    - 最終ステップ(完了)に到達したら、ステッパーのクリックナビゲーションを無効化する
    - 最終ステップの手前(確認画面)で、それまでの入力内容を一覧表示する
    - リセット(やり直す)機能で全入力・状態を初期化できる
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    【Step 1】お名前・メールアドレス(ともに必須、空欄チェックのみ)を入力。
    【Step 2】住所・電話番号(ともに必須)を入力。
    【Step 3】Step1・Step2の入力内容を一覧確認し、「この内容で登録する」で次へ。
    【Step 4】完了メッセージを表示。到達後はステッパーのクリック移動を無効化する。
    ステップの状態はDOMではなくJavaScript側のデータ(配列やオブジェクト)で一元管理し、描画はそのデータを読んで行うこと。
    確認画面に入力内容を表示する際は、XSS対策として必ず escapeHtml() 相当のサニタイズ処理を通すこと。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。