デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 で隠すと出した瞬間の読み上げが届かないので、透明度だけで出し入れします。動きを減らす設定では震わせず、色と文だけで知らせます。
つまみは、往復の回数と振れ幅、震える時間です。往復を増やすと小刻みで強い警告になり、減らすと首を横に振るような軽い否定になります。ログインや会員登録、決済のフォームで、送信したときの検証エラーに向いています。
コード
<!-- 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>
.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; }
}
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()); // 正しい形になったらチェック
});
});
});