Login form
A sign-in card with a show-password toggle and a single generic error.
<form class="auth" id="login" novalidate>
<div class="mark">A</div>
<h1>Sign in to Acme</h1>
<p class="sub">New here? <a href="/signup/">Create an account</a></p>
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="username"
required autofocus aria-describedby="form-err">
</div>
<div class="field">
<div class="row">
<label for="password">Password</label>
<a class="small" href="/reset/">Forgot?</a>
</div>
<div class="pw">
<input type="password" id="password" name="password"
autocomplete="current-password" required aria-describedby="form-err">
<button type="button" class="peek" id="peek" aria-pressed="false" aria-label="Show password">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<p class="err" id="form-err" hidden>Email or password is incorrect.</p>
<label class="check">
<input type="checkbox" name="remember" checked>
<span>Keep me signed in</span>
</label>
<button class="submit" type="submit">Sign in</button>
<div class="divider"><span>or</span></div>
<button class="oauth" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true" class="g"><path d="M21.8 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.5a4.7 4.7 0 0 1-2 3.1v2.6h3.2c1.9-1.7 3-4.3 3-7.6z"/><path d="M12 22c2.7 0 5-.9 6.7-2.4l-3.2-2.6c-.9.6-2 1-3.5 1-2.7 0-5-1.8-5.8-4.3H2.9v2.7A10 10 0 0 0 12 22z"/><path d="M6.2 13.7a6 6 0 0 1 0-3.8V7.2H2.9a10 10 0 0 0 0 9.2l3.3-2.7z"/><path d="M12 5.9c1.5 0 2.9.5 3.9 1.5l2.9-2.9A10 10 0 0 0 2.9 7.2l3.3 2.7C7 7.7 9.3 5.9 12 5.9z"/></svg>
Continue with Google
</button>
</form>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; background: #f6f8fb; padding: 24px; }
.auth {
max-width: 380px;
margin: 0 auto;
padding: 30px 30px 32px;
background: #fff;
border: 1px solid #e2e7ef;
border-radius: 14px;
text-align: center;
}
.mark {
width: 42px; height: 42px;
margin: 0 auto 16px;
display: grid;
place-items: center;
border-radius: 11px;
background: #3b4fe4;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
}
.auth h1 { font-size: 1.35rem; color: #16202e; }
.sub { font-size: .88rem; color: #8593ab; margin-top: 5px; margin-bottom: 24px; }
.sub a, .small { color: #3b4fe4; text-decoration: none; }
.sub a:hover, .small:hover { text-decoration: underline; }
.field { margin-bottom: 16px; text-align: left; }
.row { display: flex; align-items: baseline; justify-content: space-between; }
.small { font-size: .81rem; }
label { display: block; font-size: .87rem; font-weight: 500; color: #16202e; margin-bottom: 6px; }
input[type="email"], input[type="password"], input[type="text"] {
width: 100%;
font: inherit;
font-size: .95rem;
padding: 10px 12px;
border: 1px solid #d9e0ea;
border-radius: 8px;
color: #16202e;
}
input:focus { outline: 2px solid #3b4fe4; outline-offset: 1px; border-color: #3b4fe4; }
.pw { position: relative; }
.pw input { padding-right: 42px; }
.peek {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 32px; height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 6px;
background: none;
color: #8593ab;
cursor: pointer;
}
.peek:hover { color: #16202e; background: #f2f5f9; }
.peek svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.7; }
.peek[aria-pressed="true"] { color: #3b4fe4; }
.err {
font-size: .84rem;
color: #c93b3b;
background: #fdeeee;
border: 1px solid #f6d5d5;
border-radius: 8px;
padding: 9px 12px;
margin-bottom: 16px;
text-align: left;
}
.check {
display: flex;
gap: 9px;
align-items: center;
font-size: .87rem;
font-weight: 400;
color: #5b6b83;
margin-bottom: 20px;
cursor: pointer;
}
.check input { width: 16px; height: 16px; accent-color: #3b4fe4; }
.submit, .oauth {
width: 100%;
font: inherit;
font-size: .94rem;
font-weight: 500;
padding: 11px;
border-radius: 9px;
cursor: pointer;
}
.submit { border: 0; background: #3b4fe4; color: #fff; }
.submit:hover { background: #2a3abf; }
.submit:disabled { background: #c9d3e2; cursor: not-allowed; }
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: #b8c2d2; font-size: .8rem; }
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: #e2e7ef; }
.oauth {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid #d9e0ea;
background: #fff;
color: #16202e;
}
.oauth:hover { background: #f6f8fb; }
.oauth .g { width: 17px; height: 17px; fill: #5b6b83; }const peek = document.getElementById("peek");
const pw = document.getElementById("password");
peek.addEventListener("click", () => {
const shown = pw.type === "text";
pw.type = shown ? "password" : "text";
peek.setAttribute("aria-pressed", shown ? "false" : "true");
peek.setAttribute("aria-label", shown ? "Show password" : "Hide password");
pw.focus();
});
document.getElementById("login").addEventListener("submit", async (e) => {
e.preventDefault();
const err = document.getElementById("form-err");
const btn = e.target.querySelector(".submit");
err.hidden = true;
btn.disabled = true;
btn.textContent = "Signing in…";
try {
// replace with your own request
await new Promise((r) => setTimeout(r, 700));
err.hidden = false; // demo: always fails
document.getElementById("email").focus();
} finally {
btn.disabled = false;
btn.textContent = "Sign in";
}
});How it works
autocomplete="username" on the email field. This is the one people get wrong, and it is what password managers look for. Paired with autocomplete="current-password", browsers and managers fill both fields reliably. On a signup form the password field should be new-password instead, which prompts managers to suggest a generated one.
One generic error, never two specific ones. "Email or password is incorrect" rather than "No account with that email". A specific message confirms which addresses are registered, which hands an attacker a way to enumerate your users.
The show-password toggle uses aria-pressed. It is a toggle button, so the state belongs in aria-pressed and the label changes with it. Focus returns to the field afterwards so typing continues where it left off.
Do not block paste. Nothing here interferes with pasting into the password field. Blocking paste is still common and it actively harms security by making password managers unusable, which pushes people back to passwords they can remember.
The button reports its own state while the request is in flight, and is disabled to prevent a double submit.
Accessibility notes
autofocus on the email field is appropriate here because signing in is unambiguously the only purpose of the page. On a page with other content it is disorienting and should be left off.
The error message sits above the fields and is linked to both inputs with aria-describedby, so it is announced with either field rather than being missed entirely.
The Google icon is aria-hidden because the button text already says what it does.
Making it yours
Add a rate limit and an account lockout on the server. Nothing on the client side is a security control — everything here is about the experience of the person who does know their password.
For a signup form, change current-password to new-password, add a strength meter that measures actual entropy rather than counting character classes, and drop any maximum length below 64 characters.
Related templates
Contact form
A contact form with real labels, inline validation and error and success states.
HTMLCSSJavaScriptNewsletter signup
An inline email capture with validation and a success state, in three layouts.
HTMLCSSJavaScriptMulti-step form
A three-step form with a progress indicator, per-step validation and a review screen.
HTMLCSSJavaScriptToggle switch
A switch built on a real checkbox, with sizes, labels and a disabled state.
HTMLCSSCheck your version
Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.