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

誤入力で入力欄が震え、エラー文が出る

デモ

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

仕様

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

しくみと調整

形の正しくない値のまま送信すると、入力欄が左右に震えて枠とラベルが赤くなり、欄の右に「!」、下にエラー文が出ます。震えは、欄の横位置を動かす 1 本のトゥイーンに、自前のイージング関数 (1 − p)·sin(2π·4·p) をかけて作ります。4 往復しながら小さくなり、p が 0 と 1 のときにちょうど 0 なので、震え終わりで位置が飛ばず、専用のプラグインも要りません。打ち直し始めると 1 字目でエラーが消え、正しい形になるとチェックが付きます。

振れ幅は 14px 前後、震える時間は 0.55 秒です。位置を整数の px にそろえると(snap)、枠の細い線がにじみません。エラー文は震え始めて 0.3 秒後に、8px 上から降ろして出します。同時に出すと震えに紛れて読めず、遅すぎると何が起きたのか伝わりません。「!」は back.out で弾ませて目を引きます。

エラーの間の欄の地は、地にエラーの色を 12% 混ぜた不透明な色にします。半透明の赤を重ねると、色が変わる途中で下の面が透けて濁ります。エラー文とラベルは 16px 未満の小さな文字なので、赤は面の上で 4.5:1 に届く明るさにします。エラー文の場所は最初から空けておき、出たときに下の欄を押し下げないようにします。

赤い枠と震えだけでは、読み上げに何も届きません。欄に aria-invalid を付けて誤りを伝え、エラー文は aria-describedby で欄に結びます。エラー文の要素は aria-live の領域にして、出た文を読み上げさせます。このとき文を visibility で隠すと出した瞬間の読み上げが届かないので、透明度だけで出し入れします。動きを減らす設定では震わせず、色と文だけで知らせます。

つまみは、往復の回数と振れ幅、震える時間です。往復を増やすと小刻みで強い警告になり、減らすと首を横に振るような軽い否定になります。ログインや会員登録、決済のフォームで、送信したときの検証エラーに向いています。

コード

HTML
<!-- novalidate でブラウザの吹き出しを止め、自前のエラーを出す(検証そのものは required・type・pattern のまま使う) -->
<form class="login" action="/login/" method="post" novalidate>
  <div class="field">
    <label class="field__label" for="login-email">メールアドレス</label>
    <div class="field__box">
      <input class="field__input" id="login-email" type="email" name="email" autocomplete="email" required
             pattern="[^@\s]+@[^@\s]+\.[^@\s]{2,}" aria-describedby="login-email-error"
             data-missing="メールアドレスを入力してください" data-invalid="メールアドレスの形式が正しくありません">
      <svg class="field__icon field__icon--error" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="12" r="11" fill="currentColor" />
        <path d="M12 6.8v6.4" stroke="#fff" stroke-width="2.6" stroke-linecap="round" />
        <circle cx="12" cy="17.3" r="1.5" fill="#fff" />
      </svg>
      <svg class="field__icon field__icon--ok" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="12" r="11" fill="currentColor" />
        <path d="M7.2 12.4l3.2 3.2 6.4-6.8" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" />
      </svg>
    </div>
    <!-- エラー文。欄とは aria-describedby で結び、出た文は aria-live で読み上げる -->
    <p class="field__error" id="login-email-error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label class="field__label" for="login-password">パスワード</label>
    <div class="field__box">
      <input class="field__input" id="login-password" type="password" name="password" autocomplete="current-password" required
             aria-describedby="login-password-error" data-missing="パスワードを入力してください">
      <svg class="field__icon field__icon--error" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="12" r="11" fill="currentColor" />
        <path d="M12 6.8v6.4" stroke="#fff" stroke-width="2.6" stroke-linecap="round" />
        <circle cx="12" cy="17.3" r="1.5" fill="#fff" />
      </svg>
    </div>
    <p class="field__error" id="login-password-error" aria-live="polite"></p>
  </div>

  <button class="login__submit" type="submit">ログイン</button>
</form>

CSS
.login {
  --surface: #262626;                   /* フォームの面 */
  --field: #111111;                     /* 欄の地 */
  --ink: #eeece6;                       /* 文字・フォーカスの枠・ボタン */
  --mute-small: #a8a6a0;                /* ラベル */
  --line: rgba(238, 236, 230, 0.14);    /* 休んでいる枠 */
  --focus-ring: rgba(238, 236, 230, 0.14);
  --error: #c4553d;                      /* エラーの枠・輪・アイコン */
  --error-text: #e07a62;                 /* エラー文とラベル(小さい文字でも読める明るさ) */
  --error-ring: rgba(196, 85, 61, 0.3);
  --field-error: #271916;                /* 欄の地にエラーの色を 12% 混ぜた、不透明な色 */
  --ok: #4f8a64;                         /* チェック */
  --shake: 14px;                         /* 震えの振れ幅 */
  display: grid;
  gap: 8px;
  max-width: 384px;
  padding: 48px;
  border-radius: 4px;
  background: var(--surface);
  color: var(--ink);
}

