Open demo

CSS code

HTML
<div class="lf">
<main class="lf-box">
  <div class="lf-mark" aria-hidden="true">N</div>
  <h1>Sign in</h1>
  <p class="lf-sub">Welcome back to Northwind.</p>
  <div class="lf-alert" role="alert">
    <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
    <span><strong>That combination didn't work.</strong>You have 3 attempts left before the account
      is locked for 15 minutes.</span>
  </div>
  <form id="lf-form" novalidate>
    <div class="lf-grp">
      <label class="lf-label" for="vmail">Email address</label>
      <input class="lf-field" id="vmail" name="username" type="email" value="dana@northwind" autocomplete="username"
             aria-invalid="true" aria-describedby="vmail-err" required>
      <p class="lf-err" id="vmail-err">
        <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
        Enter a complete email address, like [email protected].</p>
    </div>
    <div class="lf-grp">
      <label class="lf-label" for="vpw">Password</label>
      <input class="lf-field" id="vpw" name="password" type="password" autocomplete="current-password" placeholder="••••••••"
             aria-describedby="caps" required>
      <p class="lf-caps" id="caps" role="status"></p>
    </div>
    <div class="lf-row">
      <label class="lf-check"><input type="checkbox" name="remember"> Keep me signed in</label>
      <a href="#">Forgot password?</a>
    </div>
    <button class="lf-btn" type="submit">Sign in</button>
  </form>
</main>
</div>
CSS
/* Login with three kinds of error: a form-level alert (for a failed sign-in your server reports), a field error
   tied to the input with aria-invalid and aria-describedby, and a Caps Lock warning in a polite live region. */
