テクニック

フォーム確認画面の実装方法|送信前チェックの正しい作り方とコード例

この記事の対象: 問い合わせフォームを実装する制作者・フロントエンド担当
読了時間: 約12分

「確認画面って要りますか」とクライアントに聞かれたとき、反射的に「付けましょう」と答えていないでしょうか。確認画面は1ステップ増えるぶん、そこで離脱する人が必ず出ます。それでも付けたほうがいい条件はあり、逆に付けないほうが完了率の上がるフォームもあります。この記事では判断の線引きを先に置いたうえで、確認画面ありの実装と、確認画面なしで送信前チェックを成立させる実装の両方を、素のHTML/CSS/JavaScriptで書きます。フレームワークもライブラリも使いません。


前提とする環境

この記事のコードは、次の環境で動くように書いています。

項目 内容
対象 素のHTML/CSS/JavaScriptで組んだフォーム、WordPressのテーマ内に直書きしたフォーム、Shopifyのテーマ(Online Store 2.0)に置いたフォーム
必要な権限 テーマファイル(またはHTMLファイル)を編集できること。WordPressなら子テーマの編集権限、Shopifyならテーマコードの編集権限
外部ライブラリ なし。jQueryもバリデーションライブラリも使いません
対応ブラウザ Chrome / Edge / Safari / Firefox の現行版。使っているAPIは後述のとおりで、IEは対象外

使っているブラウザAPIは以下の3つだけです。どれも主要ブラウザで長く実装されているものですが、対応状況が気になる場合は各項目を MDN か caniuse で確認してください。

  • <dialog> 要素と showModal() — 確認画面をモーダルで出す方式で使用
  • FormData — 入力値をまとめて取り出すために使用
  • :user-invalid 疑似クラス — 入力途中にエラーを出さないために使用

:user-invalid は後述するフォールバックを入れてあるので、未対応ブラウザでもフォーム自体は壊れません。対応バージョンは変わっていくものなので、実装時に必ず一次情報(MDN・caniuse)を確認してください。


確認画面を付けるかどうかの判断

確認画面が奪うもの

確認画面を挟むと、ユーザーの動作は「入力 → 送信」から「入力 → 確認画面へ進む → 内容を読む → 送信」に変わります。増えるのはクリック1回だけではありません。画面が切り替わり、読む対象が変わり、「まだ終わっていない」という認識が一度発生します。ここで手が止まる人が出ます。

とくにスマートフォンでは影響が出やすい構造です。確認画面へ遷移したあとにキーボードが閉じ、戻るときに入力値の復元に失敗すると、そのユーザーは離脱しやすくなります。旧来のPHPフォームで「戻る」を押したら空になる、というのはいまだに現役の不具合です。

それでも確認画面が要る条件

次のどれかに当てはまるなら、確認画面を付ける判断は妥当です。

  • 訂正が事実上できない項目がある — 見積依頼の数量、予約の日時、申込内容のように、送信後の訂正が相手の作業をやり直させるもの
  • 入力項目が10を超える — スクロールしないと全体が見えないフォームは、送信直前に俯瞰させる価値がある
  • 入力ミスの影響が大きい項目がある — 返信先メールアドレス、電話番号、住所。ここを間違えると連絡そのものが届かない
  • BtoBで、送信者が社内の誰かに内容を説明する必要がある — 確認画面をスクリーンショットで残す使い方が発生する

逆に、名前・メールアドレス・本文の3項目しかない問い合わせフォームに確認画面を挟むのは、得るものより失うもののほうが大きくなります。

確認画面を付けるかの分岐

入力欄の下で確認させる(推奨)

  • 項目が5つ以下
  • 訂正が容易な内容
  • ページ遷移が発生しない
  • 入力値の復元問題が起きない

確認画面を挟む

  • 項目が10を超える
  • 数量・日時など訂正が効かない
  • 送信者が内容を控える必要がある

多くの問い合わせフォームは左側に該当します。まずは左側の実装から書きます。


確認画面なしで送信前に確認させる実装

