ログインフォーム(Login Form)

フォーム入力 初級

このコンポーネントについて

ログインフォームは、メールアドレスとパスワードを入力してサービスにサインインするUIの基本パターンです。 パスワード欄には表示/非表示トグルを設け、入力ミスを減らす配慮を加えています。

送信中はボタンをローディング状態にして二重送信を防止し、認証成功・失敗それぞれの状態を視覚的に表現します。 認証ロジックは含まず、UIの状態遷移に特化した実装です。 このデモでは「送信結果プレビュー」を切り替えて成功・失敗どちらの動作も確認できます。

  • パスワード表示/非表示トグル — 目アイコンボタンで type="password"type="text" を切り替え
  • 送信時バリデーション — ボタン押下時にメールアドレス(必須・形式)とパスワード(必須)をチェック。フィールド下にエラーメッセージを表示
  • 送信中ローディング — 送信ボタンをスピナー表示+disabled に変更し、二重送信を防止
  • 成功状態 — ボタンがチェックマーク表示に変わり「ログイン中...」テキストを表示
  • 認証失敗バナー — フォーム上部に「メールアドレスまたはパスワードが違います」エラーバナーを表示
  • ログインを保持する — チェックボックスで保持設定を選択(UIのみ)

実装のポイント・注意点

バリデーションは送信ボタン押下時のみ行います。フォーカスアウト時にリアルタイムで判定する方式と比べてシンプルで、ログインフォームの標準的なUXです。 再送信の場合は前回のエラー状態をクリアしてから再バリデーションすることで、エラーメッセージが二重に出ないようにします。

送信中のローディング表示はボタンを disabled にして操作を無効化することが重要です。 これにより同じフォームが2回送信される「二重送信」を防げます。 スピナーアニメーションは @keyframes で実装しており、外部ライブラリ不要です。

送信完了後(成功・失敗いずれも)はボタンを通常状態に戻して再送信できるようにします。 成功時はフォーム上部に緑色のバナーでフィードバックを表示します。 実際のアプリでは成功後に別ページへリダイレクトすることが多いため、バナー表示はデモ向けの確認用として設けています。

認証失敗時のエラーバナーは「どのフィールドが間違いか」を区別しません。 セキュリティ上、「メールアドレスが存在しない」「パスワードが違う」を区別して表示すると、メールアドレスの存在確認に悪用されるリスクがあるためです。 フォーム上部に一括表示するパターンがベストプラクティスです。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ログインフォーム サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="login-card">
  <!-- 認証成功バナー(送信成功時に表示) -->
  <div class="login-success-banner" id="loginSuccessBanner" hidden>
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
      <polyline points="20 6 9 17 4 12"/>
    </svg>
    <span>ログインに成功しました。</span>
  </div>

  <!-- 認証エラーバナー(送信失敗時に表示) -->
  <div class="login-error-banner" id="loginErrorBanner" hidden>
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
      <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
    </svg>
    <span>メールアドレスまたはパスワードが違います。</span>
  </div>

  <form class="login-form" id="loginForm" novalidate>
    <!-- メールアドレス -->
    <div class="login-field">
      <label class="login-label" for="email">メールアドレス</label>
      <input class="login-input" type="email" id="email" name="email"
             placeholder="[email protected]" autocomplete="email">
      <span class="login-field-error" id="emailError"></span>
    </div>

    <!-- パスワード -->
    <div class="login-field">
      <label class="login-label" for="password">パスワード</label>
      <div class="login-password-wrap">
        <input class="login-input" type="password" id="password" name="password"
               autocomplete="current-password">
        <button class="login-toggle-pw" type="button" id="togglePw"
                aria-label="パスワードを表示">
          <!-- 目を開くアイコン(パスワード非表示中に表示) -->
          <svg class="icon-eye" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
            <circle cx="12" cy="12" r="3"/>
          </svg>
          <!-- 目を閉じるアイコン(パスワード表示中に表示) -->
          <svg class="icon-eye-off" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" hidden aria-hidden="true">
            <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
            <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
            <line x1="1" y1="1" x2="23" y2="23"/>
          </svg>
        </button>
      </div>
      <span class="login-field-error" id="passwordError"></span>
    </div>

    <!-- ログインを保持する -->
    <div class="login-remember">
      <label class="login-remember-label">
        <input type="checkbox" id="rememberMe" name="rememberMe">
        ログインを保持する
      </label>
    </div>

    <!-- 送信ボタン -->
    <button class="login-btn" type="submit" id="loginBtn">
      <svg class="login-btn-spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" hidden aria-hidden="true">
        <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
      </svg>
      <span class="login-btn-text">ログイン</span>
    </button>
  </form>
