テクニック

フォームのCSS実装|入力しやすい設計とエラー表示まで

この記事の対象: 問い合わせフォームを自分で組む・改修するWeb制作者
読了時間: 約15分

問い合わせフォームは、サイトの中でいちばん離脱が起きやすい場所です。デザインカンプでは整って見えても、実機で触ると「タップしたら画面が拡大した」「エラーがどこに出たか分からない」「送信を押しても何も起きない」といったつまずきが次々に出てきます。

この記事では、素のHTML / CSS / JavaScriptだけで、入力しやすいフォームをCSSから実装する手順を扱います。ラベルの置き方、入力欄のサイズ、iOSで拡大されない指定、エラーの見せ方まで、コピーしてそのまま動くコードで進めます。バリデーションライブラリは使いません。ブラウザが標準で持っている検証機能に、表示だけを自分で足す構成にします。


前提と、この記事で作るもの

動作環境

以下を前提にしています。

項目 内容
マークアップ 素のHTML。フレームワーク不要
CSS カスタムプロパティ・accent-color を使用。ネスト記法と :has() は使わない
JS ES5相当。ビルド不要
CMS WordPress・Shopifyどちらでも可。フォームの送信処理は既存のものを使う

accent-color はチェックボックスの色を変えるためだけに使っており、非対応でも標準色で表示されるだけです。対応状況が気になる場合は、ブラウザのDevToolsコンソールで次を実行すると確認できます。

console.log(CSS.supports('accent-color', 'red'));  // true なら効く

古い環境を切り捨てられない案件でも、そのまま使える構成にしてあります。

送信処理には触れない

この記事が扱うのは入力体験の部分だけです。実際の送信先(PHPのメール送信、Contact Form 7、Shopifyの /contact エンドポイント、外部フォームサービス)はそのまま使えます。<form> の action と method は既存のものを残し、その内側のマークアップとスタイル、そしてエラー表示だけを差し替える形にします。

この記事で触る範囲と、触らない範囲

この記事で作る

  • ラベルと入力欄のマークアップ
  • フォーム用CSS一式
  • エラー表示のJS(約80行)

既存のまま使う

  • form の action / method
  • サーバー側の受信・メール送信
  • スパム対策・CAPTCHA

送信処理を書き換えないので、既存フォームの改修としてそのまま適用できます。作業は基本的にテンプレートファイル1枚とCSS・JSの追加だけで終わります。


入力しやすいフォームのHTML構造を決める

ラベルは入力欄の上に、必ず <label> で置く

まずマークアップから決めます。ここを間違えると、後からCSSでいくら整えても入力体験は良くなりません。

<form class="cform" action="/contact" method="post" novalidate>
  <div class="cform__field">
    <label class="cform__label" for="cf-name">
      お名前
      <span class="cform__required">必須</span>
    </label>
    <input
      class="cform__input"
      type="text"
      id="cf-name"
      name="name"
      autocomplete="name"
      required
      maxlength="60">
    <p class="cform__error" id="cf-name-error" hidden></p>
  </div>

  <div class="cform__field">
    <label class="cform__label" for="cf-email">
      メールアドレス
      <span class="cform__required">必須</span>
    </label>
    <input
      class="cform__input"
      type="email"
      id="cf-email"
      name="email"
      autocomplete="email"
      inputmode="email"
      required>
    <p class="cform__hint">確認のご連絡をこちらにお送りします</p>
    <p class="cform__error" id="cf-email-error" hidden></p>
  </div>

  <div class="cform__field">
    <label class="cform__label" for="cf-tel">
      電話番号
      <span class="cform__optional">任意</span>
    </label>
    <input
      class="cform__input"
      type="tel"
      id="cf-tel"
      name="tel"
      autocomplete="tel"
      inputmode="tel"
      pattern="[0-9\-]{10,14}">
    <p class="cform__error" id="cf-tel-error" hidden></p>
  </div>

  <div class="cform__field">
    <label class="cform__label" for="cf-body">
      お問い合わせ内容
      <span class="cform__required">必須</span>
    </label>
    <textarea
      class="cform__input cform__input--area"
      id="cf-body"
      name="body"
      rows="6"
      required
      maxlength="2000"></textarea>
    <p class="cform__error" id="cf-body-error" hidden></p>
  </div>

  <div class="cform__field cform__field--check">
    <input class="cform__check" type="checkbox" id="cf-agree" name="agree" required>
    <label class="cform__check-label" for="cf-agree">
      <a href="/privacy" target="_blank" rel="noopener">プライバシーポリシー</a>に同意します
    </label>
    <p class="cform__error" id="cf-agree-error" hidden></p>
  </div>

  <p class="cform__summary" id="cf-summary" role="alert" hidden></p>

  <button class="cform__submit" type="submit">送信する</button>
