OTP(ワンタイムコード)入力
6桁の認証コードを1桁ずつ入力するUI。自動フォーカス移動・ペースト分配・Backspace戻り・照合とシェイク演出を備えた二段階認証向けです。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<div class="fd-screen">
<div class="otp-card">
<div class="fd-brand"><span class="fd-mark">◇</span> FlowDesk</div>
<div class="otp-shield">🔐</div>
<h2 class="otp-title">二段階認証</h2>
<p class="otp-sub">登録メール <b>you@company.com</b> に送信した<br>6桁の確認コードを入力してください</p>
<!-- 6桁の1桁入力(技法の主役) -->
<div class="otp-inputs" id="otp-inputs">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" aria-label="桁1">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁2">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁3">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁4">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁5">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁6">
</div>
<p class="otp-status" id="otp-status">テスト用コード「123456」を入力</p>
<button class="otp-resend" type="button">コードを再送する</button>
</div>
</div>
CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background:
radial-gradient(80% 60% at 15% 0%, rgba(79, 124, 255, 0.32), transparent 60%),
#0f1b34;
color: #e8edf7;
}
.otp-card {
width: min(380px, 92vw);
padding: 24px 28px 22px;
text-align: center;
background: #16244a;
border: 1px solid rgba(120, 150, 220, 0.22);
border-radius: 16px;
box-shadow: 0 26px 60px -24px rgba(0, 0, 0, 0.8);
}
.fd-brand {
display: flex; align-items: center; justify-content: center; gap: 7px;
font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; color: #9fb6f0;
}
.fd-mark { color: #4f7cff; font-size: 1rem; }
.otp-shield {
width: 50px; height: 50px; margin: 14px auto 10px;
display: grid; place-items: center; font-size: 1.4rem;
background: rgba(79, 124, 255, 0.16); border-radius: 14px;
}
.otp-title { margin: 0 0 6px; font-size: 1.15rem; font-weight: 800; }
.otp-sub { margin: 0 0 18px; font-size: 0.76rem; line-height: 1.6; color: #8ea0c4; }
.otp-sub b { color: #c6d3ef; }
/* 6桁セル */
.otp-inputs { display: flex; gap: 9px; justify-content: center; margin-bottom: 14px; }
.otp-cell {
width: 42px; height: 52px;
font-size: 1.4rem; font-weight: 700; text-align: center;
color: #eef2fb;
background: #0f1b34;
border: 1.5px solid rgba(120, 150, 220, 0.32); border-radius: 11px;
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.1s ease;
}
.otp-cell:focus { border-color: #4f7cff; box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.28); }
.otp-cell.filled { border-color: #6e8fe6; }
.otp-inputs.is-ok .otp-cell { border-color: #34d399; box-shadow: 0 0 0 2px rgba(52, 211, 153, 0.3); }
.otp-inputs.is-err { animation: otp-shake 0.4s; }
.otp-inputs.is-err .otp-cell { border-color: #f87171; }
@keyframes otp-shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}
.otp-status { margin: 0 0 12px; font-size: 0.8rem; color: #8ea0c4; }
.otp-status.ok { color: #34d399; font-weight: 700; }
.otp-status.err { color: #f87171; font-weight: 700; }
.otp-resend {
font-size: 0.76rem; font-weight: 600; color: #9fb6f0;
background: none; border: none; cursor: pointer; text-decoration: underline;
}
@media (prefers-reduced-motion: reduce) {
.otp-cell { transition: none; }
.otp-inputs.is-err { animation: none; }
}
JavaScript
const wrap = document.getElementById("otp-inputs");
const status = document.getElementById("otp-status");
const resend = document.querySelector(".otp-resend");
const cells = wrap ? [...wrap.querySelectorAll(".otp-cell")] : [];
const CORRECT = "123456"; // デモ用の正解コード
// 数字のみ許可し、入力後に次へフォーカス移動
cells.forEach((cell, i) => {
cell.addEventListener("input", () => {
cell.value = cell.value.replace(/\D/g, "").slice(0, 1);
cell.classList.toggle("filled", cell.value !== "");
if (cell.value && i < cells.length - 1) cells[i + 1].focus();
checkComplete();
});
// Backspaceで空セルなら前へ戻る
cell.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !cell.value && i > 0) cells[i - 1].focus();
if (e.key === "ArrowLeft" && i > 0) cells[i - 1].focus();
if (e.key === "ArrowRight" && i < cells.length - 1) cells[i + 1].focus();
});
// ペースト時は各桁へ振り分け
cell.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "");
if (!text) return;
cells.forEach((c, j) => {
if (j >= i && text[j - i] !== undefined) {
c.value = text[j - i];
c.classList.add("filled");
}
});
const next = Math.min(i + text.length, cells.length - 1);
cells[next].focus();
checkComplete();
});
});
// 全桁そろったら照合
function checkComplete() {
const code = cells.map((c) => c.value).join("");
wrap.classList.remove("is-ok", "is-err");
status.classList.remove("ok", "err");
if (code.length < cells.length) {
status.textContent = "テスト用コード「123456」を入力";
return;
}
if (code === CORRECT) {
wrap.classList.add("is-ok");
status.classList.add("ok");
status.textContent = "認証に成功しました ✓ ダッシュボードへ移動します";
} else {
wrap.classList.add("is-err");
status.classList.add("err");
status.textContent = "コードが正しくありません";
// エラー後はリセットして再入力しやすく
setTimeout(() => {
cells.forEach((c) => { c.value = ""; c.classList.remove("filled"); });
wrap.classList.remove("is-err");
cells[0].focus();
}, 900);
}
}
// 再送ボタン(実送信はしない)
if (resend && status) {
resend.addEventListener("click", () => {
status.classList.remove("err");
status.textContent = "確認コードを再送しました(デモ)";
});
}
コード
HTML
<div class="stage">
<div class="otp-card">
<div class="otp-icon">✉</div>
<h2 class="otp-title">認証コードを入力</h2>
<p class="otp-sub">メールに届いた6桁のコードを入力してください</p>
<!-- 6つの1桁入力。inputmode=numericで数字キーボード -->
<div class="otp-inputs" id="otp-inputs">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" aria-label="桁1">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁2">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁3">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁4">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁5">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁6">
</div>
<p class="otp-status" id="otp-status">ヒント: コードは「123456」</p>
</div>
</div>
CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
color: #1f2937;
}
.stage { width: 100%; padding: 22px; display: grid; place-items: center; }
.otp-card {
width: min(380px, 92vw);
padding: 30px 26px 26px;
text-align: center;
background: #fff;
border-radius: 18px;
box-shadow: 0 24px 60px -26px rgba(30, 41, 59, 0.45);
}
.otp-icon {
width: 52px; height: 52px;
margin: 0 auto 14px;
display: grid; place-items: center;
font-size: 1.4rem;
color: #fff;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 14px;
box-shadow: 0 12px 24px -10px rgba(99, 102, 241, 0.7);
}
.otp-title { margin: 0 0 6px; font-size: 1.15rem; color: #1e293b; }
.otp-sub { margin: 0 0 22px; font-size: 0.82rem; color: #64748b; line-height: 1.5; }
.otp-inputs { display: flex; justify-content: center; gap: 9px; margin-bottom: 18px; }
.otp-cell {
width: 46px; height: 56px;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
color: #1e293b;
background: #f1f5f9;
border: 2px solid #e2e8f0;
border-radius: 12px;
outline: none;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.1s ease;
}
.otp-cell:focus {
border-color: #6366f1;
background: #fff;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.16);
transform: translateY(-2px);
}
.otp-cell.filled { border-color: #a5b4fc; background: #eef2ff; }
/* 成否の状態カラー */
.otp-inputs.is-ok .otp-cell { border-color: #34d399; background: #ecfdf5; }
.otp-inputs.is-err .otp-cell { border-color: #f87171; background: #fef2f2; }
.otp-inputs.is-err { animation: shake 0.4s ease; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-7px); }
40%, 80% { transform: translateX(7px); }
}
.otp-status { margin: 0; min-height: 18px; font-size: 0.82rem; color: #64748b; font-weight: 600; transition: color 0.2s ease; }
.otp-status.ok { color: #059669; }
.otp-status.err { color: #dc2626; }
@media (prefers-reduced-motion: reduce) {
.otp-cell { transition: none; }
.otp-inputs.is-err { animation: none; }
}
JavaScript
const wrap = document.getElementById("otp-inputs");
const status = document.getElementById("otp-status");
const cells = wrap ? [...wrap.querySelectorAll(".otp-cell")] : [];
const CORRECT = "123456"; // デモ用の正解コード
// 数字のみ許可し、入力後に次へフォーカス移動
cells.forEach((cell, i) => {
cell.addEventListener("input", () => {
cell.value = cell.value.replace(/\D/g, "").slice(0, 1);
cell.classList.toggle("filled", cell.value !== "");
if (cell.value && i < cells.length - 1) cells[i + 1].focus();
checkComplete();
});
// Backspaceで空セルなら前へ戻る
cell.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !cell.value && i > 0) {
cells[i - 1].focus();
}
if (e.key === "ArrowLeft" && i > 0) cells[i - 1].focus();
if (e.key === "ArrowRight" && i < cells.length - 1) cells[i + 1].focus();
});
// ペースト時は各桁へ振り分け
cell.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "");
if (!text) return;
cells.forEach((c, j) => {
if (j >= i && text[j - i] !== undefined) {
c.value = text[j - i];
c.classList.add("filled");
}
});
const next = Math.min(i + text.length, cells.length - 1);
cells[next].focus();
checkComplete();
});
});
// 全桁そろったら照合
function checkComplete() {
const code = cells.map((c) => c.value).join("");
wrap.classList.remove("is-ok", "is-err");
status.classList.remove("ok", "err");
if (code.length < cells.length) {
status.textContent = "ヒント: コードは「123456」";
return;
}
if (code === CORRECT) {
wrap.classList.add("is-ok");
status.classList.add("ok");
status.textContent = "認証に成功しました ✓";
} else {
wrap.classList.add("is-err");
status.classList.add("err");
status.textContent = "コードが正しくありません";
// エラー後はリセットして再入力しやすく
setTimeout(() => {
cells.forEach((c) => { c.value = ""; c.classList.remove("filled"); });
wrap.classList.remove("is-err");
cells[0].focus();
}, 900);
}
}
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「OTP(ワンタイムコード)入力」の効果を追加してください。
# 追加してほしい効果
OTP(ワンタイムコード)入力(フォーム & 入力)
6桁の認証コードを1桁ずつ入力するUI。自動フォーカス移動・ペースト分配・Backspace戻り・照合とシェイク演出を備えた二段階認証向けです。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="stage">
<div class="otp-card">
<div class="otp-icon">✉</div>
<h2 class="otp-title">認証コードを入力</h2>
<p class="otp-sub">メールに届いた6桁のコードを入力してください</p>
<!-- 6つの1桁入力。inputmode=numericで数字キーボード -->
<div class="otp-inputs" id="otp-inputs">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" autocomplete="one-time-code" aria-label="桁1">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁2">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁3">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁4">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁5">
<input class="otp-cell" type="text" inputmode="numeric" maxlength="1" aria-label="桁6">
</div>
<p class="otp-status" id="otp-status">ヒント: コードは「123456」</p>
</div>
</div>
【CSS】
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
color: #1f2937;
}
.stage { width: 100%; padding: 22px; display: grid; place-items: center; }
.otp-card {
width: min(380px, 92vw);
padding: 30px 26px 26px;
text-align: center;
background: #fff;
border-radius: 18px;
box-shadow: 0 24px 60px -26px rgba(30, 41, 59, 0.45);
}
.otp-icon {
width: 52px; height: 52px;
margin: 0 auto 14px;
display: grid; place-items: center;
font-size: 1.4rem;
color: #fff;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 14px;
box-shadow: 0 12px 24px -10px rgba(99, 102, 241, 0.7);
}
.otp-title { margin: 0 0 6px; font-size: 1.15rem; color: #1e293b; }
.otp-sub { margin: 0 0 22px; font-size: 0.82rem; color: #64748b; line-height: 1.5; }
.otp-inputs { display: flex; justify-content: center; gap: 9px; margin-bottom: 18px; }
.otp-cell {
width: 46px; height: 56px;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
color: #1e293b;
background: #f1f5f9;
border: 2px solid #e2e8f0;
border-radius: 12px;
outline: none;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.1s ease;
}
.otp-cell:focus {
border-color: #6366f1;
background: #fff;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.16);
transform: translateY(-2px);
}
.otp-cell.filled { border-color: #a5b4fc; background: #eef2ff; }
/* 成否の状態カラー */
.otp-inputs.is-ok .otp-cell { border-color: #34d399; background: #ecfdf5; }
.otp-inputs.is-err .otp-cell { border-color: #f87171; background: #fef2f2; }
.otp-inputs.is-err { animation: shake 0.4s ease; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-7px); }
40%, 80% { transform: translateX(7px); }
}
.otp-status { margin: 0; min-height: 18px; font-size: 0.82rem; color: #64748b; font-weight: 600; transition: color 0.2s ease; }
.otp-status.ok { color: #059669; }
.otp-status.err { color: #dc2626; }
@media (prefers-reduced-motion: reduce) {
.otp-cell { transition: none; }
.otp-inputs.is-err { animation: none; }
}
【JavaScript】
const wrap = document.getElementById("otp-inputs");
const status = document.getElementById("otp-status");
const cells = wrap ? [...wrap.querySelectorAll(".otp-cell")] : [];
const CORRECT = "123456"; // デモ用の正解コード
// 数字のみ許可し、入力後に次へフォーカス移動
cells.forEach((cell, i) => {
cell.addEventListener("input", () => {
cell.value = cell.value.replace(/\D/g, "").slice(0, 1);
cell.classList.toggle("filled", cell.value !== "");
if (cell.value && i < cells.length - 1) cells[i + 1].focus();
checkComplete();
});
// Backspaceで空セルなら前へ戻る
cell.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !cell.value && i > 0) {
cells[i - 1].focus();
}
if (e.key === "ArrowLeft" && i > 0) cells[i - 1].focus();
if (e.key === "ArrowRight" && i < cells.length - 1) cells[i + 1].focus();
});
// ペースト時は各桁へ振り分け
cell.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "");
if (!text) return;
cells.forEach((c, j) => {
if (j >= i && text[j - i] !== undefined) {
c.value = text[j - i];
c.classList.add("filled");
}
});
const next = Math.min(i + text.length, cells.length - 1);
cells[next].focus();
checkComplete();
});
});
// 全桁そろったら照合
function checkComplete() {
const code = cells.map((c) => c.value).join("");
wrap.classList.remove("is-ok", "is-err");
status.classList.remove("ok", "err");
if (code.length < cells.length) {
status.textContent = "ヒント: コードは「123456」";
return;
}
if (code === CORRECT) {
wrap.classList.add("is-ok");
status.classList.add("ok");
status.textContent = "認証に成功しました ✓";
} else {
wrap.classList.add("is-err");
status.classList.add("err");
status.textContent = "コードが正しくありません";
// エラー後はリセットして再入力しやすく
setTimeout(() => {
cells.forEach((c) => { c.value = ""; c.classList.remove("filled"); });
wrap.classList.remove("is-err");
cells[0].focus();
}, 900);
}
}
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。