</div>

<script src="./script.js"></script>
</body>
</html>
:root {
  --color-primary: #2B7FE8;
  --color-primary-dark: #1E6ECF;
  --color-error: #E53E3E;
  --color-error-bg: #FFF5F5;
  --color-border: #D0D7E0;
  --color-text: #1A2B3C;
  --color-muted: #5A6A7A;
}

* { box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #F4F6F9;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  margin: 0;
  padding: 16px;
}

/* ===カード=== */
.login-card {
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
  padding: 32px;
  width: 100%;
  max-width: 400px;
}

/* ===認証エラーバナー=== */
.login-error-banner {
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--color-error-bg);
  border: 1px solid #FEB2B2;
  border-radius: 8px;
  padding: 10px 14px;
  margin-bottom: 20px;
  color: var(--color-error);
  font-size: 14px;
}
/* hidden属性が display:flex に上書きされないよう打ち消す */
.login-error-banner[hidden] { display: none; }

/* ===認証成功バナー=== */
.login-success-banner {
  display: flex;
  align-items: center;
  gap: 8px;
  background: #F0FFF4;
  border: 1px solid #9AE6B4;
  border-radius: 8px;
  padding: 10px 14px;
  margin-bottom: 20px;
  color: #276749;
  font-size: 14px;
}
.login-success-banner[hidden] { display: none; }

/* ===フィールド=== */
.login-field { margin-bottom: 20px; }

.login-label {
  display: block;
  font-size: 14px;
  font-weight: 600;
  color: var(--color-text);
  margin-bottom: 6px;
}

.login-input {
  width: 100%;
  padding: 10px 12px;
  font-size: 15px;
  border: 1.5px solid var(--color-border);
  border-radius: 8px;
  outline: none;
  transition: border-color 0.15s;
  font-family: inherit;
}

.login-input:focus { border-color: var(--color-primary); }
.login-input.is-error { border-color: var(--color-error); }

.login-field-error {
  display: block;
  font-size: 12px;
  color: var(--color-error);
  margin-top: 4px;
}

/* ===パスワードトグル=== */
.login-password-wrap { position: relative; }
.login-password-wrap .login-input { padding-right: 44px; }

.login-toggle-pw {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  color: var(--color-muted);
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}

.login-toggle-pw:hover { color: var(--color-primary); }

/* ===ログインを保持する=== */
.login-remember { margin-bottom: 24px; }

.login-remember-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: var(--color-muted);
  cursor: pointer;
}

/* ===送信ボタン=== */
.login-btn {
  width: 100%;
  padding: 12px;
  background: var(--color-primary);
  color: #fff;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: background 0.15s;
  font-family: inherit;
}

.login-btn:hover:not(:disabled) { background: var(--color-primary-dark); }
.login-btn:disabled { opacity: 0.7; cursor: not-allowed; }

/* スピナーアニメーション */
@keyframes spin { to { transform: rotate(360deg); } }
.login-btn-spinner { animation: spin 0.8s linear infinite; }

/* SVGのhidden属性はブラウザによっては効かないことがある(初期非表示の保険) */
.login-btn-spinner[hidden],
.icon-eye-off[hidden] { display: none; }

@media (max-width: 480px) {
  .login-card { padding: 24px; }
}
// ===== 動作確認の切り替え =====
// true → 送信成功をシミュレート、false → 送信失敗をシミュレート
// 両方の動作を確認したら、この定数と下の setTimeout を実際の認証API呼び出しに差し替えてください
var SIMULATE_SUCCESS = true;
var FAKE_LOADING_MS  = 1200;

var loginForm     = document.getElementById('loginForm');
var emailInput    = document.getElementById('email');
var passwordInput = document.getElementById('password');
var emailError    = document.getElementById('emailError');
var passwordError = document.getElementById('passwordError');
var errorBanner   = document.getElementById('loginErrorBanner');
var loginBtn      = document.getElementById('loginBtn');
var btnText       = loginBtn.querySelector('.login-btn-text');
var btnSpinner    = loginBtn.querySelector('.login-btn-spinner');
var successBanner = document.getElementById('loginSuccessBanner');
var togglePw      = document.getElementById('togglePw');
var eyeIcon       = togglePw.querySelector('.icon-eye');
var eyeOffIcon    = togglePw.querySelector('.icon-eye-off');