</form>

変更する箇所は次の4つです。

  • action="/contact" — 実際の送信先パス
  • id の接頭辞 cf- — ページ内に複数フォームがある場合は重複しないよう変える
  • name 属性 — サーバー側やCMSが期待するキー名に合わせる
  • /privacy — プライバシーポリシーのURL

なぜこの書き方なのか

いくつか意図的な選択をしているので、理由を挙げます。

<label> の for と入力欄の id を対応させる。 これがあると、ラベル文字をタップしただけで入力欄にフォーカスが移ります。スマホの細い入力欄を狙わなくてよくなるので、タップの失敗が減ります。チェックボックスでは効果が特に大きく、文字全体が当たり判定になります。

プレースホルダーをラベル代わりにしない。 入力を始めた瞬間に消えるため、「何を書く欄だったか」が分からなくなります。入力中の確認と、後から見直すときの両方で不利になります。ラベルは常に見える位置に置き、プレースホルダーは書式の例(090-1234-5678 など)が必要なときだけ使います。

autocomplete を正しく指定する。 ブラウザとOSの自動入力が効くようになり、名前・メール・電話の入力が実質1タップで終わります。フォームの入力量を減らす方法として、これがいちばん効きます。name email tel postal-code address-level1 などが代表的な値です。

novalidate を付ける。 ブラウザ標準のバリデーション吹き出しを止めるためです。標準の吹き出しはブラウザごとに見た目も文言も違い、スタイルも当てられません。後述のJSで検証結果を自前のDOMに出す方針にするので、標準の表示だけ止めます。検証機能そのものは無効になりません(required や type="email" の判定は checkValidity() 経由で使えます)。

エラー用の <p> を最初から置いておく。 JSで要素を生成するのではなく、hidden で隠したものを表示に切り替える形にします。DOMの構造が入力前後で変わらないので、レイアウトの実装が読みやすくなります。

「必須」「任意」を両方書く。 必須だけにマークを付けると、マークの無い欄が「任意」なのか「マークを付け忘れたのか」判断できません。全項目に明示するほうが迷いません。

入力タイプと inputmode の使い分け

スマホのキーボードは type と inputmode で変わります。ここを合わせると入力の手間がはっきり減ります。

用途 type inputmode 出るキーボード
氏名 text 指定なし 通常
メール email email @ と . が並ぶ
電話 tel tel 数字キーパッド
郵便番号 text numeric 数字のみ
数量 text numeric 数字のみ

郵便番号や数量に type="number" を使うと、スピナー(上下の矢印)が付き、スクロールで値が変わる事故が起きます。日本の郵便番号は先頭の0が落ちる問題もあるため、type="text" + inputmode="numeric" のほうが安全です。

項目の並び順を、答えやすい順にする

マークアップの段階で決めておくと後から効くのが、項目の並び順です。上から順に「答えるのに迷わないもの」から並べます。

氏名・メールは考えずに書けるので先頭に置きます。逆に「お問い合わせ内容」の自由記述は、いちばん手が止まる項目なので後ろに置きます。人は入力を始めた分だけ最後までやろうとするので、最初に重い項目を置くと、その1つで止まったまま離脱します。

同じ理由で、「ご予算」「導入時期」のような答えに判断が要る項目は、必須にせず、置くとしても末尾にします。


フォームのCSSを書く|サイズ・余白・拡大されない指定

ベースとなるCSS一式

以下をCSSファイルの末尾に追加します。WordPressなら style.css(子テーマ)、Shopifyなら assets/ 配下の独自CSSファイル、静的サイトなら共通CSSです。

/* ===== 問い合わせフォーム ===== */
.cform {
  --cform-border: #c9ccd4;
  --cform-border-focus: #1a5fd0;
  --cform-error: #c0392b;
  --cform-text: #1c1f26;
  --cform-hint: #6b7280;
  --cform-radius: 6px;

  max-width: 560px;
  margin-inline: auto;
  color: var(--cform-text);
}

.cform__field {
  margin-bottom: 28px;
}

