デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
打つたびに、長さと満たした条件の数からパスワードの強さを 0〜1 で出し、メーターをその長さまで伸ばします。強さが 0.34・0.62・0.9 を越えると、段階が「弱い → 普通 → 強い → とても強い」と上がり、帯の色が赤 → 橙 → 黄 → 緑に変わって、右の言葉が入れ替わります。満たした条件(8文字以上・大文字・数字・記号)は丸が緑に塗られてチェックが描かれ、「とても強い」になると「次へ進む」のボタンが押せる色になります。
強さは、長さで最大 0.26(12 字まで)、条件 1 つごとに 0.17 を足して出します。長さだけでは「普通」にも届かず、条件を満たすほど大きく伸びるので、何をすれば強くなるかが帯の伸び方で分かります。メーターは打つたびに 0.35 秒の power3.out で、今の長さから新しい長さへ向かい直します。チェックの線は DrawSVGPlugin で 0% から描きます。
日本語入力が効いているときは、変換の途中にも input が届き、未確定のひらがなで強さが揺れます。isComposing が true の間は数えず、compositionend で確定してから数えます。Enter は keydown では受けず、form の送信で受けます。keydown で受けると、変換を確定する Enter でも送ってしまうためです。記号は半角の記号だけを数え、全角の文字や空白は記号と数えません。
段階の色は意味のある色として持ち、欄の枠やボタンは無彩色にします。右の言葉は小さな文字なので、帯と同じ色では白い地に対して薄く、同じ色みで一段濃い色にしています。色だけに頼らないよう、段階の名前は読み上げ用の領域にも書き、満たした条件には「満たしています」を添えます。押せない間のボタンは disabled ではなく aria-disabled にして、焦点を受けられるようにします。
つまみは、長さと条件の配分、段階のしきい値と色、メーターの伸びる時間です。しきい値は HTML の段階の言葉に付けた値で変えられます。右の目のボタンで文字を見せられるようにしておくと、打ち間違いに気づきやすくなります。会員登録、パスワードの変更や再設定のフォームに向いています。
コード
<form class="password" action="/signup/step3/" method="post">
<h2 class="password__title">パスワードの設定</h2>
<p class="password__lead">英字・数字・記号を組み合わせて、8文字以上で設定してください。</p>
<label class="password__label" for="new-password">パスワード</label>
<div class="password__field">
<input class="password__input" id="new-password" name="password" type="password" autocomplete="new-password"
minlength="8" required spellcheck="false" aria-describedby="password-rules">
<!-- 押している間(aria-pressed が true)は文字を見せる。名前は変えない -->
<button class="password__toggle" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z" />
<circle cx="12" cy="12" r="3" />
<path class="password__slash" d="M4 4l16 16" />
</svg>
</button>
</div>
<!-- 段階は data-min(強さ 0〜1 のしきい値)の小さい順に並べる。帯と言葉の色は style.css -->
<div class="strength" data-level="-1">
<div class="strength__track"><span class="strength__bar"></span></div>
<p class="strength__caption">
パスワードの強さ
<span class="strength__words" aria-hidden="true">
<span class="strength__word" data-min="0">弱い</span>
<span class="strength__word" data-min="0.34">普通</span>
<span class="strength__word" data-min="0.62">強い</span>
<span class="strength__word" data-min="0.9">とても強い</span>
</span>
</p>
<p class="password__hidden" role="status" data-label="パスワードの強さ:"></p>
</div>
<!-- data-rule は script.js の条件の名前。満たした条件には、読み上げ用に data-ok の文が付く -->
<ul class="rules" id="password-rules" aria-label="パスワードの条件" data-ok="(満たしています)">
<li class="rule" data-rule="length"><span class="rule__dot" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 12.4l3.3 3.3 6.7-7" /></svg></span>8文字以上<span class="password__hidden"></span></li>
<li class="rule" data-rule="upper"><span class="rule__dot" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 12.4l3.3 3.3 6.7-7" /></svg></span>大文字を含む<span class="password__hidden"></span></li>
<li class="rule" data-rule="digit"><span class="rule__dot" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 12.4l3.3 3.3 6.7-7" /></svg></span>数字を含む<span class="password__hidden"></span></li>
<li class="rule" data-rule="symbol"><span class="rule__dot" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 12.4l3.3 3.3 6.7-7" /></svg></span>記号を含む<span class="password__hidden"></span></li>
</ul>
<button class="password__submit" type="submit" aria-disabled="true">次へ進む</button>
</form>
.password {
--surface: #ffffff; /* カードの面と欄の地 */
--ink: #262626; /* 文字・フォーカスの枠・押せるボタン */
--mute: #898989; /* 目のアイコンと、満たしていない条件の丸の縁 */
--mute-small: #6b6b6b; /* 補足の文字 */
--rule: rgba(38, 38, 38, 0.12); /* 欄の枠・メーターの地・フォーカスの輪の外側 */
--success: #4f8a64; /* 満たした条件の丸 */
--disabled: #c8c7c3; /* まだ押せないボタン */
/* 段階の色。帯はそのまま、言葉は 16px 未満の文字でも読める濃さにする */
--weak: #c4553d; --weak-text: #a8432d;
--fair: #c07a3a; --fair-text: #9a5a22;
--good: #a98b2e; --good-text: #7f6a1f;
--strong: #4f8a64; --strong-text: #3f7352;
max-width: 560px;
box-sizing: border-box;
padding: 48px;
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
}
.password__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 32px; letter-spacing: -0.01em; }
.password__lead { margin: 8px 0 0; color: var(--mute-small); font-size: 14px; line-height: 24px; letter-spacing: 0.04em; }
.password__label { display: block; margin-top: 32px; color: var(--mute-small); font-size: 13px; font-weight: 600; line-height: 16px; letter-spacing: 0.06em; }
/* 欄の枠と、フォーカスの輪(外側に 4px) */
.password__field {
display: flex;
align-items: center;
height: 56px;
margin-top: 8px;
box-sizing: border-box;
border: 1px solid var(--rule);
border-radius: 4px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.password__field:focus-within { border-color: var(--ink); box-shadow: 0 0 0 4px var(--rule); }
.password__input { flex: 1; min-width: 0; height: 100%; padding: 0 0 0 16px; border: 0; background: none; color: inherit; font: inherit; font-size: 16px; font-weight: 500; outline: none; }
.password__toggle { display: grid; place-items: center; width: 48px; height: 100%; padding: 0; border: 0; background: none; color: var(--mute); cursor: pointer; }
.password__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: -8px; border-radius: 8px; }
.password__toggle svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* メーター。段階(data-level)で帯の色を移す */
.strength { --level: var(--weak); }
.strength[data-level="1"] { --level: var(--fair); }
.strength[data-level="2"] { --level: var(--good); }
.strength[data-level="3"] { --level: var(--strong); }
.strength__track { height: 8px; margin-top: 24px; overflow: hidden; border-radius: 8px; background-color: var(--rule); }
.strength__bar { display: block; width: 0; height: 100%; border-radius: inherit; background-color: var(--level); transition: background-color 0.25s; }
.strength__caption { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 0; color: var(--mute-small); font-size: 14px; line-height: 24px; letter-spacing: 0.04em; }
/* 段階の言葉は同じ場所に重ね、JS が入れ替える */
.strength__words { display: grid; justify-items: end; font-weight: 700; }
.strength__word { grid-area: 1 / 1; white-space: nowrap; visibility: hidden; opacity: 0; }
.strength__word:nth-child(1) { color: var(--weak-text); }
.strength__word:nth-child(2) { color: var(--fair-text); }
.strength__word:nth-child(3) { color: var(--good-text); }
.strength__word:nth-child(4) { color: var(--strong-text); }
/* 条件の一覧(2 列)。満たしたら丸を塗り、チェックの線は JS が描く */
.rules { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; margin: 24px 0 0; padding: 0; list-style: none; }
.rule { display: flex; align-items: center; gap: 12px; color: var(--mute-small); font-size: 14px; line-height: 24px; letter-spacing: 0.04em; transition: color 0.2s; }
.rule.is-ok { color: var(--ink); }
.rule__dot { display: grid; flex: none; width: 20px; height: 20px; box-sizing: border-box; border: 1px solid var(--mute); border-radius: 50%; transition: background-color 0.2s, border-color 0.2s; }
.rule.is-ok .rule__dot { border-color: var(--success); background-color: var(--success); }
.rule__dot svg { width: 20px; height: 20px; margin: -1px; overflow: visible; fill: none; stroke: #ffffff; stroke-width: 2.8; stroke-linecap: round; stroke-linejoin: round; }
.password__submit {
width: 100%;
height: 48px;
margin-top: 32px;
border: 0;
border-radius: 999px;
background-color: var(--ink);
color: #ffffff;
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: background-color 0.3s;
}
.password__submit[aria-disabled="true"] { background-color: var(--disabled); cursor: not-allowed; }
.password__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 読み上げだけに届ける文(見た目からは消す) */
.password__hidden { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 480px) {
.password { padding: 32px 24px; }
}
@media (prefers-reduced-motion: reduce) {
.password__field, .strength__bar, .rule, .rule__dot, .password__submit { transition: none; }
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const t = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、補間せずに切り替える
// 条件。記号は半角の記号だけを数える(全角の文字や空白を記号と数えない)
const RULES = {
length: (s) => [...s].length >= 8,
upper: (s) => /[A-Z]/.test(s),
digit: (s) => /\d/.test(s),
symbol: (s) => /[!-/:-@[-`{-~]/.test(s),
};
// 強さ(0〜1)。長さで少しずつ伸び(12 字まで)、条件を 1 つ満たすごとに大きく伸びる
const score = (s, passed) => (s ? Math.min(1, 0.06 + (Math.min([...s].length, 12) / 12) * 0.26 + passed * 0.17) : 0);
document.querySelectorAll(".password").forEach((form) => {
const input = form.querySelector(".password__input");
const toggle = form.querySelector(".password__toggle");
const slash = toggle.querySelector(".password__slash");
const meter = form.querySelector(".strength");
const bar = meter.querySelector(".strength__bar");
const words = [...meter.querySelectorAll(".strength__word")];
const status = meter.querySelector("[role='status']");
const list = form.querySelector(".rules");
const rules = [...list.querySelectorAll(".rule")];
const marks = rules.map((rule) => rule.querySelector("path"));
const submit = form.querySelector(".password__submit");
const strongest = words.length - 1;
let level = -1;
gsap.set(marks, { drawSVG: "0%" });
function update() {
const value = input.value;
const passed = rules.map((rule) => RULES[rule.dataset.rule](value));
const s = score(value, passed.filter(Boolean).length);
const next = value ? words.findLastIndex((word) => s >= Number(word.dataset.min)) : -1;
// 打つたびに、今の長さから新しい長さへ向かい直す(overwrite で前のトゥイーンの width を止める)
gsap.to(bar, { width: `${s * 100}%`, duration: t(0.35), ease: "power3.out", overwrite: "auto" });
if (next !== level) {
// 段階が変わったときだけ、言葉を入れ替えて帯の色を替える(色は CSS が data-level で移す)
if (level >= 0) gsap.to(words[level], { autoAlpha: 0, y: -8, duration: t(0.2), ease: "power2.in", overwrite: true });
if (next >= 0) gsap.fromTo(words[next], { autoAlpha: 0, y: 8 }, { autoAlpha: 1, y: 0, duration: t(0.3), ease: "power2.out", overwrite: true });
meter.dataset.level = next;
status.textContent = next >= 0 ? status.dataset.label + words[next].textContent : "";
level = next;
}
rules.forEach((rule, i) => {
if (rule.classList.contains("is-ok") === passed[i]) return;
rule.classList.toggle("is-ok", passed[i]); // 丸の塗りと文字の色は CSS
rule.lastElementChild.textContent = passed[i] ? list.dataset.ok : "";
gsap.to(marks[i], { drawSVG: passed[i] ? "100%" : "0%", duration: t(passed[i] ? 0.3 : 0.2), delay: passed[i] ? t(0.05) : 0, ease: "power2.out", overwrite: true });
});
// いちばん強い段階になるまで、ボタンは押せない色のまま(焦点は受けられるよう disabled にはしない)
submit.setAttribute("aria-disabled", String(level < strongest));
}
// 日本語入力の変換中は数えない。未確定の文字(ひらがな)で強さが揺れないよう、確定してから数える
input.addEventListener("input", (event) => {
if (!event.isComposing) update();
});
input.addEventListener("compositionend", update);
// Enter は keydown で受けず、form の送信で受ける(keydown で受けると、変換を確定する Enter でも送ってしまう)。
// いちばん強い段階になるまでは送らず、欄へ戻す
form.addEventListener("submit", (event) => {
if (level === strongest) return;
event.preventDefault();
input.focus();
});
// 表示の切り替え。押している間は文字を見せ、目の斜線を消す
toggle.addEventListener("click", () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
toggle.setAttribute("aria-pressed", String(show));
gsap.to(slash, { drawSVG: show ? "0%" : "100%", duration: t(0.25), ease: "power2.out", overwrite: true });
});
update(); // 自動入力や、戻ってきたページに値が残っていたときも数える
});