// SVG の表示切替(element.hidden は SVGElement では動作しないため style.display を使う)
function showSvg(el) { el.style.display = 'inline'; }
function hideSvg(el) { el.style.display = 'none'; }

function validateEmail(val) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val);
}

function setFieldError(input, errorEl, message) {
  input.classList.add('is-error');
  errorEl.textContent = message;
}

function clearFieldError(input, errorEl) {
  input.classList.remove('is-error');
  errorEl.textContent = '';
}

function validate() {
  var ok = true;
  if (!emailInput.value.trim()) {
    setFieldError(emailInput, emailError, 'メールアドレスを入力してください');
    ok = false;
  } else if (!validateEmail(emailInput.value.trim())) {
    setFieldError(emailInput, emailError, '正しいメールアドレス形式で入力してください');
    ok = false;
  } else {
    clearFieldError(emailInput, emailError);
  }
  if (!passwordInput.value) {
    setFieldError(passwordInput, passwordError, 'パスワードを入力してください');
    ok = false;
  } else {
    clearFieldError(passwordInput, passwordError);
  }
  return ok;
}

function setLoading() {
  loginBtn.disabled = true;
  btnText.hidden = true;
  showSvg(btnSpinner);
}

function setSuccess() {
  hideSvg(btnSpinner);
  btnText.textContent = 'ログイン';
  btnText.hidden = false;
  loginBtn.disabled = false;
  successBanner.hidden = false;
}

function setFailure() {
  hideSvg(btnSpinner);
  btnText.hidden = false;
  loginBtn.disabled = false;
  errorBanner.hidden = false;
}

loginForm.addEventListener('submit', function (e) {
  e.preventDefault();
  clearFieldError(emailInput, emailError);
  clearFieldError(passwordInput, passwordError);
  errorBanner.hidden = true;
  successBanner.hidden = true;

  if (!validate()) return;

  setLoading();

  // 疑似レスポンス(実際の認証APIに差し替える)
  setTimeout(function () {
    if (SIMULATE_SUCCESS) {
      setSuccess();
    } else {
      setFailure();
    }
  }, FAKE_LOADING_MS);
});

// type="text" 切替時に IME が有効になるため、確定時に非ASCII文字を除去する
passwordInput.addEventListener('compositionend', function () {
  this.value = this.value.replace(/[^\x20-\x7E]/g, '');
});

// パスワードトグル
togglePw.addEventListener('click', function () {
  var isVisible = passwordInput.type === 'text';
  if (isVisible) {
    passwordInput.type = 'password';
    showSvg(eyeIcon);
    hideSvg(eyeOffIcon);
    togglePw.setAttribute('aria-label', 'パスワードを表示');
  } else {
    passwordInput.type = 'text';
    hideSvg(eyeIcon);
    showSvg(eyeOffIcon);
    togglePw.setAttribute('aria-label', 'パスワードを非表示');
  }
});

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や要件変更など、条件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# ログインフォーム 作成依頼

## 概要
メールアドレスとパスワードで構成するログインフォームを作成してください。
認証ロジックは不要で、UIの状態遷移(バリデーション・ローディング・成功・失敗)の実装が目的です。

## 要件
- メールアドレスフィールド(必須・形式チェック)
- パスワードフィールド(必須)+表示/非表示トグルボタン(目アイコン)
- 「ログインを保持する」チェックボックス(UIのみ)
- 送信ボタン押下時のバリデーション(フィールド下にエラーメッセージを表示)
- 送信中はボタンをスピナー表示+disabled にして二重送信を防止
- 成功時:フォーム上部に緑色の「ログインに成功しました。」バナーを表示。ボタンを通常状態に戻して再送信可能にする
- 失敗時:フォーム上部に「メールアドレスまたはパスワードが違います」エラーバナーを表示。ボタンを通常状態に戻して再入力・再送信可能にする
- 白背景・角丸・影のカード型デザイン

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(最小幅 320px)

## 動作詳細
送信ボタン押下 → バリデーション → 通過したらローディング状態(1〜2秒)→ 成功/失敗を表示。
成功・失敗いずれの場合もボタンを通常状態に戻し、再送信できる状態にする。
バリデーションエラーは再送信時にリセットしてから再チェックする。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。