Components
Password group
A password input with a built-in show/hide visibility toggle.
The .password-group is an input group whose trailing addon is a ghost button that toggles
the field's visibility. Wrap it in <input-password> to add the show/hide behavior while
keeping the field usable as a normal masked password input when JavaScript is unavailable.
The toggle button keeps aria-pressed and aria-label in sync, and the icon swap is driven
by CSS from that pressed state. The preview loads the required script automatically; open the
JS tab to inspect the custom element.
Default
Loading components…
<form class="flex flex-col gap-sm w-full" style="max-inline-size: 24rem"> <div class="field"> <label class="field__label" for="pw-preview">Password</label> <input-password> <label class="password-group"> <input class="input" id="pw-preview" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="••••••••" aria-describedby="pw-preview-hint pw-preview-warning" /> <span class="password-group__addon" data-align="inline-end"> <button class="button password-group__toggle" type="button" data-variant="ghost" data-size="icon-sm" aria-pressed="false" aria-label="Show password" aria-describedby="pw-preview-warning" > <svg class="password-group__icon password-group__icon--show" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <path d="M128,56C48,56,16,128,16,128s32,72,112,72,112-72,112-72S208,56,128,56Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> <svg class="password-group__icon password-group__icon--hide" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <line x1="48" y1="40" x2="208" y2="216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M154.9,157.6A40,40,0,0,1,101,98.4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M73.8,69.7C33.6,90.6,16,128,16,128s32,72,112,72a118.1,118.1,0,0,0,54.1-12.8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <path d="M208.6,169.1C229.8,149.1,240,128,240,128S208,56,128,56a126,126,0,0,0-20.5,1.6" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </button> </span> </label> </input-password> <div class="field__description"> <span class="field__hint" id="pw-preview-hint">Use eight or more characters.</span> <span class="field__error" role="alert">Password must be at least 8 characters.</span> </div> <span class="sr-only" id="pw-preview-warning" >Warning: showing the password makes it visible to anyone near your screen.</span > </div></form>// password.js"use strict";/** * @fileoverview `<input-password>` — HTML web component for password visibility. * @description Light-DOM custom element that adds show/hide behavior to a * standard password field. Wrap the existing `.password-group` markup — the * element finds the input and the `.password-group__toggle` button, flips the * input between `type="password"` and `type="text"` on click, and keeps * `aria-pressed` and `aria-label` in sync. The icon swap is pure CSS, driven * by `aria-pressed` (see _password-group.css). * * Without JavaScript the field degrades to a regular password input; the * toggle button simply does nothing. * * Configuration (attributes on `<input-password>`): * - `label-show`: Toggle label while the password is hidden (default "Show password"). * - `label-hide`: Toggle label while the password is visible (default "Hide password"). * * @example * <input-password> * <label class="password-group"> * <input class="input" type="password" autocomplete="current-password" /> * <span class="password-group__addon" data-align="inline-end"> * <button class="button password-group__toggle" type="button" * aria-pressed="false" aria-label="Show password">…</button> * </span> * </label> * </input-password> */class InputPassword extends HTMLElement { /** @type {AbortController|null} */ #controller = null; connectedCallback() { if (this.#controller) return; const input = this.querySelector('input[type="password"], input[type="text"]'); const toggle = this.querySelector(".password-group__toggle"); if (!(input instanceof HTMLInputElement) || !(toggle instanceof HTMLElement)) return; this.#controller = new AbortController(); toggle.addEventListener( "click", () => { const reveal = input.type === "password"; input.type = reveal ? "text" : "password"; toggle.setAttribute("aria-pressed", String(reveal)); toggle.setAttribute( "aria-label", reveal ? this.getAttribute("label-hide") || "Hide password" : this.getAttribute("label-show") || "Show password", ); }, { signal: this.#controller.signal }, ); } disconnectedCallback() { this.#controller?.abort(); this.#controller = null; }}// Register the element (guarded against double script loads)if (typeof window !== "undefined" && !customElements.get("input-password")) { customElements.define("input-password", InputPassword);}// Attach to window for parity with the other component scripts, and export for// module consumers (loaded for its side effect — the custom-element registration).if (typeof window !== "undefined") { window.InputPassword = InputPassword;}export { InputPassword };API
| Attribute | Where | Purpose |
|---|---|---|
<input-password> | wrapper | Finds the password input and toggle button |
.password-group | label | Field surface and addon layout |
.password-group__toggle | button | Button that flips the input between password/text |
label-show | <input-password> | Optional accessible label while the password is hidden |
label-hide | <input-password> | Optional accessible label while the password is visible |