## Password Strength — Input Strength read segment by segment. Docs: https://www.interior.dev/docs/password-strength Reference: https://www.interior.dev/reference/password-strength License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/password-strength.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { PasswordStrength, usePasswordStrength, } from "@/components/interior/password-strength"; export function SignUpForm() { const [password, setPassword] = useState(""); const { score, max } = usePasswordStrength(password); return (
e.preventDefault()} className="w-full max-w-sm"> setPassword(e.target.value)} className="mt-2 h-9 w-full rounded-[9px] border border-stone-200 px-3 text-[13px] dark:border-white/[0.16]" /> ); } ``` ### Props - `value` (`string`) The password being typed. The component is controlled and keeps no copy of the secret. - `rules` (`readonly PasswordRule[]`) — default: `defaultPasswordRules` The requirement set. Its length is the number of segments, so a five-rule policy draws five cells without touching the markup. - `labels` (`readonly string[]`) — default: `["Empty", "Weak", "Fair", "Good", "Strong"]` One label per score from 0 to rules.length. All of them share a single grid cell, so the widest sets the width once. - `announceDelay` (`number`) — default: `700` Milliseconds of quiet before the live region speaks. The visible meter is never delayed by it. - `showRules` (`boolean`) — default: `true` Renders the requirement checklist under the meter. Turn it off when the policy is stated elsewhere on the page. - `className` (`string`) — default: `""` Appended last, so a caller's spacing or width class wins. ### Behavior notes - Strength is a whole number of segments, never a percentage, so the meter cannot report a change too small to name; the cells, the label and the count move together or not at all. - The five verdict labels occupy one grid cell and the requirement list is fixed length, so nothing below the field, including the submit button, moves while a password is typed. - A screen reader hears the verdict once, after typing stops, from a polite region that names the level and the requirements still outstanding, instead of a new announcement per keystroke. - Meaning is never carried by color: the number of filled cells, the label, and a per-row met or not met string each state it, so the component survives greyscale and low vision. - Segments fill with transform rather than width, and each cell springs from wherever it currently is, so deleting three characters reverses the fill mid-flight instead of restarting it. - Common passwords, four-character repeats and keyboard walks are capped at one segment, because a meter that calls Passw0rd! strong is worse than no meter at all. ### Source (`components/interior/password-strength.tsx`) ```tsx "use client"; import { useEffect, useMemo, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const INSTANT = { duration: 0 } as const; const COMMON = /^(?:password|passw0rd|qwerty|letmein|welcome|admin|iloveyou|monkey|dragon|abc123|111111|123123|123456)/i; const RUN = /(.)\1{3,}/; const RUN_UP = /(?:0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef|defg|qwer|wert|erty|asdf)/i; const SYMBOL = /[!-/:-@[-`{-~]/; export type PasswordRule = { id: string; label: string; test: (value: string) => boolean; }; export type EvaluatedRule = PasswordRule & { met: boolean }; export type UsePasswordStrengthOptions = { rules?: readonly PasswordRule[]; labels?: readonly string[]; announceDelay?: number; }; export type PasswordStrengthState = { score: number; max: number; label: string; rules: EvaluatedRule[]; guessable: boolean; announcement: string; }; export const defaultPasswordRules: readonly PasswordRule[] = [ { id: "length", label: "12 characters or more", test: (v) => v.length >= 12 }, { id: "case", label: "Upper and lower case", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v), }, { id: "digit", label: "A number", test: (v) => /\d/.test(v) }, { id: "symbol", label: "A symbol", test: (v) => SYMBOL.test(v) }, ]; const defaultLabels = ["Empty", "Weak", "Fair", "Good", "Strong"] as const; export function usePasswordStrength( value: string, { rules = defaultPasswordRules, labels = defaultLabels, announceDelay = 700, }: UsePasswordStrengthOptions = {}, ): PasswordStrengthState { const state = useMemo(() => { const evaluated = rules.map((rule) => ({ ...rule, met: rule.test(value) })); const passed = evaluated.reduce((n, r) => n + (r.met ? 1 : 0), 0); const guessable = value.length > 0 && (COMMON.test(value) || RUN.test(value) || RUN_UP.test(value)); const score = value.length === 0 ? 0 : guessable ? 1 : Math.min(rules.length, Math.max(1, passed)); const label = labels[Math.min(score, labels.length - 1)] ?? ""; const unmet = evaluated.filter((r) => !r.met); const announcement = value.length === 0 ? "" : [ `Password strength ${label.toLowerCase()}.`, guessable ? "This is a commonly guessed pattern." : "", unmet.length === 0 ? "All requirements met." : `Still needed: ${unmet.map((r) => r.label.toLowerCase()).join(", ")}.`, ] .filter(Boolean) .join(" "); return { score, max: rules.length, label, rules: evaluated, guessable, announcement }; }, [value, rules, labels]); const [settled, setSettled] = useState(""); useEffect(() => { if (state.announcement === "") { setSettled(""); return; } const id = setTimeout(() => setSettled(state.announcement), announceDelay); return () => clearTimeout(id); }, [state.announcement, announceDelay]); return { ...state, announcement: settled }; } export type PasswordStrengthProps = { value: string; rules?: readonly PasswordRule[]; labels?: readonly string[]; announceDelay?: number; showRules?: boolean; className?: string; }; const TONES = { none: { bar: "bg-stone-300 dark:bg-white/20", text: "text-stone-500 dark:text-stone-400" }, danger: { bar: "bg-red-500", text: "text-red-600 dark:text-red-400" }, caution: { bar: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" }, safe: { bar: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" }, } as const; function toneFor(score: number, max: number) { if (score === 0) return TONES.none; const ratio = score / max; if (ratio <= 0.34) return TONES.danger; if (ratio <= 0.67) return TONES.caution; return TONES.safe; } export function PasswordStrength({ value, rules = defaultPasswordRules, labels = defaultLabels, announceDelay = 700, showRules = true, className = "", }: PasswordStrengthProps) { const { score, max, label, rules: evaluated, guessable, announcement, } = usePasswordStrength(value, { rules, labels, announceDelay }); const reduced = useReducedMotion(); const tone = toneFor(score, max); return (
{Array.from({ length: max }, (_, i) => (
))}
{labels.map((text, i) => ( {text} ))} Commonly guessed
{showRules && (
    {evaluated.map((rule) => (
  • {rule.label} {rule.met ? "met" : "not met"}
  • ))}
)}

{announcement}

); } ```