Password
Free · MITHeadless, 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
npx astrocraft-ui add forms/passwordPlain-CSS theme — no build step
npx astrocraft-ui add forms/password --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/password --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/password --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/password --theme tailwind --bridge lumosLive 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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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>
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 — 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.