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.

Visibility toggle versus confirmation field Two columns comparing a show/hide password toggle with a second confirmation field as ways to prevent password typos. Show/hide toggle • one field, one button ✓ user sees and fixes typos directly ✓ works on sign-in as well as sign-up ✗ must reset to masked before submit Confirmation field • second field typed blind ✗ doubles typing on mobile ✗ a copy-pasted typo passes both fields ✓ familiar to some users
A visibility toggle catches the same typos as a confirmation field with half the typing, and it helps on sign-in forms where a confirm field makes no sense.

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 interaction sequence The user clicks the toggle, the script flips the input type and aria-pressed, restores the selection, and on submit the field is masked again before the browser serialises the form. User Toggle button Password input Form click / Space / Enter type = "text", keep selection aria-pressed="true" announced submit capture listener sets type="password" validity and value unchanged by either switch
The validity state never changes during a toggle because only the type attribute moves; the capture-phase submit listener masks the field before anything else runs.

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

Password field with visibility toggle A sign-in form with the password revealed as plain text and the toggle button in its pressed state, annotated with the ARIA attributes. Sign in Email ada@example.com Password correct horse battery 1 Sign in 1 Toggle is a real button after the input, with aria-controls and aria-pressed 2 type="button" so activating it never submits the form 3 A capture-phase submit listener re-masks the field first
The label stays "Show password" in both states; screen readers hear "Show password, toggle button, pressed" when the value is visible.
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.

← Back to Password Validation Patterns