ページ遷移せず、送信ボタンの手前に入力内容のサマリーを出す方式です。入力欄はそのまま残っているので、間違いに気づいたらすぐ上に戻って直せます。「戻ると消える」問題が構造的に発生しません。

HTML

フォーム全体を次の形で書きます。index.html でも、WordPressの page-contact.php でも、Shopifyの sections/contact-form.liquid でも、置く場所は同じです。

<form id="cf-form" class="cf-form" action="/contact/send" method="post" novalidate>
  <div class="cf-field">
    <label class="cf-label" for="cf-name">
      お名前<span class="cf-required">必須</span>
    </label>
    <input class="cf-input" type="text" id="cf-name" name="name"
           required autocomplete="name" data-label="お名前">
    <p class="cf-error" data-error-for="cf-name"></p>
  </div>

  <div class="cf-field">
    <label class="cf-label" for="cf-email">
      メールアドレス<span class="cf-required">必須</span>
    </label>
    <input class="cf-input" type="email" id="cf-email" name="email"
           required autocomplete="email" data-label="メールアドレス">
    <p class="cf-error" data-error-for="cf-email"></p>
  </div>

  <div class="cf-field">
    <label class="cf-label" for="cf-tel">お電話番号</label>
    <input class="cf-input" type="tel" id="cf-tel" name="tel"
           autocomplete="tel" inputmode="tel" data-label="お電話番号">
    <p class="cf-error" data-error-for="cf-tel"></p>
  </div>

  <div class="cf-field">
    <label class="cf-label" for="cf-body">
      お問い合わせ内容<span class="cf-required">必須</span>
    </label>
    <textarea class="cf-input" id="cf-body" name="body" rows="7"
              required data-label="お問い合わせ内容"></textarea>
    <p class="cf-error" data-error-for="cf-body"></p>
  </div>

  <div class="cf-field">
    <label class="cf-check">
      <input type="checkbox" id="cf-agree" name="agree" required data-label="個人情報の取り扱い">
      <span><a href="/privacy/" target="_blank" rel="noopener">個人情報の取り扱い</a>に同意します</span>
    </label>
    <p class="cf-error" data-error-for="cf-agree"></p>
  </div>

  <div class="cf-summary" id="cf-summary" hidden>
    <p class="cf-summary-head">この内容で送信します</p>
    <dl class="cf-summary-list" id="cf-summary-list"></dl>
    <p class="cf-summary-note">内容を直すときは、上の入力欄をそのまま編集してください。</p>
  </div>

  <div class="cf-actions">
    <button type="submit" class="cf-submit" id="cf-submit">入力内容を確認する</button>
  </div>
</form>

変更すべき箇所

  • action="/contact/send" — 実際の送信先に差し替えます。WordPressのContact Form 7やShopifyの {{ 'contact' | form }} を使う場合は、それぞれの仕様に従った属性に置き換えてください
  • cf- のプレフィックス — 既存のCSSと衝突するなら別の接頭辞に一括置換します。JS側も同じ文字列を使っているので、置換漏れがないか確認してください
  • /privacy/ — プライバシーポリシーのURL
  • 項目そのもの — 増減させる場合、data-label 属性を必ず付けてください。サマリーの見出しにこの値を使います

novalidate を付けているのは、ブラウザ標準のエラー表示(吹き出し)を止めて自前のメッセージに置き換えるためです。これを外すと、標準の吹き出しと自前のエラー文が二重に出ます。

CSS

style.css(WordPressなら子テーマの style.css、Shopifyなら assets/ 配下のCSS)に追記します。

.cf-form {
  max-width: 44rem;
  margin-inline: auto;
}

.cf-field {
  margin-block-end: 1.75rem;
}

.cf-label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-block-end: 0.5rem;
  font-weight: 700;
  font-size: 0.95rem;
}