.cform__label {
  display: block;
  margin-bottom: 8px;
  font-size: 15px;
  font-weight: 700;
  line-height: 1.5;
}

.cform__required,
.cform__optional {
  display: inline-block;
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 3px;
  font-size: 11px;
  font-weight: 700;
  vertical-align: 2px;
}

.cform__required {
  background: #fdecea;
  color: var(--cform-error);
}

.cform__optional {
  background: #eef0f4;
  color: var(--cform-hint);
}

.cform__input {
  display: block;
  width: 100%;
  padding: 14px 14px;
  border: 1px solid var(--cform-border);
  border-radius: var(--cform-radius);
  background: #fff;
  color: inherit;
  font-family: inherit;
  font-size: 16px;      /* iOSの拡大を防ぐ。15px以下にしない */
  line-height: 1.6;
  transition: border-color .15s ease, box-shadow .15s ease;
  -webkit-appearance: none;
  appearance: none;
}

.cform__input--area {
  min-height: 160px;
  resize: vertical;
}

.cform__input:focus {
  outline: none;
  border-color: var(--cform-border-focus);
  box-shadow: 0 0 0 3px rgb(26 95 208 / .18);
}

.cform__input:focus-visible {
  outline: 2px solid var(--cform-border-focus);
  outline-offset: 1px;
}

.cform__hint {
  margin: 6px 0 0;
  font-size: 13px;
  line-height: 1.6;
  color: var(--cform-hint);
}

/* ===== エラー状態 ===== */
.cform__input[aria-invalid="true"] {
  border-color: var(--cform-error);
  background: #fffafa;
}

.cform__input[aria-invalid="true"]:focus {
  box-shadow: 0 0 0 3px rgb(192 57 43 / .18);
}

.cform__error {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  margin: 8px 0 0;
  font-size: 13.5px;
  font-weight: 700;
  line-height: 1.6;
  color: var(--cform-error);
}

.cform__error::before {
  content: "!";
  flex: none;
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  margin-top: 2px;
  border-radius: 50%;
  background: var(--cform-error);
  color: #fff;
  font-size: 11px;
  line-height: 1;
}

.cform__summary {
  margin: 0 0 24px;
  padding: 14px 16px;
  border: 1px solid var(--cform-error);
  border-left-width: 4px;
  border-radius: var(--cform-radius);
  background: #fdecea;
  font-size: 14px;
  font-weight: 700;
  line-height: 1.7;
  color: var(--cform-error);
}

/* ===== チェックボックス ===== */
.cform__field--check {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0 10px;
  align-items: start;
}

.cform__check {
  width: 22px;
  height: 22px;
  margin: 1px 0 0;
  accent-color: var(--cform-border-focus);
  flex: none;
}

.cform__check-label {
  font-size: 15px;
  line-height: 1.6;
}

.cform__field--check .cform__error {
  grid-column: 1 / -1;
}

/* ===== 送信ボタン ===== */
.cform__submit {
  display: block;
  width: 100%;
  min-height: 56px;
  padding: 16px 24px;
  border: none;
  border-radius: var(--cform-radius);
  background: var(--cform-text);
  color: #fff;
  font-family: inherit;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .04em;
  cursor: pointer;
  transition: opacity .15s ease;
}

.cform__submit:hover {
  opacity: .85;
}

.cform__submit:disabled {
  opacity: .5;
  cursor: not-allowed;
}

@media (min-width: 768px) {
  .cform__submit {
    width: auto;
    min-width: 240px;
    margin-inline: auto;
  }
}

変更する箇所は先頭の変数6つ(--cform-border --cform-border-focus --cform-error --cform-text --cform-hint --cform-radius)だけです。サイトのブランドカラーに置き換えれば全体が揃います。クラス名の接頭辞 cform は、既存CSSと衝突する場合だけ変えてください。

16pxを下回らない理由

.cform__input の font-size: 16px は、見た目の好みではなく機能上の指定です。

iOS Safariは、フォントサイズが16px未満の入力欄にフォーカスすると、自動でページを拡大します。 拡大されたビューは指で戻すまで解除されず、その状態でスクロールすると横にはみ出て、フォームの全体像が見えなくなります。スマホからの問い合わせが途中で止まる原因として、これはよく見かけるものです。

回避策として <meta name="viewport" content="user-scalable=no"> を指定する方法が広まった時期がありますが、これはやってはいけません。ユーザーが自分の意思で拡大する手段まで奪うことになり、アクセシビリティ上の問題になります。入力欄のフォントを16px以上にするだけで解決するので、そちらを取ります。