.field { display: grid; gap: 8px; }
.field__label { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; color: var(--mute-small); transition: color 0.2s; }

.field__box {
  position: relative;
  border: 1px solid var(--line);
  border-radius: 4px;
  background-color: var(--field);
  transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s;
}

/* フォーカスの輪は、欄の外側に 4px 出す(入力欄そのものの outline の代わり) */
.field__box:focus-within { border-color: var(--ink); box-shadow: 0 0 0 4px var(--focus-ring); }

.field.is-invalid .field__label { color: var(--error-text); }
.field.is-invalid .field__box { border-color: var(--error); box-shadow: 0 0 0 4px var(--error-ring); background-color: var(--field-error); }

.field__input {
  box-sizing: border-box;
  width: 100%;
  height: 54px;
  padding: 0 48px 0 16px; /* 右はアイコンの分を空ける */
  border: 0;
  outline: none;
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 16px;
  font-weight: 500;
}

.field__icon { position: absolute; top: 50%; right: 18px; width: 22px; height: 22px; margin-top: -11px; visibility: hidden; }
.field__icon--error { color: var(--error); }
.field__icon--ok { color: var(--ok); }

/* エラー文の場所は空けておく。出ても下の欄を押し下げない */
.field__error {
  min-height: 24px;
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 600;
  line-height: 24px;
  letter-spacing: 0.04em;
  color: var(--error-text);
}

.login__submit {
  height: 48px;
  margin-top: 24px;
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  color: var(--field);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.login__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

@media (prefers-reduced-motion: reduce) {
  .field__label, .field__box { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SHAKES = 4; // 往復の回数
const SHAKE_TIME = 0.55; // 震える時間(秒)
// 減衰する正弦波。p が 0 と 1 のときちょうど 0 なので、震え終わりで位置が飛ばない
const shake = (p) => (1 - p) * Math.sin(p * Math.PI * 2 * SHAKES);

document.querySelectorAll(".login").forEach((form) => {
  // 振れ幅は CSS の値から読む
  const amplitude = parseFloat(getComputedStyle(form).getPropertyValue("--shake"));
  const fields = [...form.querySelectorAll(".field")].map((field) => ({
    field,
    input: field.querySelector(".field__input"),
    box: field.querySelector(".field__box"),
    alert: field.querySelector(".field__icon--error"),
    ok: field.querySelector(".field__icon--ok"), // チェックはメールアドレスの欄だけ
    message: field.querySelector(".field__error"),
  }));

  // overwrite: true で前の出し入れを止めてから始める(押し直しても値を取り合わない)
  const pop = (icon, show) =>
    gsap.to(icon, show
      ? { autoAlpha: 1, scale: 1, duration: 0.4, ease: "back.out(3)", overwrite: true }
      : { autoAlpha: 0, scale: 0.4, duration: 0.2, ease: "power2.in", overwrite: true });

  function showError(f) {
    const { field, input, box, alert, ok, message } = f;
    message.textContent = input.validity.valueMissing ? input.dataset.missing : input.dataset.invalid;
    input.setAttribute("aria-invalid", "true");
    field.classList.add("is-invalid"); // 枠・輪・地・ラベルの色は CSS が受け持つ
    if (ok) pop(ok, false);
    gsap.fromTo(alert, { autoAlpha: 0, scale: 0.4 }, { autoAlpha: 1, scale: 1, duration: 0.4, delay: 0.05, ease: "back.out(3)", overwrite: true });
    // エラー文は震え始めて 0.3 秒後に、上から降ろす。visibility で隠すと読み上げが届かないので、透明度だけで出す
    gsap.fromTo(message, { opacity: 0, y: -8 }, { opacity: 1, y: 0, duration: 0.3, delay: 0.3, ease: "power2.out", overwrite: true });
    // 動きを減らす設定では震わせず、色と文だけで知らせる
    if (!reduceMotion) gsap.fromTo(box, { x: 0 }, { x: amplitude, duration: SHAKE_TIME, ease: shake, snap: "x", overwrite: true });
  }

  function clearError(f) {
    const { field, input, alert, message } = f;
    input.removeAttribute("aria-invalid");
    field.classList.remove("is-invalid");
    pop(alert, false);
    // 消えきってから文を空にする。残すと、欄の説明として読み上げに残る
    gsap.to(message, { opacity: 0, y: -6, duration: 0.25, ease: "power2.in", overwrite: true, onComplete: () => (message.textContent = "") });
  }

  form.addEventListener("submit", (event) => {
    const invalid = fields.filter((f) => !f.input.checkValidity());
    if (invalid.length === 0) return; // 正しければ、そのまま送る
    event.preventDefault();
    invalid.forEach(showError);
  });

  fields.forEach((f) => {
    f.input.addEventListener("input", () => {
      if (f.input.hasAttribute("aria-invalid")) clearError(f); // 打ち直し始めたら、1 字目で消す
      if (f.ok) pop(f.ok, f.input.checkValidity()); // 正しい形になったらチェック
    });
  });
});

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

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