Accessible Show/Hide Password Toggle
How do you let users reveal what they typed in a password field — the single most effective fix for password typos — while keeping the button operable by keyboard and screen reader, preserving the field’s validation state and caret position, and making sure the password is never submitted or autofilled in plain view? This recipe switches input.type between password and text from a real <button> using aria-pressed, keeps the Constraint Validation API state intact across the switch, and resets the field to masked before submission.
When to Use a Password Visibility Toggle
A reveal control is worth adding to every new-password and current-password field on forms that people fill in themselves. It replaces the older “confirm password” field in many designs: if the user can see what they typed, typing it twice adds friction without catching extra mistakes. Choose this recipe when:
- The form has no confirmation field, or you plan to remove it. Visibility gives users the same protection against typos.
- Users are likely on mobile, where mistyping is common and the masked dot flashes for only a moment.
- You need WCAG 3.3.8 conformance. Letting users see their input is one of the mechanisms that makes authentication less of a memory test.
Keep the confirmation field instead only where regulation or a security review explicitly requires it; when you do, validate the pair with cross-field password confirmation logic.
Minimal Working Toggle Implementation
The button sits after the input in DOM order so tab order flows naturally from field to toggle. Its accessible name stays constant (“Show password”) while aria-pressed carries the state — changing the label and the pressed state at the same time makes screen readers announce contradictions like “Hide password, pressed”.
<div class="pw-wrap">
<label for="login-pw">Password</label>
<input id="login-pw" name="password" type="password" required
autocomplete="current-password" aria-describedby="login-pw-err">
<button type="button" class="pw-toggle" aria-controls="login-pw" aria-pressed="false">
<span class="pw-toggle-text">Show password</span>
</button>
<p id="login-pw-err" class="field-error" hidden></p>
</div>
function enhancePasswordToggle(button: HTMLButtonElement): void {
const input = document.getElementById(button.getAttribute("aria-controls")!) as HTMLInputElement;
if (!input) return;
button.addEventListener("click", () => {
const reveal = input.type === "password";
// Preserve caret/selection — switching type can reset it in some engines.
const { selectionStart, selectionEnd } = input;
input.type = reveal ? "text" : "password";
button.setAttribute("aria-pressed", String(reveal));
// Keep focus where the user was typing if they clicked from the field.
if (document.activeElement === button && input.dataset.returnFocus === "true") input.focus();
try {
input.setSelectionRange(selectionStart, selectionEnd);
} catch {
/* type=password on some engines throws; safe to ignore */
}
});
// Remember whether the user came from the field (mouse users) or tabbed to the button.
button.addEventListener("pointerdown", () => {
input.dataset.returnFocus = String(document.activeElement === input);
});
// Always mask again before the value leaves the page.
input.form?.addEventListener("submit", () => {
input.type = "password";
button.setAttribute("aria-pressed", "false");
}, { capture: true });
}
document.querySelectorAll<HTMLButtonElement>(".pw-toggle").forEach(enhancePasswordToggle);
The button is type="button", which is essential: a <button> inside a form defaults to type="submit", and clicking “Show password” would otherwise submit the form and trigger reportValidity() on every field.
Toggle Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
aria-controls |
id reference | the input’s id | Links the button to the field it changes |
aria-pressed |
"true" | "false" |
"false" |
Communicates revealed state as a toggle |
| Button text | string |
Show password |
Constant accessible name; state lives in aria-pressed |
type="button" |
attribute | required | Prevents the toggle from submitting the form |
| Submit reset | capture listener | on | Masks the field before serialisation or navigation |
| Focus return | data-return-focus |
per click | Mouse users keep typing; keyboard users stay on the button |
| Auto re-mask timeout | number (ms) |
off | Optional: hide again after inactivity on shared devices |
Microsoft Edge draws its own reveal eye inside password inputs. With a custom toggle you get two buttons; hide the native one with input::-ms-reveal { display: none; }.
Verification Steps
import { test, expect } from "@playwright/test";
test("toggle reveals without changing validity", async ({ page }) => {
await page.goto("/login");
const pw = page.getByLabel("Password", { exact: true });
await pw.fill("hunter2-but-longer");
const before = await pw.evaluate((el: HTMLInputElement) => el.validity.valid);
const toggle = page.getByRole("button", { name: "Show password" });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect(pw).toHaveAttribute("type", "text");
expect(await pw.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(before);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(pw).toHaveAttribute("type", "password");
});
Edge Cases and Failure Modes
Autofill and the text type. If the page is left with the field as type="text" and the user navigates away, some browsers store the value in form history as ordinary text, then offer it as a suggestion in unrelated text fields. The capture-phase submit listener fixes the submit path; add a pagehide listener that re-masks too.
window.addEventListener("pagehide", () => {
document.querySelectorAll<HTMLInputElement>("input[data-pw-toggle]").forEach((i) => (i.type = "password"));
});
Changing the label instead of aria-pressed. Swapping text between “Show password” and “Hide password” and using aria-pressed produces “Hide password, pressed” — a double negative. Pick one mechanism. aria-pressed with a constant label is the more robust choice; if the design wants the text to change, drop aria-pressed entirely.
Icon-only buttons with no name. An eye icon with no text fails WCAG 4.1.2. Keep a visually hidden label inside the button, or aria-label="Show password", and mark the SVG aria-hidden="true".
Toggling clears :user-invalid styling. It should not: validity is tied to the value, not the type. If your styles disappear, you are probably styling input[type=password]:user-invalid specifically. Target the element by class so the same rule applies in both types — see styling invalid inputs with :user-invalid.
Styling the Toggle Inside the Input Without Breaking Hit Areas
Designs usually place the toggle visually inside the input’s right edge. The robust way to do that is to position the button over a padded input, not to nest it in a wrapper that pretends to be the input. Keep the button’s hit area at least 24 by 24 CSS pixels to satisfy WCAG 2.5.8 (Target Size, Minimum), reserve matching padding on the input so long passwords never run underneath the button, and give the button its own visible focus ring, because the input’s ring will not appear when focus moves to the button.
.pw-wrap { position: relative; }
.pw-wrap input { padding-inline-end: 6.5rem; inline-size: 100%; }
.pw-toggle {
position: absolute;
inset-block-end: 0.35rem;
inset-inline-end: 0.35rem;
min-block-size: 2rem; /* ≥ 24px target */
padding-inline: 0.6rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--text-link);
}
.pw-toggle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.pw-toggle[aria-pressed="true"] { text-decoration: underline; }
Two details cause most visual bugs. First, if the error message sits inside the relatively positioned wrapper, the absolutely positioned button’s inset-block-end anchors to the bottom of the message rather than of the input, so the toggle drifts down when an error appears; keep the message outside the wrapper or anchor with top computed from the label height. Second, when the field is in an error state its border thickens, which nudges the text; add the same border width in every state and change only the colour, so revealing and hiding never shifts layout — the same principle behind reserving space for error messages.
Security Considerations for Revealing Passwords
Revealing a password is a user-initiated action, so it does not weaken the stored secret, but it changes the shoulder-surfing risk for the moment it is visible. The defaults above are the right balance for most products: masked by default, revealed only on an explicit press, re-masked on submit and page hide. Products used on shared or kiosk devices can add an inactivity timeout that re-masks after 30 seconds without typing. What you must not do is persist the revealed state across page loads in localStorage; a user who revealed their password at home should not find it revealed at a library terminal. Equally, do not log the field’s value in analytics “rage click” or session-replay tools while revealed — many replay SDKs mask type=password inputs automatically and stop doing so the moment the type changes, so add an explicit masking class such as data-private that those tools respect regardless of type.
Frequently Asked Questions
Should the toggle button text change between Show and Hide?
Use either a changing label or aria-pressed, not both. A constant "Show password" label with aria-pressed is the most robust pattern; a changing label without aria-pressed also works. Combining them produces confusing announcements such as "Hide password, pressed".
Does switching the input type reset validation?
No. Validity flags depend on the value and constraints, not on whether the type is password or text, so validity is unchanged. Only CSS selectors tied to type=password will appear to change.
Why must the toggle be type button?
Buttons inside a form default to type="submit". Without type="button", activating the toggle submits the form, runs validation on every field and may navigate away.
Can a visibility toggle replace the confirm password field?
In most products, yes. Letting users see what they typed catches the same typos a second blind field would, with less effort, and it works on sign-in forms too.
Related Guides
- Password Validation Patterns — the overall approach for new and current password fields.
- Live Password Requirements Checklist — rule feedback that works alongside the toggle.
- Cross-Field Password Confirmation Logic — if you keep a confirmation field anyway.
- WCAG 3.3.7 Redundant Entry Checklist — why removing duplicate entry helps compliance.