.lf{--lf-brand:#4f46e5;--lf-brand-dark:#4338ca;--lf-ink:#111827;--lf-muted:#6b7280;--lf-line:#e5e7eb;--lf-edge:#868c99;--lf-bg:#f5f6fa;--lf-bad:#dc2626;
  min-height:100vh;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:28px 20px;
  background:var(--lf-bg);color:var(--lf-ink);line-height:normal;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
.lf,.lf *,.lf *::before,.lf *::after{box-sizing:border-box}
.lf :where(h1,p,form){margin:0}
.lf :where(input,button){font:inherit}
.lf [hidden]{display:none!important}
.lf a{color:var(--lf-brand);text-decoration:none;font-weight:600}
.lf a:hover{text-decoration:underline}
.lf :focus-visible{outline:2px solid var(--lf-brand);outline-offset:2px}
.lf-box{width:100%;max-width:410px;background:#fff;border:1px solid var(--lf-line);border-radius:16px;
  padding:38px 34px;box-shadow:0 10px 34px rgba(17,24,39,.06)}
.lf-mark{width:42px;height:42px;border-radius:12px;background:var(--lf-brand);display:grid;place-items:center;
  color:#fff;font-weight:800;margin-bottom:22px}
.lf h1{font-size:1.4rem;letter-spacing:-.02em;margin-bottom:7px}
.lf-sub{color:var(--lf-muted);font-size:.93rem;line-height:1.6;margin-bottom:20px}
.lf-alert{display:flex;gap:11px;background:#fef2f2;border:1px solid #fecaca;border-radius:11px;
  padding:13px 15px;margin-bottom:24px;font-size:.88rem;color:#991b1b;line-height:1.55}
.lf-alert svg{width:18px;height:18px;stroke:var(--lf-bad);fill:none;stroke-width:2.2;flex-shrink:0;margin-top:1px}
.lf-alert strong{display:block;margin-bottom:2px}
.lf-grp{margin-bottom:18px}
.lf-label{display:block;font-size:.85rem;font-weight:600;margin-bottom:6px}
.lf-field{width:100%;padding:12px 14px;border:1px solid var(--lf-edge);border-radius:10px;font-size:.95rem;
  background:#fff;color:inherit;transition:border-color .15s,box-shadow .15s}
.lf-field::placeholder{color:var(--lf-muted)}
.lf-field:focus{outline:none;border-color:var(--lf-brand);box-shadow:0 0 0 3px rgba(79,70,229,.15)}
.lf-field[aria-invalid="true"]{border-color:var(--lf-bad);background:#fffafa}
.lf-field[aria-invalid="true"]:focus{box-shadow:0 0 0 3px rgba(220,38,38,.14)}
.lf-err{display:flex;align-items:center;gap:6px;font-size:.82rem;color:var(--lf-bad);margin-top:7px;font-weight:600}
.lf-err svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;flex-shrink:0}
.lf-caps{font-size:.82rem;color:#92400e;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;
  padding:7px 11px;margin-top:7px}
.lf-caps:empty{display:none}
.lf-row{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:22px;font-size:.86rem}
.lf-check{display:flex;align-items:center;gap:7px;font-weight:500;color:var(--lf-muted);cursor:pointer}
.lf-check input{margin:0;accent-color:var(--lf-brand)}
.lf-btn{width:100%;padding:13px;border:0;border-radius:10px;background:var(--lf-brand);color:#fff;
  font-size:.97rem;font-weight:700;cursor:pointer;transition:background .15s}
.lf-btn:hover{background:var(--lf-brand-dark)}
@media (prefers-reduced-motion:reduce){.lf-field,.lf-btn{transition:none}}
JavaScript
(() => {
  const form = document.getElementById('lf-form'), mail = document.getElementById('vmail');
  const err = document.getElementById('vmail-err'), pw = document.getElementById('vpw'), caps = document.getElementById('caps');
  if (!form || !mail || !pw) return;
  // type="email" accepts "name@host", so ask for a dot in the domain as well.
  const ok = () => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail.value.trim());
  const show = bad => { mail.setAttribute('aria-invalid', String(bad)); err.hidden = !bad; };
  mail.addEventListener('input', () => { if (ok()) show(false); });
  form.addEventListener('submit', e => {
    e.preventDefault(); // demo: remove this line once the form posts to your server
    if (!ok()) { show(true); mail.focus(); return; }
    if (!pw.value) { pw.focus(); }
  });
  // Caps Lock is the most common cause of a "wrong password" that is not wrong.
  // getModifierState reports it without reading what was typed.
  const check = e => { caps.textContent = e.getModifierState && e.getModifierState('CapsLock') ? 'Caps Lock is on.' : ''; };
  pw.addEventListener('keyup', check);
  pw.addEventListener('keydown', check);
  pw.addEventListener('blur', () => { caps.textContent = ''; });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="lf d-grid">
<main class="card w-100">
  <div class="card-body">
    <div class="lf-mark" aria-hidden="true">N</div>
    <h1>Sign in</h1>
    <p class="lf-sub text-body-secondary">Welcome back to Northwind.</p>
    <div class="alert alert-danger d-flex" role="alert">
      <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
      <span><strong>That combination didn't work.</strong>You have 3 attempts left before the account
        is locked for 15 minutes.</span>
    </div>
    <form id="lf-form" novalidate>
      <div class="lf-grp">
        <label class="form-label" for="vmail">Email address</label>
        <input class="form-control is-invalid" id="vmail" name="username" type="email" value="dana@northwind" autocomplete="username"
               aria-invalid="true" aria-describedby="vmail-err" required>
        <p class="invalid-feedback mb-0" id="vmail-err">
          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
          Enter a complete email address, like [email protected].</p>
      </div>
      <div class="lf-grp">
        <label class="form-label" for="vpw">Password</label>
        <input class="form-control" id="vpw" name="password" type="password" autocomplete="current-password" placeholder="••••••••"
               aria-describedby="caps" required>
        <p class="lf-caps mb-0" id="caps" role="status"></p>
      </div>
      <div class="lf-row d-flex justify-content-between align-items-center">
        <div class="form-check">
          <input class="form-check-input" type="checkbox" id="remember" name="remember">
          <label class="form-check-label" for="remember">Keep me signed in</label>
        </div>
        <a href="#">Forgot password?</a>
      </div>
      <button class="btn btn-brand w-100" type="submit">Sign in</button>
    </form>
  </div>
</main>
</div>
CSS
/* Bootstrap .card, .alert-danger, .form-control with .is-invalid and .invalid-feedback, .form-check and .btn,
   recoloured through Bootstrap's CSS variables. The script keeps .is-invalid and aria-invalid in step. */
.lf{--lf-brand:#4f46e5;--lf-brand-dark:#4338ca;--lf-ink:#111827;--lf-muted:#6b7280;--lf-line:#e5e7eb;--lf-edge:#868c99;--lf-bad:#dc2626;
  --bs-body-color:var(--lf-ink);--bs-secondary-color:var(--lf-muted);--bs-border-color:var(--lf-edge);
  --bs-link-color-rgb:79,70,229;--bs-link-hover-color-rgb:67,56,202;
  --bs-form-invalid-color:var(--lf-bad);--bs-form-invalid-border-color:var(--lf-bad);
  min-height:100vh;grid-template-columns:minmax(0,1fr);place-items:center;padding:28px 20px;
  background:#f5f6fa;color:var(--lf-ink);line-height:normal}
.lf a{font-weight:600;text-decoration:none}
.lf a:hover{text-decoration:underline}
.lf :focus-visible{outline:2px solid var(--lf-brand);outline-offset:2px}
.lf .card{--bs-card-border-color:var(--lf-line);--bs-card-border-radius:16px;--bs-card-spacer-y:38px;--bs-card-spacer-x:34px;
  max-width:410px;box-shadow:0 10px 34px rgba(17,24,39,.06)}
.lf-mark{width:42px;height:42px;border-radius:12px;background:var(--lf-brand);display:grid;place-items:center;
  color:#fff;font-weight:800;margin-bottom:22px}
.lf h1{font-size:1.4rem;font-weight:700;line-height:normal;letter-spacing:-.02em;margin-bottom:7px}
.lf-sub{font-size:.93rem;line-height:1.6;margin-bottom:20px}
.lf .alert-danger{--bs-alert-bg:#fef2f2;--bs-alert-border-color:#fecaca;--bs-alert-color:#991b1b;--bs-alert-padding-x:15px;--bs-alert-padding-y:13px;
  --bs-alert-margin-bottom:24px;--bs-alert-border-radius:11px;gap:11px;font-size:.88rem;line-height:1.55}
.lf .alert svg{width:18px;height:18px;stroke:var(--lf-bad);fill:none;stroke-width:2.2;flex-shrink:0;margin-top:1px}
.lf .alert strong{display:block;margin-bottom:2px}
.lf-grp{margin-bottom:18px}
.lf .form-label{font-size:.85rem;font-weight:600;margin-bottom:6px}
.lf .form-control{padding:12px 14px;border-radius:10px;font-size:.95rem;line-height:normal;transition:border-color .15s,box-shadow .15s}
.lf .form-control:focus{border-color:var(--lf-brand);box-shadow:0 0 0 3px rgba(79,70,229,.15);outline:none}
.lf .form-control::placeholder{color:var(--lf-muted)}
.lf .form-control.is-invalid{background:#fffafa;padding-right:14px}
.lf .form-control.is-invalid:focus{border-color:var(--lf-bad);box-shadow:0 0 0 3px rgba(220,38,38,.14)}
.lf .invalid-feedback{align-items:center;gap:6px;font-size:.82rem;font-weight:600;margin-top:7px}
.lf .is-invalid~.invalid-feedback{display:flex}
.lf .invalid-feedback svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;flex-shrink:0}
.lf-caps{font-size:.82rem;color:#92400e;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:7px 11px;margin-top:7px}
.lf-caps:empty{display:none}
.lf-row{gap:12px;margin-bottom:22px;font-size:.86rem}
.lf .form-check{display:flex;align-items:center;gap:7px;padding:0;margin:0;min-height:0}
.lf .form-check-input{float:none;margin:0;cursor:pointer}
.lf .form-check-input:checked{background-color:var(--lf-brand);border-color:var(--lf-brand)}
.lf .form-check-input:focus{border-color:var(--lf-brand);box-shadow:none}
.lf .form-check-label{font-weight:500;color:var(--lf-muted);cursor:pointer}
.lf .btn-brand{--bs-btn-line-height:normal;--bs-btn-border-width:0;--bs-btn-padding-y:13px;--bs-btn-font-size:.97rem;--bs-btn-font-weight:700;--bs-btn-border-radius:10px;
  --bs-btn-bg:var(--lf-brand);--bs-btn-border-color:var(--lf-brand);--bs-btn-color:#fff;
  --bs-btn-hover-bg:var(--lf-brand-dark);--bs-btn-hover-border-color:var(--lf-brand-dark);--bs-btn-hover-color:#fff;
  --bs-btn-active-bg:var(--lf-brand-dark);--bs-btn-active-border-color:var(--lf-brand-dark);--bs-btn-active-color:#fff;
  --bs-btn-focus-box-shadow:none;transition:background-color .15s}
@media (prefers-reduced-motion:reduce){.lf .form-control,.lf .btn{transition:none}}
JavaScript
(() => {
  const form = document.getElementById('lf-form'), mail = document.getElementById('vmail');
  const pw = document.getElementById('vpw'), caps = document.getElementById('caps');
  if (!form || !mail || !pw) return;
  // type="email" accepts "name@host", so ask for a dot in the domain as well.
  const ok = () => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail.value.trim());
  const show = bad => { mail.setAttribute('aria-invalid', String(bad)); mail.classList.toggle('is-invalid', bad); };
  mail.addEventListener('input', () => { if (ok()) show(false); });
  form.addEventListener('submit', e => {
    e.preventDefault(); // demo: remove this line once the form posts to your server
    if (!ok()) { show(true); mail.focus(); return; }
    if (!pw.value) { pw.focus(); }
  });
  // Caps Lock is the most common cause of a "wrong password" that is not wrong.
  // getModifierState reports it without reading what was typed.
  const check = e => { caps.textContent = e.getModifierState && e.getModifierState('CapsLock') ? 'Caps Lock is on.' : ''; };
  pw.addEventListener('keyup', check);
  pw.addEventListener('keydown', check);
  pw.addEventListener('blur', () => { caps.textContent = ''; });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="grid min-h-screen grid-cols-[minmax(0,1fr)] place-items-center bg-page px-5 py-7 font-[-apple-system,BlinkMacSystemFont,'Segoe_UI',Roboto,Helvetica,Arial,sans-serif] leading-[normal] text-ink">
<main class="w-full max-w-[410px] rounded-2xl border border-line bg-white px-[34px] py-[38px] shadow-[0_10px_34px_rgba(17,24,39,.06)]">
  <div class="mb-[22px] grid size-[42px] place-items-center rounded-xl bg-brand font-extrabold text-white" aria-hidden="true">N</div>
  <h1 class="mb-[7px] text-[1.4rem] font-bold tracking-[-.02em]">Sign in</h1>
  <p class="mb-5 text-[.93rem] leading-[1.6] text-muted">Welcome back to Northwind.</p>
  <div class="mb-6 flex gap-[11px] rounded-[11px] border border-[#fecaca] bg-[#fef2f2] px-[15px] py-[13px] text-[.88rem] leading-[1.55] text-[#991b1b]" role="alert">
    <svg class="mt-px size-[18px] shrink-0 fill-none stroke-bad stroke-[2.2]" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
    <span><strong class="mb-0.5 block">That combination didn't work.</strong>You have 3 attempts left before the account
      is locked for 15 minutes.</span>
  </div>
  <form id="lf-form" novalidate>
    <div class="mb-[18px]">
      <label class="mb-1.5 block text-[.85rem] font-semibold" for="vmail">Email address</label>
      <input class="w-full rounded-[10px] border border-edge bg-white px-3.5 py-3 text-[.95rem] transition-[border-color,box-shadow] duration-150 focus:border-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.15)] focus:outline-none aria-invalid:border-bad aria-invalid:bg-[#fffafa] aria-invalid:focus:border-bad aria-invalid:focus:shadow-[0_0_0_3px_rgba(220,38,38,.14)] motion-reduce:transition-none"
             id="vmail" name="username" type="email" value="dana@northwind" autocomplete="username"
             aria-invalid="true" aria-describedby="vmail-err" required>
      <p class="mt-[7px] flex items-center gap-1.5 text-[.82rem] font-semibold text-bad" id="vmail-err">
        <svg class="size-3.5 shrink-0 fill-none stroke-current stroke-[2.4]" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/></svg>
        Enter a complete email address, like [email protected].</p>
    </div>
    <div class="mb-[18px]">
      <label class="mb-1.5 block text-[.85rem] font-semibold" for="vpw">Password</label>
      <input class="w-full rounded-[10px] border border-edge bg-white px-3.5 py-3 text-[.95rem] transition-[border-color,box-shadow] duration-150 placeholder:text-muted focus:border-brand focus:shadow-[0_0_0_3px_rgba(79,70,229,.15)] focus:outline-none motion-reduce:transition-none"
             id="vpw" name="password" type="password" autocomplete="current-password" placeholder="••••••••"
             aria-describedby="caps" required>
      <p class="mt-[7px] rounded-lg border border-[#fde68a] bg-[#fffbeb] px-[11px] py-[7px] text-[.82rem] text-[#92400e] empty:hidden" id="caps" role="status"></p>
    </div>
    <div class="mb-[22px] flex items-center justify-between gap-3 text-[.86rem]">
      <label class="flex cursor-pointer items-center gap-[7px] font-medium text-muted"><input class="accent-brand focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand" type="checkbox" name="remember"> Keep me signed in</label>
      <a class="font-semibold text-brand hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand" href="#">Forgot password?</a>
    </div>
    <button class="w-full cursor-pointer rounded-[10px] bg-brand p-[13px] text-[.97rem] font-bold text-white transition-[background-color,border-color,color] duration-150 hover:bg-brand-dark focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand motion-reduce:transition-none" type="submit">Sign in</button>
  </form>
</main>
</div>
CSS
@theme {
  --color-brand: #4f46e5;
  --color-brand-dark: #4338ca;
  --color-ink: #111827;
  --color-muted: #6b7280;
  --color-line: #e5e7eb;
  --color-edge: #868c99;
  --color-page: #f5f6fa;
  --color-bad: #dc2626;
}
JavaScript
(() => {
  const form = document.getElementById('lf-form'), mail = document.getElementById('vmail');
  const err = document.getElementById('vmail-err'), pw = document.getElementById('vpw'), caps = document.getElementById('caps');
  if (!form || !mail || !pw) return;
  // type="email" accepts "name@host", so ask for a dot in the domain as well.
  const ok = () => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail.value.trim());
  const show = bad => { mail.setAttribute('aria-invalid', String(bad)); err.hidden = !bad; };
  mail.addEventListener('input', () => { if (ok()) show(false); });
  form.addEventListener('submit', e => {
    e.preventDefault(); // demo: remove this line once the form posts to your server
    if (!ok()) { show(true); mail.focus(); return; }
    if (!pw.value) { pw.focus(); }
  });
  // Caps Lock is the most common cause of a "wrong password" that is not wrong.
  // getModifierState reports it without reading what was typed.
  const check = e => { caps.textContent = e.getModifierState && e.getModifierState('CapsLock') ? 'Caps Lock is on.' : ''; };
  pw.addEventListener('keyup', check);
  pw.addEventListener('keydown', check);
  pw.addEventListener('blur', () => { caps.textContent = ''; });
})();

About this set

A sign-in card that shows the three errors a login form has to handle. At the top, a red form-level alert reports a failed attempt and how many tries are left before a 15-minute lock; that one comes from your server. Under the email field, a field-level message explains what is wrong with the address, with the input marked aria-invalid and a red border. Under the password field, a Caps Lock warning appears while Caps Lock is on, because it is the most common reason a correct password fails. A short script makes the field error live: typing a valid address clears it, and submitting with an invalid one brings it back and returns focus to the field. The check asks for a dot in the domain, since type=”email” alone accepts name@host. The Caps Lock check uses getModifierState and never reads what was typed. In Bootstrap the states map to .alert-danger, .is-invalid with .invalid-feedback and .form-check; Tailwind uses the aria-invalid variant.

What’s included

  • Form-level alert with role="alert"
  • Field error linked by aria-describedby and aria-invalid
  • Error clears as soon as the address becomes valid
  • Caps Lock warning in a polite live region
  • Remember-me checkbox and forgotten-password link

Accessibility

The email error is tied to the field with aria-describedby and the field carries aria-invalid, so screen readers read the message with the label. The Caps Lock notice is a role="status" region that the password field also references. On a failed submit focus returns to the invalid field; all controls show a focus ring or outline, and transitions switch off under prefers-reduced-motion.

Customise it

The error red is --lf-bad (plain CSS), --bs-form-invalid-color (Bootstrap) or --color-bad (Tailwind); the alert and Caps Lock colours are the pink and amber values on .lf-alert and .lf-caps or their utilities.