デザイン上どうしても小さく見せたい場合は、font-size: 16px を保ったまま padding と line-height を詰めると、体感の圧迫感は下げられます。文字そのものを14pxにするのは避けてください。

タップ領域とフォーカスリング

.cform__input の padding: 14px と .cform__submit の min-height: 56px は、指で触る対象として十分なサイズを確保するためのものです。入力欄の高さは、16pxの文字に line-height: 1.6 と上下14pxのパディング、1pxの枠を足して約56pxになります。

:focus で outline: none を書いていますが、直後に :focus-visible で輪郭を戻しています。これはキーボード操作の人からフォーカス位置を奪わないためです。outline: none だけ書いて何も戻さないコードをよく見かけますが、Tabキーで移動している人には「今どこにいるか」が完全に見えなくなります。マウスクリック時は :focus-visible が発火しないので、見た目を損なわずに済みます。

チェックボックスの accent-color は、ブラウザ標準のチェックマークの色だけをブランドカラーに変えるプロパティです。appearance: none で全部作り直すより短く済み、OSごとの見た目の自然さも保てます。

余白でグループを伝える

.cform__field の margin-bottom: 28px と、ラベル・入力欄の間の 8px は、意図的に差をつけています。

近いものは同じグループに見え、離れたものは別のグループに見えます。ラベルと入力欄の距離(8px)が、項目と項目の距離(28px)より明らかに小さいので、「このラベルはこの欄のもの」が説明なしで伝わります。逆にこの2つを同じ余白にすると、ラベルが上の欄に属しているのか下の欄に属しているのか分からない画面になります。

エラー文(margin-top: 8px)とヒント文(margin-top: 6px)を入力欄に密着させているのも同じ理由です。どの欄についての指摘かを、線や囲みを足さずに位置だけで示しています。

古い環境でも壊れない構成にしてある

上のCSSはネスト記法も :has() も使っていないので、そのまま古い環境でも動きます。新しめの機能として使っているのは accent-color だけで、これは非対応でもチェックボックスが標準色になるだけです。

rgb(26 95 208 / .18) のスペース区切り記法だけは比較的新しいので、極端に古い環境を相手にする場合は rgba(26, 95, 208, .18) に書き換えてください。


エラー表示の実装|どこが・なぜ・どう直すかを出す

エラーの見せ方を先に決める

コードの前に、方針を決めます。エラー表示で外しやすいのは次の3点です。

エラー表示で守る3つの原則
入力中に赤くしない打ち終わる前に警告を出さない
欄の直下に出す上部にまとめるだけにしない
直し方を書く「エラー」ではなく何をすべきか

入力中に判定しない。 メールアドレスを打っている途中は当然どの瞬間も不正な形式です。input イベントで検証すると、1文字目から赤い枠と警告が出続けます。判定を始めるのは「その欄から離れたとき(blur)」以降にします。ただし、一度エラーになった欄だけは input で再判定する——直している最中に赤いままだと、直ったかどうか分からないためです。

欄の直下に出す。 フォーム上部にまとめて出す形式は、長いフォームだと該当欄までスクロールして探すことになります。上部のサマリーは「何件エラーがあるか」を伝える役割に留め、具体的な指摘は各欄の下に置きます。

何をすればいいか書く。 「入力エラー」「不正な値です」では直しようがありません。「メールアドレスに @ が含まれていません」のように、見て直せる文言にします。

JSコード

assets/js/form-validate.js などのファイル名で保存し、</body> の直前で読み込みます。defer を付ければ <head> でも構いません。

