Skip to main content
astrocraft-ui/ components · 101

Password

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/password

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/password --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/password --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/password --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/password --theme tailwind --bridge lumos

Live demo

Password — toggle & strength

Enter a password

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`PasswordInput.astro``eye` `eye-off` `password-field` `password-input` `password-toggle``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
—`[aria-pressed]`
`PasswordStrength.astro``password-strength` `password-strength-label` `password-strength-segment` `password-strength-track``data-size`: `sm` · `md``data-tone`—

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/password/PasswordInput.astro
---
// src/components/ui/forms/password/PasswordInput.astro — headless primitive (see ../../README.md).
// A password field with a show/hide toggle. The small script flips the field `type` between
// password/text and keeps the button's `aria-pressed` / `aria-label` accurate — and that is ALL it
// does. The eye / eye-off swap is pure CSS off that same `aria-pressed`, so there is one source of
// truth and no class is ever touched from JavaScript (structure.css carries the two display rules).
// Without JS it stays a plain, fully-usable password field (the button is simply inert).
// Give it a name via `id`/`name`. Carries the shared `data-size` / `data-state` field surface.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const { size = "md", state = "default", class: className, ...rest } = Astro.props;
---

<div data-slot="password-input">
  <input
    type="password"
    autocomplete="current-password"
    data-slot="password-field"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  <button type="button" data-slot="password-toggle" aria-pressed="false" aria-label="Show password">
    <slot name="show"
      ><svg
        data-slot="eye"
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path>
        <circle cx="12" cy="12" r="3"></circle>
      </svg></slot
    >
    <slot name="hide"
      ><svg
        data-slot="eye-off"
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M9.88 9.88a3 3 0 0 0 4.24 4.24"></path>
        <path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"
        ></path>
        <path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"></path>
        <line x1="2" x2="22" y1="2" y2="22"></line>
      </svg></slot
    >
  </button>
</div>

<script>
  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const field = root.querySelector<HTMLInputElement>('[data-slot="password-field"]');
    const btn = root.querySelector<HTMLButtonElement>('[data-slot="password-toggle"]');
    if (!field || !btn) return;
    btn.addEventListener("click", () => {
      const shown = field.type === "text";
      field.type = shown ? "password" : "text";
      btn.setAttribute("aria-pressed", String(!shown));
      btn.setAttribute("aria-label", shown ? "Show password" : "Hide password");
    });
  }
  onReadyOnce('[data-slot="password-input"]', wire);
</script>
src/components/ui/forms/password/PasswordStrength.astro
---
// src/components/ui/forms/password/PasswordStrength.astro — headless primitive (see ../../README.md).
// A 4-segment strength meter + a live label, driven by the rule-based `scorePassword` (strength.ts).
// It watches the paired field (`for` = the input's id, mirroring <label for>), scores on every input,
// and marks N segments with `data-tone="weak|fair|good|strong"` — deleting the attribute on the
// unfilled ones. The script sets nothing but that attribute and the label text; a theme paints the
// tones. The label sits in an aria-live region so screen readers announce changes. Without JS the bar
// renders empty with a neutral hint — no error.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & { for: string; size?: "sm" | "md" };

const { for: target, size = "md", class: className, ...rest } = Astro.props;
---

<div class={className} data-slot="password-strength" data-size={size} data-for={target} {...rest}>
  <div data-slot="password-strength-track" aria-hidden="true">
    {[0, 1, 2, 3].map(() => <div data-slot="password-strength-segment" />)}
  </div>
  <p data-slot="password-strength-label" aria-live="polite">Enter a password</p>
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { scorePassword } from "./strength";

  const TONE = ["weak", "fair", "good", "strong"] as const; // score 1..4
  const LABEL = ["Enter a password", "Weak", "Fair", "Good", "Strong"] as const; // score 0..4

  function wire(root: HTMLElement) {
    const field = document.getElementById(root.dataset.for ?? "");
    if (!(field instanceof HTMLInputElement)) return;
    const segs = [...root.querySelectorAll<HTMLElement>('[data-slot="password-strength-segment"]')];
    const label = root.querySelector<HTMLElement>('[data-slot="password-strength-label"]');

    const paint = () => {
      const score = scorePassword(field.value);
      segs.forEach((seg, i) => {
        if (score > 0 && i < score) seg.dataset.tone = TONE[score - 1];
        else delete seg.dataset.tone;
      });
      if (label) label.textContent = LABEL[score];
    };

    field.addEventListener("input", paint);
    paint();
  }

  onReadyOnce('[data-slot="password-strength"]', wire);
</script>
src/components/ui/forms/password/index.ts
import PasswordInput from "./PasswordInput.astro";
import PasswordStrength from "./PasswordStrength.astro";

export { scorePassword, type Strength } from "./strength";
export { PasswordInput, PasswordStrength };
export default PasswordInput;
src/components/ui/forms/password/strength.ts
// src/components/ui/forms/password/strength.ts — the scoring rule behind PasswordStrength, in a plain module
// so it's unit-checkable (see strength.test.ts) and swappable per project.

export type Strength = 0 | 1 | 2 | 3 | 4; // none · weak · fair · good · strong

/**
 * Score a password's strength on a 0–4 scale.
 *
 * ponytail: naive rule-based scorer (length + character-class diversity), NOT entropy. Ceiling: it
 * rates "Password1!" as strong even though it's weak. Upgrade path: drop in `zxcvbn` per-project for
 * real estimation — keep this signature so callers (PasswordStrength) don't change.
 *
 * @param pw - the raw password string
 * @returns a {@link Strength} from 0 (empty) to 4 (strong)
 * @example scorePassword("Abcd1234!xyz") // => 4
 */
export function scorePassword(pw: string): Strength {
  if (!pw) return 0;
  let s = 0;
  if (pw.length >= 8) s++;
  if (pw.length >= 12) s++;
  if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
  if (/\d/.test(pw) && /[^A-Za-z0-9]/.test(pw)) s++;
  return Math.min(s, 4) as Strength;
}

What you get

The component source, copied into your project by npx astrocraft-ui add forms/password — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.