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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label.
valuestringnoneControlled value.
defaultValuestring""Starting value when uncontrolled.
onValueChange(value: string) => voidnoneCalled with the new value on every change.
requirements{ label: string; test: (value: string) => boolean }[]nonePlain-language rules listed under the field. Each ticks off as soon as it is met.
showStrengthbooleanfalseShows a four-step bar and a word (Weak, Fair, Good, Strong) for the share of requirements met.
autoCompletestring"current-password"Pass "new-password" on sign-up and reset forms so password managers offer to generate one.
hintReactNodenoneHelp text under the input, read as its description.
errorReactNodenoneError text. Marks the input invalid.
idstringnoneInput id. Generated when left out.
classNamestringnoneAdded to the root, not the input.
...restOmit<ComponentProps<"input">, "type" | "value" | "defaultValue" | "size">noneEvery other native input attribute and ref, including name, disabled and required.

Anatomy

root.eq-password-fieldWraps a Field, which draws the label, input, hint and error.
toggle.eq-password-field-toggleIn the field's trail slot. The slash draws in while the password is hidden.
caps.eq-password-field-capsA 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-wordThe strength in words, so it is never colour alone.
requirements.eq-password-field-requirementsLinked to the input as part of its description.
requirement.eq-password-field-requirement
tick.eq-password-field-tickA dot until met, then a drawn tick.
sr.eq-password-field-srText for screen readers only, such as ", met".

Keyboard

TabMoves from the input to the show toggle.
EnterSpaceOn 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index