デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
6 つの枠の上に、見えない入力欄を 1 つ重ねています。打つたびに入力欄の値と枠の数字を比べ、増えた数字は枠の下からせり上げ、消えた数字は下へ落とします。フォーカスの輪は 1 つだけ置き、次に入る枠へ横に滑らせます。6 つそろうとコードを確かめ、正しければ枠が左から順に跳ねながら緑に色づき、下の案内が「確認できました」に替わります。
数字は枠の高さの 55% 下から 0.28 秒の power3.out でせり上がり、枠の overflow を hidden にして下側を切っています。輪は 0.18 秒で次の枠へ移ります。確認の波は枠ごとに 0.06 秒ずらし、跳ねは sin(πp) の形の ease を 1 本のトゥイーンにかけて、12px 上がって元の位置に戻します。成功の緑は暗い地では線だけだと見分けにくいので、枠の地にも緑を 28% 混ぜて塗ります。
枠ごとに 1 桁の入力欄を 6 つ並べる作り方では、貼り付けやメール・SMS からの自動入力で 6 桁が 1 つ目の欄に一度に入り、後ろが切れてしまいます。1 つの入力欄に autocomplete の one-time-code を付けて全部を受け、数字以外(「482 917」の空白など)を除いてから枠に配ります。この欄にも maxlength は付けません。区切り入りで貼り付けると、空白を除く前に最後の桁が切れるためです。Backspace での戻りや矢印キーでの移動はブラウザに任せられ、読み上げでも 1 つの欄として扱われます。
違っていたときは枠の列を左右に震わせ、案内を誤りの文に替えて欄を空にします。確かめている間は欄を読み取りだけにして、書き換えを受けません。動きを減らす設定では補間をやめ、跳ねと震えも止めます。長さ 0 のトゥイーンは ease を通さずに終わりの値を置くので、そのままだと枠が上や横にずれたまま残ります。
つまみは、せり上がる量、波のずれ、跳ねる高さです。確かめる先は form の action で、2xx が返れば正しいとみなす形にしてあるので、サーバーの返し方に合わせて書き替えます。2 段階認証、電話番号やメールアドレスの確認、PIN の入力に向いています。
コード
<!-- 確かめる先は form の action。違っていたときの文は data-error -->
<form class="otp" action="/verify/" method="post" data-error="コードが正しくありません。もう一度入力してください。">
<span class="otp__badge" aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2.5" /><path d="M3.5 6.5l8.5 6.5 8.5-6.5" /></svg>
</span>
<h2 class="otp__title">メールをご確認ください</h2>
<p class="otp__lead" id="otp-lead"><strong>sample@higashiyuki.com</strong>に送信した<br>6桁の認証コードを入力してください。</p>
<!-- 1 つの input を 6 つの枠の上に重ねる。貼り付けと、メールや SMS からの自動入力(one-time-code)をそのまま受ける -->
<div class="otp__field">
<input class="otp__input" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="\d{6}" required
aria-label="認証コード(6桁)" aria-describedby="otp-lead">
<div class="otp__boxes" aria-hidden="true">
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__dash"></span>
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__box"><span class="otp__digit"></span></span>
<span class="otp__ring"></span>
<span class="otp__caret"></span>
</div>
</div>
<p class="otp__status" role="status">
<span class="otp__note">コードの有効期限は10分です。</span>
<!-- 文は、確認できたときに script.js が data-text から入れる(入れた時に読み上げられる) -->
<span class="otp__done" data-text="確認できました。ログインしています…">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
</span>
</p>
<p class="otp__footer">コードが届かない場合は<a href="/verify/resend/">再送信する</a></p>
</form>
.otp {
--surface: #262626; /* カードの面 */
--field: #111111; /* 枠の地(面より一段沈める) */
--ink: #eeece6; /* 数字・フォーカスの輪・キャレット */
--mute: #898989; /* 数字が入った枠と、間の線 */
--mute-small: #a8a6a0; /* 説明と案内の文字 */
--rule: rgba(238, 236, 230, 0.14); /* 空の枠と、フォーカスの輪の外側の光 */
--success: #4f8a64; /* 確認できた枠の線とチェック */
--success-field: #223328; /* 確認できた枠の地(枠の地に成功の緑を 28% 混ぜた色) */
--success-text: #6ba37f; /* 「確認できました」の文字 */
--error: #e07a62; /* 違っていたときの文字 */
--box-width: 64px;
--box-height: 80px;
max-width: 576px;
box-sizing: border-box;
padding: 48px;
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
text-align: center;
}
.otp__badge { display: grid; place-items: center; width: 56px; height: 56px; margin: 0 auto; border-radius: 50%; background-color: color-mix(in srgb, var(--ink) 8%, transparent); }
.otp__badge svg { width: 26px; height: 26px; fill: none; stroke: var(--ink); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.otp__title { margin: 24px 0 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.otp__lead { margin: 8px 0 0; color: var(--mute-small); font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; }
.otp__lead strong { color: var(--ink); font-weight: 600; }
/* 見えない input を枠の列の上に重ね、押した所がどこでも打てるようにする */
.otp__field { position: relative; width: fit-content; margin: 32px auto 0; }
.otp__input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: transparent;
caret-color: transparent;
font-size: 16px; /* 16px より小さいと、iPhone がフォーカスで拡大する */
outline: none;
}
.otp__input::selection { background: transparent; }
/* 自動入力で付く地の色と文字を出さない */
.otp__input:-webkit-autofill { -webkit-text-fill-color: transparent; transition: background-color 0s 600000s; }
.otp__boxes { position: relative; display: flex; align-items: center; gap: 12px; }
.otp__box {
position: relative;
display: grid;
place-items: center;
width: var(--box-width);
height: var(--box-height);
box-sizing: border-box;
overflow: hidden; /* せり上がる数字を枠で切る */
border: 1px solid var(--rule);
border-radius: 4px;
background-color: var(--field);
transition: border-color 0.15s;
}
.otp__box.is-filled { border-color: var(--mute); }
.otp__digit { font-size: calc(var(--box-height) * 0.5); font-weight: 700; font-variant-numeric: tabular-nums; visibility: hidden; opacity: 0; }
.otp__dash { flex: none; width: 16px; height: 2px; margin: 0 4px; background-color: var(--mute); }
/* フォーカスの輪とキャレットは 1 つずつ置き、JS が x で次の枠へ滑らせる */
.otp__ring, .otp__caret { position: absolute; left: 0; top: 0; opacity: 0; pointer-events: none; transition: opacity 0.15s; }
.otp__ring { width: var(--box-width); height: var(--box-height); box-sizing: border-box; border: 1px solid var(--ink); border-radius: 4px; box-shadow: 0 0 0 4px var(--rule); }
.otp__caret { left: calc(var(--box-width) / 2 - 1px); top: calc(var(--box-height) * 0.28); width: 2px; height: calc(var(--box-height) * 0.44); background-color: var(--ink); }
.otp__field:focus-within .otp__ring { opacity: 1; }
.otp:not(.is-full) .otp__field:focus-within .otp__caret { opacity: 1; animation: otp-blink 1s steps(1) infinite; }
.otp.is-verified .otp__ring { opacity: 0; }
@keyframes otp-blink { 50% { opacity: 0; } }
/* 案内と「確認できました」を同じ場所に重ね、JS が入れ替える */
.otp__status { display: grid; margin: 24px 0 0; font-size: 14px; line-height: 32px; letter-spacing: 0.04em; }
.otp__status > span { grid-area: 1 / 1; }
.otp__note { color: var(--mute-small); }
.otp__note.is-error { color: var(--error); }
.otp__done { display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--success-text); font-weight: 600; visibility: hidden; opacity: 0; }
.otp__done svg { width: 16px; height: 16px; fill: none; stroke: var(--success); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.otp__footer { margin: 24px 0 0; padding-top: 24px; border-top: 1px solid var(--rule); color: var(--mute-small); font-size: 13px; line-height: 1.8; letter-spacing: 0.04em; }
.otp__footer a { color: var(--ink); font-weight: 600; text-decoration: underline 1px; text-underline-offset: 0.3em; }
.otp__footer a:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
@media (max-width: 480px) {
.otp { --box-width: 40px; --box-height: 56px; padding: 32px 16px; }
.otp__boxes { gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.otp__box, .otp__ring, .otp__caret { transition: none; }
.otp:not(.is-full) .otp__field:focus-within .otp__caret { animation: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const t = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、補間せずに切り替える
const hop = (p) => Math.sin(Math.PI * p); // 上がって元の位置に戻る(p = 0 と 1 で 0)
const shake = (p) => (1 - p) * Math.sin(p * Math.PI * 8); // 4 往復して止まる
// 跳ねと震えは、長さ 0 だと ease を通さずに終わりの値(上や横にずれたまま)で止まるので、動きを減らす設定では動かさない
// コードを確かめる。form の action に送り、2xx なら正しいとみなす(サーバーの返し方に合わせて書き替える)
const check = (form) => fetch(form.action, { method: "POST", body: new FormData(form) }).then((res) => res.ok, () => false);
document.querySelectorAll(".otp").forEach((form) => {
const input = form.querySelector(".otp__input");
const row = form.querySelector(".otp__boxes");
const boxes = [...row.querySelectorAll(".otp__box")];
const digits = boxes.map((box) => box.firstElementChild);
const [ring, caret] = [".otp__ring", ".otp__caret"].map((selector) => row.querySelector(selector));
const [note, done] = form.querySelectorAll(".otp__status > span");
const hint = note.textContent;
let shown = ""; // 枠に出している数字
let busy = false;
// 輪とキャレットを、キャレットのある枠へ滑らせる(6 つそろったら最後の枠)
const place = () => {
const i = Math.min(input.selectionStart ?? shown.length, boxes.length - 1);
gsap.to([ring, caret], { x: boxes[i].offsetLeft, duration: t(0.18), ease: "power3.out", overwrite: true });
};
// 値と枠の数字を比べ、変わった枠だけ動かす。入った数字は下からせり上げ(貼り付けたら左から順に)、消えた数字は下へ落とす
function sync() {
const value = input.value.replace(/\D/g, "").slice(0, boxes.length);
if (value !== input.value) input.value = value; // 貼り付けた「482 917」の空白や、7 つ目の数字を除く
if (value && note.classList.contains("is-error")) {
note.textContent = hint;
note.classList.remove("is-error");
}
const rise = Math.round(boxes[0].offsetHeight * 0.55);
let order = 0;
boxes.forEach((box, i) => {
const next = value[i] ?? "";
if (next === (shown[i] ?? "")) return;
box.classList.toggle("is-filled", next !== "");
if (!next) return gsap.to(digits[i], { y: rise / 2, autoAlpha: 0, duration: t(0.15), ease: "power2.in", overwrite: true });
digits[i].textContent = next;
gsap.fromTo(digits[i], { y: rise, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: t(0.28), delay: t(0.05 * order++), ease: "power3.out", overwrite: true });
});
shown = value;
form.classList.toggle("is-full", value.length === boxes.length);
place();
if (value.length === boxes.length) verify();
}
// 6 つそろったら確かめる。確かめている間は書き換えさせない
async function verify() {
if (busy) return;
busy = input.readOnly = true;
if (await check(form)) return pass();
busy = input.readOnly = false;
note.textContent = form.dataset.error;
note.classList.add("is-error");
gsap.fromTo(row, { x: 0 }, { x: reduceMotion ? 0 : 10, duration: t(0.5), ease: shake });
input.value = "";
sync();
input.focus();
}
// 確認できた: 輪を消し(CSS)、枠が左から順に跳ねながら緑に色づき、案内が替わる
function pass() {
const color = (name) => getComputedStyle(form).getPropertyValue(name).trim();
form.classList.add("is-verified");
done.append(done.dataset.text); // 読み上げに届くよう、見せる時に文を入れる
gsap
.timeline()
.to(boxes, { backgroundColor: color("--success-field"), borderColor: color("--success"), duration: t(0.2), ease: "none", stagger: t(0.06) }, 0)
.to(boxes, { y: reduceMotion ? 0 : -12, duration: t(0.4), ease: hop, stagger: t(0.06) }, 0)
.to(note, { autoAlpha: 0, y: -8, duration: t(0.2), ease: "power2.in" }, t(0.15))
// 前の案内が消えきってから出す(同じ場所で 2 つの文が重なると読めない)
.fromTo(done, { autoAlpha: 0, y: 8 }, { autoAlpha: 1, y: 0, duration: t(0.3), ease: "power2.out" }, t(0.35));
// ここでログインしたあとのページへ移る(location.assign など)
}
input.addEventListener("input", sync);
// 押したら末尾(次に入る枠)から打てるようにする。矢印キーで動かしたときは、輪がキャレットについていく
input.addEventListener("click", () => input.setSelectionRange(shown.length, shown.length));
["click", "focus", "keyup"].forEach((type) => input.addEventListener(type, place));
window.addEventListener("resize", place);
// Enter で送られたときも、そろっていれば同じ確かめ方にする(そろっていなければブラウザが知らせる)
form.addEventListener("submit", (event) => {
event.preventDefault();
if (shown.length === boxes.length) verify();
});
sync(); // 自動入力や、戻ってきたページに値が残っていたときも枠に出す
});