(function () {
  'use strict';

  var form = document.querySelector('.cform');
  if (!form) return;

  var summary = document.getElementById('cf-summary');

  // エラー文言。項目ごとに上書きしたいときはここを編集する
  var MESSAGES = {
    valueMissing: {
      'cf-name':  'お名前を入力してください',
      'cf-email': 'メールアドレスを入力してください',
      'cf-body':  'お問い合わせ内容を入力してください',
      'cf-agree': 'プライバシーポリシーへの同意が必要です',
      _default:   'この項目は入力が必要です'
    },
    typeMismatch: {
      'cf-email': 'メールアドレスの形式が正しくありません(例:info@example.com)',
      _default:   '形式が正しくありません'
    },
    patternMismatch: {
      'cf-tel':   '電話番号は数字とハイフンで入力してください(例:03-1234-5678)',
      _default:   '入力できない文字が含まれています'
    },
    tooLong: {
      _default:   '文字数が上限を超えています'
    },
    _fallback:    '入力内容をご確認ください'
  };

  function messageFor(el) {
    var v = el.validity;
    var keys = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooLong'];
    for (var i = 0; i < keys.length; i++) {
      if (v[keys[i]]) {
        var group = MESSAGES[keys[i]];
        return group[el.id] || group._default;
      }
    }
    return MESSAGES._fallback;
  }

  function errorBox(el) {
    return document.getElementById(el.id + '-error');
  }

  function showError(el, text) {
    var box = errorBox(el);
    el.setAttribute('aria-invalid', 'true');
    if (!box) return;
    box.textContent = text;
    box.hidden = false;
    el.setAttribute('aria-describedby', box.id);
  }

  function clearError(el) {
    var box = errorBox(el);
    el.removeAttribute('aria-invalid');
    if (!box) return;
    box.textContent = '';
    box.hidden = true;
    el.removeAttribute('aria-describedby');
  }

  function validate(el) {
    if (el.checkValidity()) {
      clearError(el);
      return true;
    }
    showError(el, messageFor(el));
    return false;
  }

  var fields = form.querySelectorAll('input, textarea, select');

  Array.prototype.forEach.call(fields, function (el) {
    if (el.type === 'submit' || el.type === 'button') return;

    // 欄を離れたときに1回だけ判定する
    el.addEventListener('blur', function () {
      validate(el);
    });

    // すでにエラーが出ている欄だけ、入力中も追いかける
    el.addEventListener('input', function () {
      if (el.getAttribute('aria-invalid') === 'true') validate(el);
    });

    // チェックボックスは change で見る
    el.addEventListener('change', function () {
      if (el.type === 'checkbox') validate(el);
    });
  });

  form.addEventListener('submit', function (e) {
    var invalid = [];

    Array.prototype.forEach.call(fields, function (el) {
      if (el.type === 'submit' || el.type === 'button') return;
      if (!validate(el)) invalid.push(el);
    });

    if (invalid.length === 0) {
      if (summary) summary.hidden = true;
      return;   // 通常どおり送信される
    }

    e.preventDefault();

    if (summary) {
      summary.textContent = '入力内容に ' + invalid.length + ' 件の不備があります。赤く表示された項目をご確認ください。';
      summary.hidden = false;
    }

    // 最初のエラー欄まで移動してフォーカスを当てる
    var first = invalid[0];
    var top = first.getBoundingClientRect().top + window.pageYOffset - 120;
    window.scrollTo({ top: top, behavior: 'smooth' });
    setTimeout(function () { first.focus({ preventScroll: true }); }, 400);
  });
})();

変更する箇所は次の3つです。

  • MESSAGES の中の cf-name などのキー — HTMLの id に合わせる
  • 文言そのもの — サイトの言い回しに合わせる
  • - 120 の数値 — 固定ヘッダーの高さ。ヘッダーが無ければ - 24 程度でよい

コードの要点

checkValidity() に判定を任せる。 正規表現でメールアドレスを自作しません。type="email" を書いた時点でブラウザが判定してくれるので、JSは結果を受け取って表示するだけにします。自作の正規表現は、記号を含む正当なアドレスを弾いたり、逆に緩すぎたりと、事故の元になります。

validity オブジェクトで理由を分ける。 valueMissing(未入力)・typeMismatch(形式違い)・patternMismatch(pattern 不一致)・tooLong(文字数超過)を見分けて、それぞれ違う文言を出します。これがあるから「メールアドレスの形式が正しくありません」と具体的に書けます。

aria-invalid と aria-describedby を付ける。 見た目の赤枠だけでは、スクリーンリーダーを使っている人にエラーが伝わりません。aria-invalid="true" で不正であることを、aria-describedby でエラー文の位置を伝えます。CSSのセレクタも aria-invalid を見ているので、属性を付けるだけで見た目とアクセシビリティが同時に揃う構成にしてあります。

サマリーに role="alert" を付ける。 HTML側で指定しておくと、送信失敗時に内容が入った瞬間、スクリーンリーダーが読み上げます。

