Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Password group

A password input with a show/hide toggle.

Installation

pnpm dlx zazz-ui add password-group
npx zazz-ui add password-group
yarn dlx zazz-ui add password-group
bunx zazz-ui add password-group

Also adds fields, kbd, button, input, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Use eight or more characters. Password must be at least 8 characters.
Warning: showing the password makes it visible to anyone near your screen.
<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

password-group attributes
AttributeKindValuesNotes
data-ui="password-group"Identityfree valueIdentity token in data-ui.
<ui-password>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-password-group-alignPresetblock-end block-start inline-end inline-startPreset: a named bundle of values.
data-password-group-slotSlotaddon icon icon-hide icon-show text toggleSlot: a part inside the primitive.
data-password-group-label-hideConfigfree valueConfiguration the kit's script reads.
data-password-group-label-showConfigfree valueConfiguration 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.

password-group hooks
HookDefaultState
--ui-password-group-cursortext—
--ui-password-group-displayflex—
--ui-password-group-flex-wrapwrap—
--ui-password-group-font-sizevar(--font-size-sm)—
--ui-password-group-font-weightvar(--font-weight-strong)—
--ui-password-group-gapvar(--spacing)—
--ui-password-group-itemscenter—
--ui-password-group-min-hvar(--ui-field-h)—
--ui-password-group-p0—
--ui-password-group-textvar(--color-muted-foreground)—
--ui-password-group-textarea-min-h3lh—
--ui-password-group-textarea-pxcalc(var(--spacing) * 2)—
--ui-password-group-textarea-pycalc(var(--spacing) * 1.5)—
--ui-password-group-w100%—