API
Password group
A password input with a show/hide toggle.
Installation
pnpm dlx zazz-ui add password-groupnpx zazz-ui add password-groupyarn dlx zazz-ui add password-groupbunx zazz-ui add password-groupAlso adds fields, kbd, button, input, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<form
style="
--display: flex-col;
--gap: 4;
--w: 100%;
--max-w: 96;
"
>
<div data-ui="field">
<label data-field-slot="label" for="pw-preview">Password</label>
<ui-password>
<label data-ui="password-group">
<input
data-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-password-group-slot="addon" data-password-group-align="inline-end">
<button
data-ui="button"
data-password-group-slot="toggle"
type="button"
data-button-variant="ghost"
data-button-size="icon-sm"
aria-pressed="false"
aria-label="Show password"
aria-describedby="pw-preview-warning"
>
<svg
data-password-group-slot="icon 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-password-group-slot="icon 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-field-slot="description">
<span data-field-slot="hint" id="pw-preview-hint">Use eight or more characters.</span>
<span data-field-slot="error" role="alert">Password must be at least 8 characters.</span>
</div>
<span data-ui="sr-only" id="pw-preview-warning"
>Warning: showing the password makes it visible to anyone near your screen.</span
>
</div>
</form>/**
* password-group.css — Password group ([data-ui~="password-group"])
*
* @layer variables, components
* @requires _layers.css, _variables.css, fields.css, input.css,
* button.css
* @uses :focus-within, :has: shell focus ring and padding (as input-group.css)
* @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-flex-wrap: wrap;
--ui-password-group-items: center;
--ui-password-group-gap: var(--spacing);
--ui-password-group-w: 100%;
--ui-password-group-min-h: var(--ui-field-h);
--ui-password-group-p: 0;
--ui-password-group-cursor: text;
--ui-password-group-text: var(--color-muted-foreground);
--ui-password-group-font-weight: var(--font-weight-strong);
--ui-password-group-font-size: var(--font-size-sm);
--ui-password-group-textarea-min-h: 3lh;
--ui-password-group-textarea-px: calc(var(--spacing) * 2);
--ui-password-group-textarea-py: calc(var(--spacing) * 1.5);
}
}
@layer ui.components {
/* ===========================================================================
PASSWORD — :where([data-ui~="password-group"]) (text field + reveal toggle)
- The input inherits the shared --ui-field-* surface and states; the only
password-specific part is the show/hide toggle.
- <ui-password> flips the input type and keeps aria-pressed + aria-label in
sync. With JS off the field is a normal masked input.
=========================================================================== */
/* The element wraps the shell, so give it block flow. */
ui-password {
display: block;
}
:where([data-ui~="password-group"]) {
display: var(--_display-resolved, var(--ui-password-group-display));
flex-wrap: var(--_flex-wrap-resolved, var(--ui-password-group-flex-wrap));
align-items: var(--_items-resolved, var(--ui-password-group-items));
--_gap-len: var(--_gap-resolved, var(--ui-password-group-gap));
--_gap-num: var(--_gap-resolved, var(--ui-password-group-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_w-len: var(--_w-resolved, var(--ui-password-group-w));
--_w-num: var(--_w-resolved, var(--ui-password-group-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--ui-password-group-min-h));
--_min-h-num: var(--_min-h-resolved, var(--ui-password-group-min-h));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
--_p-len: var(--_p-resolved, var(--ui-password-group-p));
--_p-num: var(--_p-resolved, var(--ui-password-group-p));
padding: calc(var(--_p-len) + var(--_p-num) * var(--spacing));
overflow: clip;
/* The shell is a <label>: clicking an addon focuses the nested control. */
cursor: var(--_cursor-resolved, var(--ui-password-group-cursor));
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
border: 1px solid var(--_border-color-resolved, var(--ui-field-border-color));
border-radius: var(--_rounded-resolved, var(--ui-field-rounded));
/* the ring is a box-shadow; the transparent outline keeps focus visible in
forced-colors mode */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent);
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="password-group"]):hover {
background-color: var(--_bg-resolved, var(--ui-field-bg--hover));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
:where([data-ui~="password-group"]):focus-within {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where([data-ui~="password-group"]):focus-within [data-ui~="input"] {
/* The shell draws the ring; the control keeps :focus for its own styling. */
outline: none;
box-shadow: none;
}
:where([data-ui~="password-group"]) [data-ui~="input"],
:where([data-ui~="password-group"]) [data-ui~="textarea"] {
flex: 1;
min-inline-size: 0;
background-color: transparent;
border: none;
border-radius: 0;
outline: none;
}
:where([data-ui~="password-group"]) [data-ui~="input"] {
block-size: 100%;
padding-inline: 0;
}
/* Restore inline padding on whichever edge has no addon so text never sits
flush against the shell. block-* addons take their own row and don't count. */
:where([data-ui~="password-group"]):not(
:has(
[data-password-group-slot~="addon"]:not([data-password-group-align]),
[data-password-group-slot~="addon"][data-password-group-align="inline-start"]
)
)
[data-ui~="input"] {
padding-inline-start: var(--ui-field-px);
}
:where([data-ui~="password-group"]):not(
:has([data-password-group-slot~="addon"][data-password-group-align="inline-end"])
)
[data-ui~="input"] {
padding-inline-end: var(--ui-field-px);
}
/* A textarea claims the full width; addons stack above/below via block-* */
:where([data-ui~="password-group"]) [data-ui~="textarea"] {
flex-basis: 100%;
min-block-size: var(--ui-password-group-textarea-min-h);
padding-inline: var(--ui-password-group-textarea-px);
padding-block: var(--ui-password-group-textarea-py);
resize: none;
}
:where([data-ui~="password-group"]) [data-password-group-slot~="addon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: var(--spacing);
color: var(--color-muted-foreground);
white-space: nowrap;
block-size: var(--ui-field-h);
min-inline-size: var(--ui-field-h);
padding-inline: var(--ui-field-addon-padding);
}
:where([data-ui~="password-group"]) [data-password-group-slot~="addon"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
:where([data-ui~="password-group"])
[data-password-group-slot~="addon"]:not([data-password-group-align]),
:where([data-ui~="password-group"])
[data-password-group-slot~="addon"][data-password-group-align="inline-start"] {
order: -1;
}
:where([data-ui~="password-group"])
[data-password-group-slot~="addon"][data-password-group-align="inline-end"] {
order: 1;
}
:where([data-ui~="password-group"])
[data-password-group-slot~="addon"][data-password-group-align="block-start"] {
order: -2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-start;
}
:where([data-ui~="password-group"])
[data-password-group-slot~="addon"][data-password-group-align="block-end"] {
order: 2;
flex-basis: 100%;
padding-block: var(--ui-field-addon-padding);
align-items: flex-end;
}
:where([data-ui~="password-group"]) [data-password-group-slot~="text"] {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding-inline: var(--ui-field-addon-padding);
font-size: var(--ui-password-group-font-size);
font-weight: var(--ui-password-group-font-weight);
color: var(--ui-password-group-text);
min-inline-size: max-content;
}
:where([data-ui~="password-group"]) [data-password-group-slot~="text"] > svg {
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
}
:where([data-ui~="password-group"]) [data-ui~="button"] {
flex-shrink: 0;
border-radius: var(--ui-field-inset-child-radius);
}
:where([data-ui~="field"]:has(:user-invalid))
:where([data-ui~="password-group"])
[data-password-group-slot~="text"] {
--ui-password-group-text: var(--color-destructive);
}
/* Both icons ship in the markup; aria-pressed picks which one shows. */
:where([data-ui~="password-group"])
[data-password-group-slot~="toggle"]
[data-password-group-slot~="icon-hide"] {
display: none;
}
:where([data-ui~="password-group"])
[data-password-group-slot~="toggle"][aria-pressed="true"]
[data-password-group-slot~="icon-show"] {
display: none;
}
:where([data-ui~="password-group"])
[data-password-group-slot~="toggle"][aria-pressed="true"]
[data-password-group-slot~="icon-hide"] {
display: block;
}
}
/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="password-group"]):disabled {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="password-group"]):has(:user-invalid) {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}// 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-password-group-slot~="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-password-group-label-show`: Toggle label while the password is hidden (default "Show password").
* - `data-password-group-label-hide`: Toggle label while the password is visible (default "Hide password").
*
* @example
* <ui-password>
* <label data-ui="password-group">
* <input data-ui="input" type="password" name="password" autocomplete="current-password" />
* <span data-password-group-slot="addon" data-password-group-align="inline-end">
* <button data-ui="button" data-password-group-slot="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-password-group-slot~="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-password-group-label-show") || "Show password";
const labelHide = this.getAttribute("data-password-group-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);
export { UiPassword, resolveToggleState };Behavior
primitives/password-group/password-group.js
<ui-password>: HTML web component for password visibility. 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-password-group-slot~="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-password-group-label-show: Toggle label while the password is hidden (default "Show password").data-password-group-label-hide: Toggle label while the password is visible (default "Hide password").
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="password-group" | Identity | free value | Identity token in data-ui. |
<ui-password> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-password-group-align | Preset | block-end block-start inline-end inline-start | Preset: a named bundle of values. |
data-password-group-slot | Slot | addon icon icon-hide icon-show text toggle | Slot: a part inside the primitive. |
data-password-group-label-hide | Config | free value | Configuration the kit's script reads. |
data-password-group-label-show | Config | free value | Configuration the kit's script reads. |
Requires: fields, input, button.
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-password-group-cursor | text | — |
--ui-password-group-display | flex | — |
--ui-password-group-flex-wrap | wrap | — |
--ui-password-group-font-size | var(--font-size-sm) | — |
--ui-password-group-font-weight | var(--font-weight-strong) | — |
--ui-password-group-gap | var(--spacing) | — |
--ui-password-group-items | center | — |
--ui-password-group-min-h | var(--ui-field-h) | — |
--ui-password-group-p | 0 | — |
--ui-password-group-text | var(--color-muted-foreground) | — |
--ui-password-group-textarea-min-h | 3lh | — |
--ui-password-group-textarea-px | calc(var(--spacing) * 2) | — |
--ui-password-group-textarea-py | calc(var(--spacing) * 1.5) | — |
--ui-password-group-w | 100% | — |