Components
Password group
Password input with built-in show/hide visibility toggle.
.ui-password-group provides an input group with a trailing ghost button that toggles password visibility. Wrap with <ui-password> to enhance with JavaScript while remaining fully functional as a masked text input without JS.
Default
Loading components…
<form class="flex flex-col gap-sm w-full max-w-xl"> <div class="ui-field"> <label data-slot="field-label" for="pw-preview">Password</label> <ui-password> <label class="ui-password-group"> <input class="ui-input" id="pw-preview" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="••••••••" aria-describedby="pw-preview-hint pw-preview-warning" /> <span data-slot="password-group-addon" data-align="inline-end"> <button class="ui-button" data-slot="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 data-slot="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 data-slot="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> </ui-password> <div data-slot="field-description"> <span data-slot="field-hint" id="pw-preview-hint">Use eight or more characters.</span> <span data-slot="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>// primitives/password-group/password-group.js"use strict";/** * @fileoverview `<ui-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 `[data-slot~="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). * * The revealed/hidden state is a signal (`base/signals.ts`): the click handler * is the input adapter, `resolveToggleState` is the pure derivation, and one * effect is the output adapter that writes `type`, `aria-pressed`, and * `aria-label` together. The effect also runs once on connect, so the toggle * self-corrects to match the input's actual starting type even if the * markup's static attributes drift from it. * * Without JavaScript the field degrades to a regular password input; the * toggle button simply does nothing. * * Configuration (attributes on `<ui-password>`): * - `data-label-show`: Toggle label while the password is hidden (default "Show password"). * - `data-label-hide`: Toggle label while the password is visible (default "Hide password"). * * @example * <ui-password> * <label class="password-group"> * <input class="input" type="password" autocomplete="current-password" /> * <span data-slot="password-group-addon" data-align="inline-end"> * <button class="button" data-slot="password-group-toggle" type="button" * aria-pressed="false" aria-label="Show password">…</button> * </span> * </label> * </ui-password> */import { effect, state } from "../../base/signals.js";import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";/** * @description Derives the input type, `aria-pressed`, and `aria-label` for a * given reveal state. Pure: the effect in `connectedCallback` is the only * place that writes it to the DOM. * * @param revealed - Whether the password is currently shown as plain text. * @param labelShow - Toggle label while hidden. * @param labelHide - Toggle label while revealed. * @returns The derived DOM state. * @private */function resolveToggleState(revealed, labelShow, labelHide) { return { type: revealed ? "text" : "password", ariaPressed: revealed ? "true" : "false", ariaLabel: revealed ? labelHide : labelShow, };}class UiPassword extends ZazzElement { setup(signal) { const input = this.querySelector('input[type="password"], input[type="text"]'); const toggle = this.querySelector('[data-slot~="password-group-toggle"]'); if (!(input instanceof HTMLInputElement) || !(toggle instanceof HTMLElement)) return; const revealed = state(input.type === "text"); toggle.addEventListener("click", () => revealed.set(!revealed.get()), { signal }); effect(() => { const labelShow = this.getAttribute("data-label-show") || "Show password"; const labelHide = this.getAttribute("data-label-hide") || "Hide password"; const next = resolveToggleState(revealed.get(), labelShow, labelHide); input.type = next.type; toggle.setAttribute("aria-pressed", next.ariaPressed); toggle.setAttribute("aria-label", next.ariaLabel); }, { signal }); }}defineZazzElement("ui-password", UiPassword);// resolveToggleState is exported for unit tests only: not part of the public API.export { UiPassword, resolveToggleState };/** * password-group.css — Password group (.ui-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-group.ts) * @uses Native reveal suppressed in _reset.css (::-ms-reveal, etc.) * @tokens --ui-password-group-* (@layer variables) */@layer variables { :root { --ui-password-group-display: flex; --ui-password-group-wrap: wrap; --ui-password-group-align: center; --ui-password-group-gap: var(--step-1); --ui-password-group-inline-size: 100%; --ui-password-group-min-block-size: var(--ui-field-height); --ui-password-group-padding: 0; --ui-password-group-cursor: text; --ui-password-group-text-color: var(--muted-foreground); --ui-password-group-text-weight: var(--font-weight-strong); --ui-password-group-text-size: var(--font-size-sm); --ui-password-group-textarea-min-block-size: 3lh; --ui-password-group-textarea-padding-inline: var(--step-2); --ui-password-group-textarea-padding-block: var(--step-1_5); }}@layer zazz.components { /* =========================================================================== PASSWORD — .ui-password-group (text field + reveal toggle) - A password is a plain .ui-input[type="password"] inside an .ui-password-group, so it inherits the shared --ui-field-* surface, hover, focus, and :user-invalid states for free. The only password-specific concern is the show/hide toggle. - The toggle is a .ui-button[data-variant="ghost"] in a trailing addon. Its aria-pressed state drives which icon shows; the <ui-password> element (ui/password-group/password-group.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. =========================================================================== */ /* <ui-password> is the custom element (ui/password-group/password-group.js) that wires the toggle — it wraps .ui-password-group, so give it block flow. */ ui-password { display: block; } .ui-password-group { display: var(--ui-password-group-display); flex-wrap: var(--ui-password-group-wrap); align-items: var(--ui-password-group-align); gap: var(--ui-password-group-gap); inline-size: var(--ui-password-group-inline-size); min-block-size: var(--ui-password-group-min-block-size); padding: var(--ui-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(--ui-password-group-cursor); color: var(--ui-field-foreground); background-color: var(--ui-field-background); border: 1px solid var(--ui-field-border); border-radius: var(--ui-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; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); 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, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } .ui-password-group:hover { background-color: var(--ui-field-background--hover); border-color: var(--ui-field-border--hover); } .ui-password-group:focus-within { background-color: var(--ui-field-background--focus); border-color: var(--ui-field-border--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } .ui-password-group:focus-within .ui-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 */ .ui-password-group .ui-input, .ui-password-group .ui-textarea { flex: 1; min-inline-size: 0; background-color: transparent; border: none; border-radius: 0; outline: none; } .ui-password-group .ui-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. */ .ui-password-group:not( :has( [data-slot~="password-group-addon"]:not([data-align]), [data-slot~="password-group-addon"][data-align="inline-start"] ) ) .ui-input { padding-inline-start: var(--ui-field-padding); } .ui-password-group:not(:has([data-slot~="password-group-addon"][data-align="inline-end"])) .ui-input { padding-inline-end: var(--ui-field-padding); } /* A textarea claims the full width; addons stack above/below via block-* */ .ui-password-group .ui-textarea { flex-basis: 100%; min-block-size: var(--ui-password-group-textarea-min-block-size); padding-inline: var(--ui-password-group-textarea-padding-inline); padding-block: var(--ui-password-group-textarea-padding-block); resize: none; } /* Addon — groups icons, text, kbd, or buttons together */ [data-slot~="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(--ui-field-height); min-inline-size: var(--ui-field-height); padding-inline: var(--ui-field-addon-padding); } [data-slot~="password-group-addon"] > svg { inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); } /* Alignment — inline-start is the default (addon visually leads the control) */ [data-slot~="password-group-addon"]:not([data-align]), [data-slot~="password-group-addon"][data-align="inline-start"] { order: -1; } [data-slot~="password-group-addon"][data-align="inline-end"] { order: 1; } [data-slot~="password-group-addon"][data-align="block-start"] { order: -2; flex-basis: 100%; padding-block: var(--ui-field-addon-padding); align-items: flex-start; } [data-slot~="password-group-addon"][data-align="block-end"] { order: 2; flex-basis: 100%; padding-block: var(--ui-field-addon-padding); align-items: flex-end; } /* Text addon — units, protocols, @handles ("https://", "USD", "@user") */ [data-slot~="password-group-text"] { display: inline-flex; align-items: center; gap: var(--gap-xs); padding-inline: var(--ui-field-addon-padding); font-size: var(--ui-password-group-text-size); font-weight: var(--ui-password-group-text-weight); color: var(--ui-password-group-text-color); min-inline-size: max-content; } [data-slot~="password-group-text"] > svg { inline-size: var(--ui-field-icon-size); block-size: var(--ui-field-icon-size); } /* Buttons embedded in a group shrink to nest cleanly inside the shell */ .ui-password-group .ui-button { flex-shrink: 0; border-radius: var(--ui-field-button-radius); } :where(.ui-field:has(:user-invalid)) [data-slot~="password-group-text"] { --ui-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. */ [data-slot~="password-group-toggle"] [data-slot~="password-group-icon-hide"] { display: none; } [data-slot~="password-group-toggle"][aria-pressed="true"] [data-slot~="password-group-icon-show"] { display: none; } [data-slot~="password-group-toggle"][aria-pressed="true"] [data-slot~="password-group-icon-hide"] { display: block; }}API
| Element / Attribute | Target | Description |
|---|---|---|
<ui-password> | Wrapper | Finds password input and toggles visibility |
.ui-password-group | Container | Layout for input field and addon button |
[data-slot="password-group-toggle"] | Button | Toggles input type between password and text |
data-label-show | <ui-password> | Accessible label when password is hidden |
data-label-hide | <ui-password> | Accessible label when password is shown |