送信時は1件目までスクロールする。 長いフォームだと、エラーが画面外にあると気づけません。scrollTo で移動し、少し待ってから focus({ preventScroll: true }) を当てます。preventScroll を付けないと、フォーカスによる瞬間移動とスムーススクロールが競合してガタつきます。

複数フォームがあるページに対応する

上のコードは document.querySelector('.cform') で最初の1つだけを掴みます。1ページに問い合わせフォームとメルマガ登録フォームが両方ある場合は、querySelectorAll で回す形に変えます。

  var forms = document.querySelectorAll('.cform');
  Array.prototype.forEach.call(forms, function (form) {
    var summary = form.querySelector('.cform__summary');
    // 以降、これまでの処理を form 単位で行う
  });

このとき、エラー用 <p> の id がフォーム間で重複していると、errorBox() が別のフォームの要素を返します。id の接頭辞をフォームごとに変える(cf- / nl- など)か、document.getElementById を form.querySelector('#' + ...) に置き換えてください。


途中離脱を減らすための細部

二重送信を止める

送信ボタンを連打されると、同じ問い合わせが複数届きます。上のJSの submit ハンドラの末尾(if (invalid.length === 0) { の中)に、次を足します。

    if (invalid.length === 0) {
      if (summary) summary.hidden = true;
      var btn = form.querySelector('.cform__submit');
      if (btn) {
        btn.disabled = true;
        btn.textContent = '送信中…';
      }
      return;
    }

disabled にしたボタンは値が送信されないので、ボタンに name と value を持たせている場合は注意が必要です。その場合は disabled の代わりに pointer-events: none とフラグ変数で制御します。

      var sending = false;
      if (sending) { e.preventDefault(); return; }
      sending = true;
      btn.classList.add('is-sending');   // CSSで pointer-events: none を当てる

送信先がサーバー側でリダイレクトを返す構成なら、これで十分です。Ajax送信にしている場合は、失敗時に disabled を戻す処理を必ず入れてください。押せないボタンだけが残ると、ユーザーはページを再読み込みするしかなくなります。

入力内容を失わせない

ページを離れようとしたときに確認を出します。入力途中で誤ってブラウザバックした場合の救済になります。

  var dirty = false;
  form.addEventListener('input', function () { dirty = true; });
  form.addEventListener('submit', function () { dirty = false; });
  window.addEventListener('beforeunload', function (e) {
    if (!dirty) return;
    e.preventDefault();
    e.returnValue = '';
  });

確認ダイアログの文言はブラウザ側が決めるため、自分で指定した文字列は表示されません。e.returnValue = '' は「確認を出す」という合図としてだけ機能します。

なお、submit で dirty を戻す処理を入れ忘れると、正常に送信したときにも確認ダイアログが出ます。ここは実装した本人が気づきにくいので、必ず送信まで通して確認してください。

項目を減らす

実装の話から少し離れますが、離脱を減らす手段としてはこれがいちばん効きます。フォームの項目数は、そのまま入力コストです。

項目 判断
氏名 必要
メール 必要
電話番号 任意にする。必須にすると入力の負担が明確に増える
会社名 BtoBなら必要。BtoCでは外す
住所 見積や配送が絡まないなら不要
問い合わせ種別 選択肢が3つ以下なら残す価値あり
ご予算 必須にしない。答えられないと止まる

「あとで聞けばいいこと」はフォームから外します。予算感を先に伝えたい場合は、フォームで聞くのではなく、判断材料になる記事へ誘導するほうが結果的に噛み合います。

送信後の画面を作る

送信完了ページを用意せず、同じページに「送信しました」とだけ出す実装をときどき見かけますが、これは避けたほうが無難です。完了ページを別URLにすると、コンバージョン計測がページビューだけで組めます。イベント計測を組む手間が省け、後から数字を見返すときにも扱いやすくなります。

完了ページには、次の3つを書きます。

  • 受け付けた旨と、自動返信メールが届くこと
  • 返信までの目安(営業日で書く)
  • メールが届かない場合の連絡先(迷惑メールフォルダの案内を添える)

計測の組み方そのものは、使うツールによって変わります。


実装の順序と確認手順

作業の流れをまとめます。既存フォームの改修なら、上から順に短時間で終わります。

フォーム改修の作業順序
1. マークアップlabel・id・autocompleteを整える
2. CSS追加変数6つを自サイトの色に変える
3. JS追加MESSAGESのidとヘッダー高さを合わせる
4. 実機確認iPhone実機で拡大されないか見る

4の実機確認は省略しないでください。iOSの自動拡大は、PCのDevToolsのデバイスモードでは再現しません。

確認するチェックリスト

  • [ ] ラベル文字をタップして入力欄にフォーカスが移る
  • [ ] iPhone実機で、入力欄をタップしても画面が拡大しない
  • [ ] 未入力のまま送信 → 各欄の下にエラーが出て、1件目までスクロールする
  • [ ] メール欄に abc と入れて欄を離れる → 形式エラーが出る
  • [ ] エラー状態の欄を直すと、入力中に赤が消える
  • [ ] Tabキーだけで最後まで進み、今どこにいるか常に見える
  • [ ] 全部正しく入れて送信 → 実際にメールが届く
  • [ ] 送信ボタンを連打しても1件しか届かない
  • [ ] 送信完了後、ブラウザバックしても確認ダイアログが出ない

最後の3つはサーバー側と送信後の挙動の確認になります。ここを飛ばすと、見た目だけ整って送信されないフォームが出来上がります。実際にメールが届くところまで見てください。

CMS別の設置場所

WordPress(クラシックテーマ / ブロックテーマ共通) — 子テーマの functions.php に読み込みを書きます。

add_action( 'wp_enqueue_scripts', function () {
    if ( ! is_page( 'contact' ) ) {
        return;
    }
    $dir = get_stylesheet_directory_uri();
    wp_enqueue_style( 'cform', $dir . '/assets/css/form.css', array(), '1.0.0' );
    wp_enqueue_script( 'cform', $dir . '/assets/js/form-validate.js', array(), '1.0.0', true );
} );

'contact' は問い合わせページのスラッグに変えてください。wp_enqueue_script の第5引数 true が「</body> 直前で読み込む」の指定なので、これがあれば defer は不要です。

Contact Form 7 を使っている場合は、生成されるHTMLのクラス名が異なるので、JSの document.querySelector('.cform') を document.querySelector('.wpcf7-form') に変え、エラー用の <p> をフォームのタグ内に手で追加します。プラグイン標準のエラー表示と二重になるため、片方を止める判断が必要です。

テンプレート側の作り込みについては、編集画面から崩さず触れる形にしておくと運用が楽になります。

Shopify(Online Store 2.0 のテーマ) — assets/ にCSSとJSを置き、sections/contact-form.liquid(テーマにより名称が異なる)または layout/theme.liquid から読み込みます。

{{ 'form.css' | asset_url | stylesheet_tag }}
<script src="{{ 'form-validate.js' | asset_url }}" defer></script>

Shopifyの標準の問い合わせフォームは {% form 'contact' %} で生成され、フィールド名は contact[name] contact[email] contact[body] の形式です。name 属性はこの形を保ったまま、id とクラスだけ差し替えてください。{% form %} タグは novalidate を自動では付けないので、{% form 'contact', novalidate: 'novalidate' %} のように渡すか、JSの先頭で form.setAttribute('novalidate', '') を実行します。

ライブラリを足さずにテーマを拡張する考え方は、他の機能でも同じです。


うまくいかないとき

iPhoneで入力欄をタップすると拡大する

font-size が16px未満になっています。開発者ツールで実際に適用されている値を確認してください。CSSで16pxを指定していても、テーマ側のリセットCSSや rem 指定、あるいは親要素の zoom で実効値が下がっていることがあります。

.cform__input {
  font-size: 16px !important;
}

一時的に !important で確認し、拡大が止まるなら原因は特定できます。その後、上書き元を探して正しく直してください。select 要素も同じ挙動をするので、プルダウンを使っている場合はそちらのフォントサイズも合わせて確認します。

エラーが出ない・送信が止まらない

novalidate が付いていない可能性があります。付いていないとブラウザ標準の吹き出しが先に出て、submit イベントまで到達しません。

逆に、エラーがあるのに送信されてしまう場合は、checkValidity() が true を返しています。required や type が入力欄に付いているか確認してください。コンソールで直接確認できます。

document.getElementById('cf-email').validity
// ValidityState { valueMissing: false, typeMismatch: true, ... } のように出る

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

JSがエラーで止まっている可能性が高いです。コンソールを開いてエラーを確認してください。よくある原因は次の2つです。

  • cf-name-error などのIDが、HTMLとJSで食い違っている
  • .cform の要素がJSの実行時にまだ存在していない(defer を付けていない、または <head> で読み込んでいる)

defer を付けるか、スクリプトを </body> の直前に置けば後者は解決します。

スクロールが目的の位置で止まらない

固定ヘッダーの高さと - 120 の値が合っていません。ヘッダーの実際の高さをDevToolsで測り、そこに20〜30pxの余白を足した値にします。ヘッダーの高さが画面幅で変わる場合は、固定値ではなく実測に切り替えます。

var header = document.querySelector('.site-header');
var offset = (header ? header.offsetHeight : 0) + 24;
var top = first.getBoundingClientRect().top + window.pageYOffset - offset;

.site-header はサイトのヘッダーのクラス名に変えてください。

チェックボックスの色が変わらない

accent-color の非対応ブラウザです。標準色で表示されるだけで機能には影響しませんが、どうしても色を揃えたい場合は appearance: none で全部作り直すことになります。工数が増えるので、要件として本当に必要かは確認したほうがよいでしょう。

自動入力が効かない

autocomplete の値が正しくないか、name 属性が想定外の値になっています。仕様で定義された値以外を書くと機能しません。氏名なら name、姓名を分けるなら family-name / given-name、メールは email、電話は tel です。日本語の namae のような独自の値は効きません。

また、autocomplete="off" をフォーム全体に付けている場合、ブラウザによっては個別指定より優先されます。問い合わせフォームで off を付ける理由はほぼないので、外してください。

エラー文が読み上げられない

エラー文の <p> を display: none で隠していると、スクリーンリーダーからも消えます。この記事では hidden 属性で制御していますが、CSSで .cform__error { display: none } を書くと hidden を外しても表示されません(display: flex の指定が勝つため、実際には逆に常時表示になることもあります)。

エラー文の表示・非表示は hidden 属性だけで制御し、CSS側では触らないでください。JSが box.hidden = false にした瞬間に表示され、aria-describedby の参照先として読み上げられます。


よくある質問

バリデーションライブラリを使わなくて本当に大丈夫ですか

問い合わせフォーム程度の要件なら、素のHTML属性と checkValidity() で足ります。ライブラリが有利になるのは、項目間の依存(Aを選んだらBが必須になる)や、非同期の重複チェックが必要な場合です。読み込むファイルが増えると表示速度に効くので、要件が単純なうちは足さない判断で問題ありません。

サーバー側の検証も必要ですか

必要です。この記事のJSは入力体験を良くするためのもので、セキュリティ対策ではありません。ブラウザの開発者ツールから required を外せば簡単に迂回できます。受け取る側で必須項目・形式・文字数を必ず再検証してください。

エラーは赤色以外でも良いですか

色だけに頼らなければ問題ありません。色覚特性によっては赤と黒の区別が付きにくいため、この記事のCSSではアイコン(::before の ! マーク)と文言を必ず添える構成にしています。色をブランドカラーに変える場合も、アイコンと文言は残してください。

プレースホルダーは完全に使ってはいけませんか

ラベルの代わりに使わなければ問題ありません。電話番号の書式(03-1234-5678)のように、ラベルだけでは伝わらない例を示す用途では有効です。ただし、プレースホルダーは薄い文字色で表示されるため、そこに必須の情報を置かないでください。読めない環境があります。

確認画面は用意すべきですか

案件次第です。確認画面を挟むと入力ミスは減りますが、その分だけ離脱のポイントが1つ増えます。項目が5つ程度なら不要と判断してよく、10項目を超える申込フォームや、金額・日程を確定させる用途なら挟む価値があります。挟む場合は、確認画面から入力画面へ戻ったときに値が消えないことを必ず確認してください。


まとめ

入力しやすいフォームをCSSと最小限のJSで実装するためにやったことは、突き詰めると次の6つです。

  1. ラベルを <label for> で置く — タップ領域が広がり、支援技術にも伝わる
  2. autocomplete を正しく指定する — 入力量そのものを減らす、いちばん効く手
  3. 入力欄のフォントを16px以上にする — iOSの自動拡大を止める
  4. :focus-visible でフォーカスリングを残す — キーボード操作の人を切り捨てない
  5. エラーは欄の直下に、直し方を書いて出す — 「エラー」だけでは直せない
  6. 入力中に赤くしない — 打っている途中の警告は邪魔にしかならない

どれも派手な実装ではありませんが、問い合わせが途中で止まる原因はこのあたりに集中しています。ライブラリを足さずに済む範囲で、今日中に終わる作業です。