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 };/** * _password-group.css — Password group (.password-group) * * @layer variables, components * @requires layers.css, _variables.css, _fields.css, _input.css, * _button.css * @uses Same shell pattern as _input-group.css (:focus-within, :has, data-align) * @uses aria-pressed on toggle — show/hide icon swap via CSS (JS sets type + * aria in password.tsx) * @uses Native reveal suppressed in _reset.css (::-ms-reveal, etc.) * @tokens --password-group-* (@layer variables) */@layer variables { :root { --password-group-display: flex; --password-group-wrap: wrap; --password-group-align: center; --password-group-gap: var(--step-1); --password-group-inline-size: 100%; --password-group-min-block-size: var(--field-height); --password-group-padding: 0; --password-group-cursor: text; --password-group-text-color: var(--muted-foreground); --password-group-text-weight: var(--font-weight-strong); --password-group-text-size: var(--font-size-sm); --password-group-textarea-min-block-size: 3lh; --password-group-textarea-padding-inline: var(--step-2); --password-group-textarea-padding-block: var(--step-1_5); }}@layer zazz.components { /* =========================================================================== PASSWORD — .password-group (text field + reveal toggle) - A password is a plain .input[type="password"] inside an .password-group, so it inherits the shared --field-* surface, hover, focus, and :user-invalid states for free. The only password-specific concern is the show/hide toggle. - The toggle is a .button[data-variant="ghost"] in a trailing addon. Its aria-pressed state drives which icon shows; the <input-password> element (zazz/scripts/password.js) flips the input's type between password/text and keeps aria-pressed + aria-label in sync. With JS off the field still works as a normal masked password input. - Native reveal/clear chrome (Edge ::-ms-reveal, Safari credential buttons) is suppressed in _reset.css so it never double-stacks with our toggle. =========================================================================== */ /* <input-password> is the custom element (zazz/scripts/password.js) that wires the toggle — it wraps .password-group, so give it block flow. */ input-password { display: block; } .password-group { display: var(--password-group-display); flex-wrap: var(--password-group-wrap); align-items: var(--password-group-align); gap: var(--password-group-gap); inline-size: var(--password-group-inline-size); min-block-size: var(--password-group-min-block-size); padding: var(--password-group-padding); overflow: clip; /* The shell is a <label>: clicking a non-interactive addon focuses the nested control, so the whole field reads as one clickable target. */ cursor: var(--password-group-cursor); color: var(--field-foreground); background-color: var(--field-background); border: 1px solid var(--field-border); border-radius: var(--field-radius); /* ring renders as box-shadow; transparent outline twin keeps focus visible in forced-colors / high-contrast modes */ --_ring-offset-width: 0px; --_ring-width: 0px; box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--ring); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .password-group:hover { background-color: var(--field-background--hover); border-color: var(--field-border--hover); } .password-group:focus-within { background-color: var(--field-background--focus); border-color: var(--field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } .password-group:focus-within .input { /* Suppress the control's own focus ring since the shell has one. The control still gets :focus so it can style its focus-within state (e.g. show a password reveal toggle). */ outline: none; box-shadow: none; } /* Nested control sheds its own chrome and fills the row */ .password-group .input, .password-group .textarea { flex: 1; min-inline-size: 0; background-color: transparent; border: none; border-radius: 0; outline: none; } .password-group .input { block-size: 100%; padding-inline: 0; } /* Restore the control's own inline padding on whichever edge has no addon, so text never sits flush against the shell. The reveal toggle is a trailing (inline-end) addon, so by default the leading edge gets the field padding. block-* addons take their own row and don't count here. */ .password-group:not( :has( .password-group__addon:not([data-align]), .password-group__addon[data-align="inline-start"] ) ) .input { padding-inline-start: var(--field-padding); } .password-group:not(:has(.password-group__addon[data-align="inline-end"])) .input { padding-inline-end: var(--field-padding); } /* A textarea claims the full width; addons stack above/below via block-* */ .password-group .textarea { flex-basis: 100%; min-block-size: var(--password-group-textarea-min-block-size); padding-inline: var(--password-group-textarea-padding-inline); padding-block: var(--password-group-textarea-padding-block); resize: none; } /* Addon — groups icons, text, kbd, or buttons together */ .password-group__addon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; gap: var(--step-1); color: var(--muted-foreground); white-space: nowrap; block-size: var(--field-height); min-inline-size: var(--field-height); padding-inline: var(--field-addon-padding); } .password-group__addon > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Alignment — inline-start is the default (addon visually leads the control) */ .password-group__addon:not([data-align]), .password-group__addon[data-align="inline-start"] { order: -1; } .password-group__addon[data-align="inline-end"] { order: 1; } .password-group__addon[data-align="block-start"] { order: -2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-start; } .password-group__addon[data-align="block-end"] { order: 2; flex-basis: 100%; padding-block: var(--field-addon-padding); align-items: flex-end; } /* Text addon — units, protocols, @handles ("https://", "USD", "@user") */ .password-group__text { display: inline-flex; align-items: center; gap: var(--gap-xs); padding-inline: var(--field-addon-padding); font-size: var(--password-group-text-size); font-weight: var(--password-group-text-weight); color: var(--password-group-text-color); min-inline-size: max-content; } .password-group__text > svg { inline-size: var(--field-icon-size); block-size: var(--field-icon-size); } /* Buttons embedded in a group shrink to nest cleanly inside the shell */ .password-group .button { flex-shrink: 0; border-radius: var(--field-button-radius); } :where(.field:has(:user-invalid)) .password-group__text { --password-group-text-color: var(--destructive); } /* Icon swap is driven by the toggle's pressed state: show the eye when masked, the eye-off when revealed. Both icons ship in the markup; CSS toggles them so no icon swap round-trips through JS. */ .password-group__toggle .password-group__icon--hide { display: none; } .password-group__toggle[aria-pressed="true"] .password-group__icon--show { display: none; } .password-group__toggle[aria-pressed="true"] .password-group__icon--hide { display: block; }}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 |