Inputs
Password field
A labelled password input with a show toggle, a Caps Lock notice and optional requirements and strength.
- Built on
<input type="password">autocompletearia-pressedgetModifierState- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
- Built from
- Field
Strength: Fair
- 8+ characters, met
- One number, met
- One capital, not met
- One symbol, not met
Try its options
Loading its options.
Code
"use client";
import { PasswordField } from "@/components/equip/password-field";
const requirements = [
{ label: "8+ characters", test: (v: string) => v.length >= 8 },
{ label: "One number", test: (v: string) => /\d/.test(v) },
{ label: "One capital", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One symbol", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
];
export default function Demo() {
return (
<div style={{ width: 224 }}>
<PasswordField
label="New password"
name="new-password"
autoComplete="new-password"
defaultValue="trellis42"
requirements={requirements}
showStrength
/>
</div>
);
}Your versionchange its options in Look, This part
Examples
Sign in
The eye button keeps focus in the field, and a Caps Lock notice appears while you type with it on.
The one you use for the Larder workspace.
Code
import { PasswordField } from "@/components/equip/password-field";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 280 }}>
<PasswordField label="Password" name="password" hint="The one you use for the Larder workspace." />
</div>
);
}Error
That password is not right. 2 tries left.
Code
import { PasswordField } from "@/components/equip/password-field";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 280 }}>
<PasswordField label="Password" name="password" defaultValue="hunter2" error="That password is not right. 2 tries left." />
</div>
);
}Requirements without a meter
The list is linked as the field's description, so each rule and whether it is met is read on focus.
- 12 or more characters, not met
- Not your email address, not met
- No spaces at either end, not met
Code
"use client";
import { PasswordField } from "@/components/equip/password-field";
const requirements = [
{ label: "12 or more characters", test: (v: string) => v.length >= 12 },
{ label: "Not your email address", test: (v: string) => v !== "" && !v.toLowerCase().includes("dana@larder") },
{ label: "No spaces at either end", test: (v: string) => v !== "" && v.trim() === v },
];
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 300 }}>
<PasswordField label="Vault passphrase" name="passphrase" autoComplete="new-password" requirements={requirements} />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label. |
value | string | none | Controlled value. |
defaultValue | string | "" | Starting value when uncontrolled. |
onValueChange | (value: string) => void | none | Called with the new value on every change. |
requirements | { label: string; test: (value: string) => boolean }[] | none | Plain-language rules listed under the field. Each ticks off as soon as it is met. |
showStrength | boolean | false | Shows a four-step bar and a word (Weak, Fair, Good, Strong) for the share of requirements met. |
autoComplete | string | "current-password" | Pass "new-password" on sign-up and reset forms so password managers offer to generate one. |
hint | ReactNode | none | Help text under the input, read as its description. |
error | ReactNode | none | Error text. Marks the input invalid. |
id | string | none | Input id. Generated when left out. |
className | string | none | Added to the root, not the input. |
...rest | Omit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "size"> | none | Every other native input attribute and ref, including name, disabled and required. |
Anatomy
| root | .eq-password-field | Wraps a Field, which draws the label, input, hint and error. |
|---|---|---|
| toggle | .eq-password-field-toggle | In the field's trail slot. The slash draws in while the password is hidden. |
| caps | .eq-password-field-caps | A polite live region, empty and out of the layout until Caps Lock is on. |
| meter | .eq-password-field-meter | |
| bar | .eq-password-field-bar | |
| segment | .eq-password-field-segment | |
| word | .eq-password-field-word | The strength in words, so it is never colour alone. |
| requirements | .eq-password-field-requirements | Linked to the input as part of its description. |
| requirement | .eq-password-field-requirement | |
| tick | .eq-password-field-tick | A dot until met, then a drawn tick. |
| sr | .eq-password-field-sr | Text for screen readers only, such as ", met". |
Keyboard
| Tab | Moves from the input to the show toggle. |
|---|---|
| EnterSpace | On the toggle, shows or hides the password. |
Notes
- The toggle is a button with aria-pressed and a fixed name, Show password; clicking it keeps focus and the caret in the input.
- Caps Lock is read from key events, so the notice appears after the first key press with it on, and clears when the field loses focus.
- The strength meter counts only the requirements you pass; it is guidance for the person typing, not a security check.
- Check passwords on the server too, against your own rules and known breached passwords.
- The part never logs or stores the value; a shown password is plain text in the page, so hide it again before screen sharing.
- Without requirements, showStrength draws nothing, since there is nothing to measure.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/password-field.tsx155 lines
"use client";
import { useId, useState } from "react";
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import { Field } from "./field";
import "./password-field.css";
export type PasswordRequirement = {
/** Plain-language rule, e.g. "One number". */
label: string;
test: (value: string) => boolean;
};
export type PasswordFieldProps = Omit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "size"> & {
label: ReactNode;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Rules listed under the field. Each ticks off as soon as the value meets it. */
requirements?: PasswordRequirement[];
/** Shows a four-step meter and a word for the share of requirements met. */
showStrength?: boolean;
hint?: ReactNode;
error?: ReactNode;
};
const words = ["Weak", "Fair", "Good", "Strong"];
export function PasswordField({
label,
value,
defaultValue = "",
onValueChange,
requirements = [],
showStrength = false,
autoComplete = "current-password",
hint,
error,
id,
className,
disabled,
onChange,
onKeyDown,
onKeyUp,
onBlur,
"aria-describedby": describedBy,
...rest
}: PasswordFieldProps) {
const autoId = useId();
const inputId = id ?? autoId;
const [inner, setInner] = useState(defaultValue);
const current = value ?? inner;
const [shown, setShown] = useState(false);
const [caps, setCaps] = useState(false);
const met = requirements.map((r) => r.test(current));
const count = met.filter(Boolean).length;
const level = count === 0 ? 0 : Math.ceil((count / requirements.length) * 4);
const reqsId = requirements.length > 0 ? `${inputId}-requirements` : undefined;
const readCaps = (event: KeyboardEvent<HTMLInputElement>) => setCaps(event.getModifierState("CapsLock"));
return (
<div className={["eq-password-field", className].filter(Boolean).join(" ")}>
<Field
{...rest}
id={inputId}
label={label}
hint={hint}
error={error}
type={shown ? "text" : "password"}
value={current}
autoComplete={autoComplete}
autoCapitalize="off"
spellCheck={false}
disabled={disabled}
trailInset="button"
aria-describedby={[describedBy, reqsId].filter(Boolean).join(" ") || undefined}
onChange={(event) => {
onChange?.(event);
if (value === undefined) setInner(event.target.value);
onValueChange?.(event.target.value);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
readCaps(event);
}}
onKeyUp={(event) => {
onKeyUp?.(event);
readCaps(event);
}}
onBlur={(event) => {
onBlur?.(event);
setCaps(false);
}}
trail={
<button
type="button"
className="eq-password-field-toggle"
aria-label="Show password"
aria-pressed={shown}
aria-controls={inputId}
disabled={disabled}
// Keeps focus, and the caret, in the input when the toggle is clicked.
onPointerDown={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setShown((s) => !s)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8Z" />
<circle cx="8" cy="8" r="2" />
<path data-slash="" d="m2.75 2.75 10.5 10.5" pathLength="1" />
</svg>
</button>
}
/>
<p className="eq-password-field-caps" role="status">
{caps && (
<>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2.75 3 8h2.75v2.5h4.5V8H13L8 2.75ZM5.75 13.25h4.5" />
</svg>
Caps Lock is on
</>
)}
</p>
{showStrength && requirements.length > 0 && (
<div className="eq-password-field-meter" data-level={level}>
<span className="eq-password-field-bar" aria-hidden="true">
{[1, 2, 3, 4].map((n) => (
<span key={n} className="eq-password-field-segment" data-on={n <= level || undefined} />
))}
</span>
<span className="eq-password-field-word" aria-live="polite">
<span className="eq-password-field-sr">Strength: </span>
{level === 0 ? "None" : words[level - 1]}
</span>
</div>
)}
{requirements.length > 0 && (
<ul className="eq-password-field-requirements" id={reqsId}>
{requirements.map((r, i) => (
<li key={r.label} className="eq-password-field-requirement" data-met={met[i] || undefined}>
<svg className="eq-password-field-tick" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
<path d="m4.5 8.25 2.25 2.25 4.75-5" pathLength="1" />
</svg>
{r.label}
<span className="eq-password-field-sr">{met[i] ? ", met" : ", not met"}</span>
</li>
))}
</ul>
)}
</div>
);
}components/equip/password-field.css194 lines
@layer equip {
.eq-password-field {
display: flex;
flex-direction: column;
gap: var(--eq-space-2);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-password-field-toggle {
display: grid;
place-items: center;
width: calc(var(--eq-h-m) - 8px);
height: calc(var(--eq-h-m) - 8px);
margin: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-password-field-toggle > svg {
width: 16px;
height: 16px;
}
/* The slash draws across the eye while the password is hidden and retracts when shown. */
.eq-password-field-toggle [data-slash] {
stroke-dasharray: 2 3;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
.eq-password-field-toggle[aria-pressed="true"] {
color: var(--eq-ink);
}
.eq-password-field-toggle[aria-pressed="true"] [data-slash] {
stroke-dashoffset: 2;
transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-password-field-toggle:hover:not(:disabled) {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-password-field-toggle:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-password-field-toggle:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-password-field-toggle:disabled {
cursor: not-allowed;
}
.eq-password-field-caps {
display: flex;
align-items: center;
gap: var(--eq-space-1);
margin: 0;
font: var(--eq-weight-strong) var(--eq-text-xs) / 1.4 var(--eq-font);
color: var(--eq-warn);
}
.eq-password-field-caps > svg {
flex: none;
width: 14px;
height: 14px;
}
/* Empty, the live region stays in the page but takes no room and no gap. */
.eq-password-field-caps:empty,
.eq-password-field-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-password-field-meter {
--_on: var(--eq-ink);
display: flex;
align-items: center;
gap: var(--eq-space-2);
}
.eq-password-field-meter[data-level="1"] {
--_on: var(--eq-danger);
}
.eq-password-field-meter[data-level="2"] {
--_on: var(--eq-warn);
}
.eq-password-field-meter[data-level="4"] {
--_on: var(--eq-ok);
}
.eq-password-field-bar {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--eq-space-1);
}
.eq-password-field-segment {
height: 4px;
border-radius: var(--eq-radius-full);
background: var(--eq-tint-strong);
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-password-field-segment[data-on] {
background: var(--_on);
}
.eq-password-field-word {
flex: none;
min-width: 6ch;
font: var(--eq-weight-strong) var(--eq-text-xs) / 1.2 var(--eq-font);
text-align: end;
}
.eq-password-field-requirements {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13ch, 1fr));
gap: 2px var(--eq-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.eq-password-field-requirement {
display: flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
color: var(--eq-ink-2);
transition: color var(--eq-dur) var(--eq-ease);
}
.eq-password-field-requirement[data-met] {
color: var(--eq-ink);
}
.eq-password-field-tick {
flex: none;
width: 14px;
height: 14px;
color: var(--eq-ink-3);
}
.eq-password-field-requirement[data-met] .eq-password-field-tick {
color: var(--eq-ok);
}
.eq-password-field-tick > circle {
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-password-field-requirement[data-met] .eq-password-field-tick > circle {
opacity: 0;
}
/* Drawn along the stroke, as in Checkbox: the spring may overshoot without clipping. */
.eq-password-field-tick > path {
stroke-dasharray: 2 3;
stroke-dashoffset: 2;
transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
}
.eq-password-field-requirement[data-met] .eq-password-field-tick > path {
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
}