.cf-required {
  padding: 0.1em 0.5em;
  border-radius: 2px;
  background: #c0392b;
  color: #fff;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.cf-input {
  width: 100%;
  padding: 0.85rem 0.9rem;
  border: 1px solid #c9ccd1;
  border-radius: 4px;
  background: #fff;
  font-size: 1rem;
  line-height: 1.7;
  font-family: inherit;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.cf-input:focus-visible {
  outline: none;
  border-color: #1b6ac9;
  box-shadow: 0 0 0 3px rgba(27, 106, 201, 0.18);
}

/* 入力途中では赤くしない。一度触って離れてから判定される */
.cf-input:user-invalid {
  border-color: #c0392b;
  background: #fdf6f5;
}

/* JS側が付けるクラス。:user-invalid 未対応ブラウザの受け皿 */
.cf-input.is-invalid {
  border-color: #c0392b;
  background: #fdf6f5;
}

.cf-error {
  margin-block-start: 0.4rem;
  color: #c0392b;
  font-size: 0.85rem;
  line-height: 1.6;
  min-height: 1.4em;
}

.cf-check {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  line-height: 1.7;
  cursor: pointer;
}

.cf-check input {
  inline-size: 1.15rem;
  block-size: 1.15rem;
  margin-block-start: 0.25rem;
  flex-shrink: 0;
}

/* 送信前サマリー */
.cf-summary {
  margin-block: 2rem;
  padding: 1.5rem 1.6rem;
  border: 2px solid #1b6ac9;
  border-radius: 6px;
  background: #f5f9fe;
}

.cf-summary-head {
  margin: 0 0 1rem;
  font-weight: 700;
  font-size: 1.05rem;
}

.cf-summary-list {
  margin: 0;
  display: grid;
  grid-template-columns: minmax(7rem, 11rem) 1fr;
  gap: 0.75rem 1.25rem;
}

.cf-summary-list dt {
  font-weight: 700;
  font-size: 0.9rem;
  color: #46515e;
}

.cf-summary-list dd {
  margin: 0;
  line-height: 1.75;
  white-space: pre-wrap;
  word-break: break-word;
}

.cf-summary-note {
  margin: 1.25rem 0 0;
  font-size: 0.85rem;
  color: #46515e;
}

.cf-submit {
  display: block;
  inline-size: 100%;
  max-inline-size: 22rem;
  margin-inline: auto;
  padding: 1rem 2rem;
  border: none;
  border-radius: 4px;
  background: #1b6ac9;
  color: #fff;
  font-size: 1.05rem;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.cf-submit:hover { background: #155399; }
.cf-submit[disabled] { background: #9aa4b0; cursor: not-allowed; }

@media (max-width: 640px) {
  .cf-summary-list {
    grid-template-columns: 1fr;
    gap: 0.25rem 0;
  }
  .cf-summary-list dd {
    margin-block-end: 0.85rem;
  }
}

変更すべき箇所

  • #1b6ac9(青) #c0392b(赤) — サイトのカラーに合わせて置換します。エラー色は赤系を維持したほうが伝わりますが、ブランドカラーが赤系のサイトでは区別できなくなるので、その場合はエラー側をオレンジ寄りにずらします
  • max-width: 44rem — フォームの横幅。1カラムの記事幅に合わせるなら、本文と同じ値にします
  • grid-template-columns の minmax(7rem, 11rem) — 項目名が長い場合に広げます

:user-invalid を使っているのが肝心なところです。:invalid だと、ページを開いた瞬間から未入力の必須項目がすべて赤くなります。まだ何も触っていないのに叱られる状態で、これを避けるためだけに JS で細かくクラスを付け外しする実装をよく見かけますが、CSSだけで済みます。

JavaScript

form.js として保存し、</body> の直前で読み込みます。

<script src="/assets/js/form.js" defer></script>
(function () {
  'use strict';

  var form = document.getElementById('cf-form');
  if (!form) return;

  var summary = document.getElementById('cf-summary');
  var summaryList = document.getElementById('cf-summary-list');
  var submitBtn = document.getElementById('cf-submit');

  // 確認済みかどうか。false のあいだは送信せずサマリーを出す
  var confirmed = false;

  // ---- エラーメッセージの定義 ----------------------------------
  // キーは input の id。項目を増やしたらここにも足す
  var MESSAGES = {
    'cf-name':  { valueMissing: 'お名前を入力してください。' },
    'cf-email': {
      valueMissing: 'メールアドレスを入力してください。',
      typeMismatch: 'メールアドレスの形式が正しくありません。'
    },
    'cf-tel':   { patternMismatch: '電話番号は半角数字とハイフンで入力してください。' },
    'cf-body':  { valueMissing: 'お問い合わせ内容を入力してください。' },
    'cf-agree': { valueMissing: '個人情報の取り扱いへの同意が必要です。' }
  };

  function messageFor(field) {
    var table = MESSAGES[field.id] || {};
    var v = field.validity;
    if (v.valueMissing)    return table.valueMissing    || '入力してください。';
    if (v.typeMismatch)    return table.typeMismatch    || '形式が正しくありません。';
    if (v.patternMismatch) return table.patternMismatch || '形式が正しくありません。';
    if (v.tooShort)        return '入力が短すぎます。';
    if (v.tooLong)         return '入力が長すぎます。';
    return '入力内容を確認してください。';
  }

  // ---- エラー表示の出し入れ ------------------------------------
  function errorSlot(field) {
    return form.querySelector('[data-error-for="' + field.id + '"]');
  }

  function showError(field) {
    var slot = errorSlot(field);
    if (slot) slot.textContent = messageFor(field);
    field.classList.add('is-invalid');
    field.setAttribute('aria-invalid', 'true');
  }

  function clearError(field) {
    var slot = errorSlot(field);
    if (slot) slot.textContent = '';
    field.classList.remove('is-invalid');
    field.removeAttribute('aria-invalid');
  }

  // ---- 入力欄を離れたときの検査 --------------------------------
  var fields = form.querySelectorAll('input, textarea, select');

  Array.prototype.forEach.call(fields, function (field) {
    field.addEventListener('blur', function () {
      if (field.checkValidity()) clearError(field);
      else showError(field);
    });

    // 直したらその場でエラーを消す。赤いまま放置しない
    field.addEventListener('input', function () {
      if (field.checkValidity()) clearError(field);
    });

    // 内容が変わったら確認済みを取り消す
    field.addEventListener('input', resetConfirmed);
    field.addEventListener('change', resetConfirmed);
  });

  function resetConfirmed() {
    if (!confirmed) return;
    confirmed = false;
    summary.hidden = true;
    submitBtn.textContent = '入力内容を確認する';
  }

  // ---- サマリーの組み立て --------------------------------------
  function buildSummary() {
    summaryList.textContent = '';

    Array.prototype.forEach.call(fields, function (field) {
      var label = field.getAttribute('data-label');
      if (!label) return;

      var value;
      if (field.type === 'checkbox') {
        value = field.checked ? '同意する' : '同意しない';
      } else {
        value = field.value.trim();
      }
      if (value === '') value = '(未入力)';

      var dt = document.createElement('dt');
      dt.textContent = label;
      var dd = document.createElement('dd');
      dd.textContent = value; // textContent なので HTML は解釈されない

      summaryList.appendChild(dt);
      summaryList.appendChild(dd);
    });
  }

  // ---- 送信 ----------------------------------------------------
  form.addEventListener('submit', function (e) {
    // 1回目のクリック:検査してサマリーを出すだけ。送信しない
    if (!confirmed) {
      e.preventDefault();

      var firstInvalid = null;
      Array.prototype.forEach.call(fields, function (field) {
        if (field.checkValidity()) {
          clearError(field);
        } else {
          showError(field);
          if (!firstInvalid) firstInvalid = field;
        }
      });

      if (firstInvalid) {
        firstInvalid.focus();
        firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
        return;
      }

      buildSummary();
      summary.hidden = false;
      confirmed = true;
      submitBtn.textContent = 'この内容で送信する';
      summary.scrollIntoView({ behavior: 'smooth', block: 'center' });
      return;
    }

    // 2回目のクリック:そのまま送信。二重送信だけ止める
    submitBtn.disabled = true;
    submitBtn.textContent = '送信中...';
  });
})();

変更すべき箇所

  • MESSAGES — 項目を増やしたら、その id をキーにして文言を足します。キーが無い項目は汎用の文言にフォールバックするので、忘れても壊れはしません
  • '入力内容を確認する' 'この内容で送信する' — ボタンの文言。HTML側の初期値と一致させてください
  • scrollIntoView の behavior: 'smooth' — 動きを止めたい場合は 'auto' に変えます

この実装の要点は、submit イベントを2段階で扱っているところです。1回目は preventDefault() でサマリーを出すだけ、2回目でそのまま送信に通します。JavaScriptがエラーで止まった場合でも、type="submit" のボタンは素直に送信されるので、フォームが完全に使えなくなる事故は起きません。

もうひとつ、値を埋めるのに textContent を使っている点も重要です。innerHTML で組み立てると、入力欄に <script> を書かれたときにそのまま実行されます。確認画面の実装でXSSを作り込む典型パターンなので、ここは必ず textContent にしてください。


確認画面をモーダルで出す実装

項目数が多く、どうしても一度画面を切り替えて見せたい場合は、<dialog> 要素を使います。ページ遷移が発生しないので、戻ったときに値が消える問題は起きません。

HTMLの追加分

先ほどのフォームの直後、</form> の外側に置きます。

<dialog class="cf-dialog" id="cf-dialog">
  <div class="cf-dialog-inner">
    <h2 class="cf-dialog-title">入力内容の確認</h2>
    <dl class="cf-summary-list" id="cf-dialog-list"></dl>
    <div class="cf-dialog-actions">
      <button type="button" class="cf-dialog-back" id="cf-dialog-back">修正する</button>
      <button type="button" class="cf-submit" id="cf-dialog-send">この内容で送信する</button>
    </div>
  </div>
</dialog>

CSSの追加分

.cf-dialog {
  max-inline-size: min(40rem, calc(100vw - 2rem));
  inline-size: 100%;
  padding: 0;
  border: none;
  border-radius: 8px;
  box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.25);
}

.cf-dialog::backdrop {
  background: rgba(20, 24, 30, 0.55);
}

.cf-dialog-inner {
  padding: 2rem 1.75rem;
  max-block-size: 80vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.cf-dialog-title {
  margin: 0 0 1.5rem;
  font-size: 1.25rem;
}

.cf-dialog-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-block-start: 2rem;
}

.cf-dialog-actions button {
  flex: 1 1 12rem;
  margin-inline: 0;
  max-inline-size: none;
}

.cf-dialog-back {
  padding: 1rem 2rem;
  border: 1px solid #c9ccd1;
  border-radius: 4px;
  background: #fff;
  font-size: 1rem;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}

max-block-size: 80vh と overflow-y: auto を内側の要素に付けているのは、項目が多いときにモーダルが画面からはみ出して送信ボタンに手が届かなくなるのを防ぐためです。項目が10を超えるフォームで確認画面を出す以上、ここは必ず起きます。

JavaScriptの差し替え分

先ほどのスクリプトのうち、submit ハンドラとサマリー部分を次に置き換えます。他の関数はそのまま使えます。

  var dialog = document.getElementById('cf-dialog');
  var dialogList = document.getElementById('cf-dialog-list');
  var dialogBack = document.getElementById('cf-dialog-back');
  var dialogSend = document.getElementById('cf-dialog-send');

  function buildDialogSummary() {
    dialogList.textContent = '';
    var data = new FormData(form);

    Array.prototype.forEach.call(fields, function (field) {
      var label = field.getAttribute('data-label');
      if (!label) return;

      var value;
      if (field.type === 'checkbox') {
        value = field.checked ? '同意する' : '同意しない';
      } else {
        value = (data.get(field.name) || '').toString().trim();
      }
      if (value === '') value = '(未入力)';

      var dt = document.createElement('dt');
      dt.textContent = label;
      var dd = document.createElement('dd');
      dd.textContent = value;

      dialogList.appendChild(dt);
      dialogList.appendChild(dd);
    });
  }

  form.addEventListener('submit', function (e) {
    if (confirmed) {
      submitBtn.disabled = true;
      return;
    }
    e.preventDefault();

    var firstInvalid = null;
    Array.prototype.forEach.call(fields, function (field) {
      if (field.checkValidity()) {
        clearError(field);
      } else {
        showError(field);
        if (!firstInvalid) firstInvalid = field;
      }
    });

    if (firstInvalid) {
      firstInvalid.focus();
      firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
      return;
    }

    buildDialogSummary();
    dialog.showModal();
  });

  dialogBack.addEventListener('click', function () {
    dialog.close();
  });

  dialogSend.addEventListener('click', function () {
    confirmed = true;
    dialog.close();
    dialogSend.disabled = true;
    form.submit(); // submit イベントは発火しないので確認は再走しない
  });

ここでの注意点が2つあります。

form.submit() はJavaScriptから直接呼ぶメソッドで、submit イベントを発火させません。つまり確認処理を通らずにそのまま送信されます。上のコードで confirmed = true を先に立てているのは保険で、実際には form.submit() の時点で確認ロジックは迂回されています。逆に言うと、submit イベントに別の処理(計測タグの送信など)をぶら下げている場合、それも発火しません。計測を入れるなら dialogSend のクリックハンドラ内で直接呼んでください。

もうひとつ、<dialog> は showModal() で開いたときに Esc キーで閉じられます。これは標準の挙動で、止める必要はありません。閉じてもフォームの値は残っているので、ユーザーは修正して再度確認できます。

モーダル方式の送信までの流れ
入力blurごとに項目単位で検査する
確認ボタン全項目を検査しモーダルを開く
確認修正するで戻れる。値は残る
送信form.submit で確認を迂回して送る

「修正する」で戻ったときに値が保持されるのが、ページ遷移型との決定的な違いです。


サーバー側の検査は省けない

サーバー側の検査は省けない

ここまでの実装はすべてブラウザ上の話です。JavaScriptを切られた状態でも、開発者ツールから required 属性を消された状態でも、フォームは送信されます。受け取る側で同じ検査をもう一度やってください。

PHPで受ける場合の最小構成です。contact-send.php として設置します。

<?php
declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Method Not Allowed');
}

$errors = [];

$name  = trim((string)($_POST['name']  ?? ''));
$email = trim((string)($_POST['email'] ?? ''));
$tel   = trim((string)($_POST['tel']   ?? ''));
$body  = trim((string)($_POST['body']  ?? ''));
$agree = isset($_POST['agree']);

if ($name === '')  { $errors[] = 'お名前が入力されていません。'; }
if ($email === '') {
    $errors[] = 'メールアドレスが入力されていません。';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = 'メールアドレスの形式が正しくありません。';
}
if ($body === '')  { $errors[] = 'お問い合わせ内容が入力されていません。'; }
if (!$agree)       { $errors[] = '個人情報の取り扱いへの同意がありません。'; }

// 改行コードの混入を弾く(ヘッダインジェクション対策)
if (preg_match('/[\r\n]/', $name . $email . $tel)) {
    http_response_code(400);
    exit('Bad Request');
}

if ($errors !== []) {
    http_response_code(422);
    header('Content-Type: text/html; charset=UTF-8');
    echo '<ul>';
    foreach ($errors as $err) {
        echo '<li>' . htmlspecialchars($err, ENT_QUOTES, 'UTF-8') . '</li>';
    }
    echo '</ul>';
    exit;
}

$to      = 'info@example.com';
$subject = 'ウェブサイトからのお問い合わせ';
$message = "お名前: {$name}\n"
         . "メールアドレス: {$email}\n"
         . "お電話番号: {$tel}\n"
         . "お問い合わせ内容:\n{$body}\n";

mb_language('Japanese');
mb_internal_encoding('UTF-8');

$headers = 'From: ' . mb_encode_mimeheader('お問い合わせフォーム', 'UTF-8')
         . ' <no-reply@example.com>';

if (mb_send_mail($to, $subject, $message, $headers)) {
    header('Location: /contact/thanks/', true, 303);
    exit;
}

http_response_code(500);
exit('送信に失敗しました。時間をおいて再度お試しください。');

変更すべき箇所

  • info@example.com — 受信先アドレス
  • no-reply@example.com — 送信元アドレス。設置するサーバーのドメインに揃えてください。 他ドメインを From に入れるとSPFで弾かれ、迷惑メール扱いになるか届かなくなります
  • /contact/thanks/ — 完了ページのURL
  • 項目 — HTMLの name 属性と一致させます

送信成功後に 303 でリダイレクトしているのは、完了ページでリロードしたときに再送信されるのを防ぐためです(PRGパターン)。ここを echo で完了メッセージを出すだけにすると、リロードのたびにメールが飛びます。

WordPressで実装する場合は、この処理をテーマに直書きせず、wp_mail() と wp_verify_nonce() を使った形に置き換えます。その際のテンプレート側の書き方は、カスタムフィールドの出力と分岐の記事が参考になります。


迷惑メール対策を1つだけ入れる

迷惑メール対策を1つだけ入れる

フォームを公開すると、遅かれ早かれ自動送信が来ます。reCAPTCHAを入れるとスクリプトが増え、表示速度に影響します。まずはハニーポットで様子を見るのが現実的です。

HTMLに次を追加します。

<div class="cf-hp" aria-hidden="true">
  <label for="cf-url">この欄は入力しないでください</label>
  <input type="text" id="cf-url" name="website" tabindex="-1" autocomplete="off">
</div>

CSS:

.cf-hp {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

PHP側に1行足します。

if (trim((string)($_POST['website'] ?? '')) !== '') {
    header('Location: /contact/thanks/', true, 303);
    exit; // 弾いたことを相手に知らせない
}

変更すべき箇所 — website という名前は、スパムボットが自動で埋めそうな一般的な語を選んでいます。url company_url なども有効です。逆に honeypot のような名前にすると、賢いボットには避けられます。

display: none ではなく clip-path で隠しているのは、display: none の入力欄を無視するボットが存在するためです。加えて aria-hidden と tabindex="-1" で、スクリーンリーダーとキーボード操作の両方から除外しています。ここを省くと、キーボードだけで操作する人がこの欄にたどり着いてしまい、埋めてしまったせいで送信できなくなります。


うまくいかないとき

送信ボタンを押しても何も起こらない

ブラウザのコンソールを開いて、JavaScriptのエラーが出ていないか見てください。多いのは次の3つです。

症状 原因
Cannot read properties of null getElementById のIDがHTMLと一致していない。cf-summary cf-submit などを一括置換したときに漏れやすい
スクリプトが読み込まれていない <script> のパスが違う。ネットワークタブで404になっていないか確認
何のエラーも出ないが反応しない defer を付けずに <head> で読み込んでいて、フォーム要素がまだ無い状態で実行されている

サマリーが空のまま出る

data-label 属性が付いていない項目はサマリーに出ません。項目を追加したときに付け忘れているのが原因です。仕様としてそうしているので、逆に「確認画面には出したくない隠し項目」は data-label を付けなければ除外されます。

確認画面から戻ると入力が消える

ページ遷移型の確認画面を別で実装している場合に起きます。この記事の方式(同一ページ内のサマリー、または <dialog>)なら構造的に起きません。既存のページ遷移型を維持したまま直すなら、確認画面側で全項目を <input type="hidden"> として持ち、「修正する」を送信ボタンにして入力画面へPOSTで戻す形にします。ブラウザの「戻る」ボタンでの復元には依存しないでください。

スマホでモーダルの送信ボタンが押せない

.cf-dialog-inner の max-block-size: 80vh が効いていないか、値が大きすぎます。モバイルブラウザはアドレスバーの表示状態によって vh の基準が変わることがあるため、80vh でも足りない場合があります。max-block-size: 70dvh に変えると、実際の表示領域に追従しやすくなります。

:user-invalid が効かず、ページを開いた瞬間に全部赤くなる

このセレクタを :invalid に書き換えていないか確認してください。書き換えていないのに起きるなら、他のCSSで :invalid を指定している箇所があります。既存テーマのリセットCSSやフレームワークが input:invalid を持っていることがあるので、:invalid で検索してみてください。

メールが届かない

順番に切り分けます。

  1. PHPのエラーログを見る。mb_send_mail() が false を返しているなら、サーバーのメール送信設定の問題です
  2. true が返っているのに届かないなら、迷惑メールフォルダを確認します
  3. そこにも無いなら、From のドメインとサーバーのドメインが一致しているか見てください。ここが不一致だと受信側で破棄されます
  4. レンタルサーバーによっては mb_send_mail() 自体が制限されています。その場合はSMTP経由での送信に切り替えます

Shopifyのテーマに置いたら送信できない

Shopifyのフォームは {% form 'contact' %} で出力される形が基本で、action 属性を自前で指定してもShopify側の受け口には届きません。この記事のJavaScriptはそのまま使えますが、HTMLは {% form 'contact' %} の中に入力欄を置く形に組み替えてください。テーマ改修の方針そのものは次の記事にまとめてあります。

WordPressで管理画面まで含めて整える場合は、納品前に隠す項目の判断が別途必要になります。あわせてWordPress管理画面カスタマイズ完全ガイドを見てください。子テーマの構成から作る場合はWordPress子テーマの作り方が先になります。


よくある質問

確認画面は付けたほうがいいのでしょうか

入力項目が10を超える、または数量・日時のように送信後の訂正が効かない内容を扱うなら付ける価値があります。名前・メールアドレス・本文だけの問い合わせフォームなら、ページ遷移する確認画面は離脱を増やすだけなので、入力欄の下にサマリーを出す方式で十分です。

確認画面から戻ると入力内容が消えてしまいます

ページ遷移型の確認画面で、ブラウザの「戻る」に依存しているのが原因です。確認画面側で全項目を hidden フィールドとして保持し、「修正する」ボタンで入力画面へPOSTして値を復元してください。この記事の方式なら同一ページ内で完結するので、この問題自体が発生しません。

JavaScriptで検査していればサーバー側の検査は不要ですか

必要です。JavaScriptを無効にした状態でも、開発者ツールで required 属性を消した状態でも、フォームは送信できます。ブラウザ側の検査は入力体験を良くするためのもので、データの正しさを保証するのはサーバー側の検査だけです。

reCAPTCHAは入れるべきですか

まずハニーポット(隠しフィールド)で様子を見て、それでもスパムが止まらない場合に検討してください。reCAPTCHAは外部スクリプトを読み込むぶん表示速度に影響し、プライバシーポリシーへの記載も必要になります。ハニーポットは追加コストがなく、単純な自動送信の多くは止まります。

dialog 要素は古いブラウザでも動きますか

主要ブラウザの現行版ではおおむね対応していますが、showModal() の対応時期はブラウザごとに異なります。古いバージョンを対象に含める必要があるなら、実装前に MDN か caniuse で最新の対応状況を確認したうえで、モーダル方式ではなく同一ページ内サマリー方式を選ぶ判断もできます。こちらは dialog を使いません。


まとめ

確認画面は「丁寧なフォーム」の記号として扱われがちですが、実際には完了までの距離を伸ばす機能です。付けるかどうかは項目数と訂正コストで決め、付けないと決めたなら送信ボタンの手前に内容を出して埋め合わせます。

実装として押さえるべきは4点です。:user-invalid で入力途中に叱らないこと、サマリーは textContent で組み立てること、form.submit() は submit イベントを発火させないこと、そしてブラウザ側の検査はサーバー側の検査を一切代替しないこと。この4つを外さなければ、確認画面ありでもなしでも、壊れないフォームになります。