OTP(ワンタイムコード)入力

6桁の認証コードを1桁ずつ入力するUI。自動フォーカス移動・ペースト分配・Backspace戻り・照合とシェイク演出を備えた二段階認証向けです。

#javascript#form#auth

ライブデモ

使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。