Building a Robust Password Strength Indicator in React
Learn how to build an accessible, accurate React password strength indicator with scoring logic, UX patterns, TypeScript code, and testing tips.
Image used for representation purposes only.
Why a Password Strength Indicator Matters
A password strength indicator does two jobs at once: it educates users about what makes a password resilient and gives immediate, actionable feedback during sign‑up or password changes. Done well, it improves security outcomes without increasing friction. Done poorly—by enforcing simplistic “must include a special character” rules—it can push users into predictable patterns.
This article walks through building a robust, accessible, and privacy‑preserving password strength indicator in React. We’ll cover scoring logic, UI patterns, performance, testing, and optional integration with a dedicated estimator.
What “Strength” Really Means
- Entropy/guessability: How many attempts would an offline attacker need? Longer, less predictable phrases tend to be stronger than short, complex‑looking strings.
- Pattern resistance: Avoid dictionary words, keyboard sequences, and common substitutions (e.g., “P@ssw0rd”).
- Context awareness: User‑specific inputs (name, email, site name) reduce effective strength.
Complexity rules are a proxy; strength is about resistance to realistic attacks. Our indicator should guide users toward length, unpredictability, and avoidance of common patterns.
Design Goals
- Accuracy: Score by length, character diversity, and pattern penalties; optionally complement with a proven estimator.
- Accessibility: Screen‑reader friendly, color‑contrast safe, and not color‑only.
- Privacy: Never log or transmit raw passwords client‑side; avoid analytics hooks.
- Performance: Lightweight by default, with optional lazy/dynamic imports for heavy libraries.
Architecture Overview
We’ll implement three layers:
- Scoring: A pure function that maps a password + optional user inputs to a score (0–4), label, and feedback.
- Presentation: A React component that renders a bar/meter and text feedback.
- Integration: A controlled input example with debouncing and i18n hooks.
The Scoring Function (TypeScript)
The following function is intentionally simple, explainable, and fast. It rewards length and character variety, and penalizes common pitfalls like repeats, sequences, and dictionary words. It also considers user‑provided context (e.g., email handle).
// strength.ts
export type StrengthResult = {
score: 0 | 1 | 2 | 3 | 4;
label: 'Very weak' | 'Weak' | 'Fair' | 'Strong' | 'Very strong';
suggestions: string[];
};
const COMMON_PATTERNS = [
'password', 'letmein', 'qwerty', 'iloveyou', 'admin', 'welcome',
'dragon', 'monkey', 'abc123', '123456', '12345678', 'football'
];
const leetMap: Record<string, string> = { '0': 'o', '1': 'l', '3': 'e', '4': 'a', '5': 's', '7': 't', '@': 'a', '$': 's' };
function normalizeForDict(s: string) {
// Lowercase and de-leet to catch common substitutions
return s
.toLowerCase()
.replace(/[013457@$]/g, (c) => leetMap[c] ?? c);
}
function hasSeq(password: string): boolean {
// Detect ascending sequences of length >= 4 (e.g., abcd, 1234)
const p = password.toLowerCase();
for (let i = 0; i <= p.length - 4; i++) {
const a = p.charCodeAt(i);
const b = p.charCodeAt(i + 1);
const c = p.charCodeAt(i + 2);
const d = p.charCodeAt(i + 3);
if (b === a + 1 && c === b + 1 && d === c + 1) return true;
}
return false;
}
function hasKeyboardRowSeq(password: string): boolean {
// Simple keyboard row check: qwerty, asdf, zxcv, etc.
const rows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
const p = password.toLowerCase();
return rows.some((row) => row.includes(p) || row.split('').reverse().join('').includes(p))
|| rows.some((row) => {
for (let i = 0; i <= p.length - 4; i++) {
if (row.includes(p.slice(i, i + 4))) return true;
if (row.split('').reverse().join('').includes(p.slice(i, i + 4))) return true;
}
return false;
});
}
export function evaluateStrength(password: string, userInputs: string[] = []): StrengthResult {
const suggestions: string[] = [];
if (!password) return { score: 0, label: 'Very weak', suggestions: ['Use at least 12 characters.'] };
const lengthScore = password.length >= 16 ? 3 : password.length >= 12 ? 2 : password.length >= 8 ? 1 : 0;
if (lengthScore < 2) suggestions.push('Make it longer (12+ characters recommended).');
const sets = {
lower: /[a-z]/.test(password),
upper: /[A-Z]/.test(password),
digit: /\d/.test(password),
symbol: /[^A-Za-z0-9]/.test(password)
};
const variety = Object.values(sets).filter(Boolean).length;
if (variety < 3) suggestions.push('Mix UPPER/lowercase, digits, and symbols.');
let penalty = 0;
if (/(.)\1{2,}/.test(password)) { // repeated chars like aaa or 111
penalty += 1; suggestions.push('Avoid repeated characters.');
}
if (hasSeq(password)) { penalty += 1; suggestions.push('Avoid obvious sequences (e.g., abcd, 1234).'); }
if (hasKeyboardRowSeq(password)) { penalty += 1; suggestions.push('Avoid keyboard patterns (e.g., qwerty).'); }
const normalized = normalizeForDict(password);
if (COMMON_PATTERNS.some((w) => normalized.includes(w))) {
penalty += 2; suggestions.push('Avoid common words and phrases.');
}
if (userInputs.length && userInputs.some((u) => u && normalized.includes(normalizeForDict(u)))) {
penalty += 2; suggestions.push('Don’t include personal info (name, email, site).');
}
// Base score: length + variety, then subtract penalties; clamp to 0–4
let raw = lengthScore + Math.max(0, variety - 1) - penalty; // variety-1 scales 0..3
raw = Math.max(0, Math.min(4, raw));
const label = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][raw];
if (raw < 3) suggestions.push('Consider a passphrase of 3–5 random words.');
// Dedupe suggestions, keep concise
const unique = Array.from(new Set(suggestions)).slice(0, 4);
return { score: raw as 0 | 1 | 2 | 3 | 4, label: label as StrengthResult['label'], suggestions: unique };
}
The React Component
Our meter should be perceivable for all users. We’ll use role=“meter” with aria attributes, visible text, and color + shape changes.
// StrengthMeter.tsx
import React, { useMemo } from 'react';
import { evaluateStrength } from './strength';
export type StrengthMeterProps = {
value: string;
userInputs?: string[]; // e.g., [email, username, siteName]
className?: string;
};
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const;
export function StrengthMeter({ value, userInputs = [], className }: StrengthMeterProps) {
const result = useMemo(() => evaluateStrength(value, userInputs), [value, userInputs]);
return (
<div className={className}>
<div
role="meter"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={result.score}
aria-valuetext={`Password strength: ${result.label}`}
className="pw-meter"
data-score={result.score}
>
{[0,1,2,3].map((i) => (
<span key={i} className={`bar ${i <= result.score - 1 ? 'filled' : ''}`} />
))}
</div>
<div className="pw-meter-text">
<strong>{labels[result.score]}</strong>
{result.suggestions.length > 0 && (
<ul aria-live="polite">
{result.suggestions.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
)}
</div>
</div>
);
}
Minimal CSS (CSS Modules or global)
The colors progressively change with the score. Ensure sufficient contrast and use more than color alone.
/* strength.css */
.pw-meter {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
height: 8px;
margin-block: 6px;
}
.pw-meter .bar { background: #e5e7eb; border-radius: 2px; }
.pw-meter .bar.filled { background: var(--pw-color, #16a34a); }
/* Score-dependent color */
.pw-meter[data-score="0"] { --pw-color: #ef4444; }
.pw-meter[data-score="1"] { --pw-color: #f59e0b; }
.pw-meter[data-score="2"] { --pw-color: #eab308; }
.pw-meter[data-score="3"] { --pw-color: #10b981; }
.pw-meter[data-score="4"] { --pw-color: #16a34a; }
.pw-meter-text strong { display: inline-block; min-width: 7.5ch; }
.pw-meter-text ul { margin: 4px 0 0; padding-left: 18px; font-size: 0.9rem; color: #374151; }
Example Usage
// PasswordField.tsx
import React, { useState, useMemo } from 'react';
import { StrengthMeter } from './StrengthMeter';
import './strength.css';
export function PasswordField({ email }: { email?: string }) {
const [pwd, setPwd] = useState('');
const userInputs = useMemo(() => [email ?? ''], [email]);
return (
<label style={{ display: 'block' }}>
<span>Create password</span>
<input
type="password"
autoComplete="new-password"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
aria-describedby="pw-help"
/>
<StrengthMeter value={pwd} userInputs={userInputs} />
<small id="pw-help">Use a long, unique passphrase. Don’t reuse passwords.</small>
</label>
);
}
Optional: Add a Proven Estimator (zxcvbn) Lazily
If you want deeper analysis (e.g., estimated crack time, keyboard spans, and large dictionaries), you can lazy‑load a dedicated estimator to keep your main bundle lean.
// useZxcvbn.ts
import { useEffect, useState } from 'react';
export function useZxcvbn(password: string, userInputs: string[] = []) {
const [result, setResult] = useState<{ score: number; feedback: { suggestions: string[] } } | null>(null);
useEffect(() => {
let mounted = true;
if (!password) { setResult(null); return; }
(async () => {
// @ts-ignore: dynamic import example; replace with your chosen estimator package
const { default: zxcvbn } = await import('zxcvbn');
if (!mounted) return;
const r = zxcvbn(password, userInputs);
setResult({ score: r.score, feedback: { suggestions: r.feedback?.suggestions ?? [] } });
})();
return () => { mounted = false; };
}, [password, userInputs.join('|')]);
return result;
}
You can then blend scores: use the local evaluator for immediate UI and replace with the estimator’s result once loaded. For large forms, consider running heavy analysis in a Web Worker to keep typing responsive.
UX Best Practices
- Encourage length over arbitrary complexity. Suggest passphrases like “battery zebra orbit mural”.
- Provide concrete suggestions (e.g., “avoid repeated characters”), not just a numeric score.
- Don’t block paste. It frustrates users and breaks password manager flows.
- Show strength feedback as the user types, but avoid jittery animations.
- Don’t require special characters; nudge toward uniqueness and length instead.
Accessibility Considerations
- Use role=“meter” (or role=“progressbar”) with aria-valuemin/now/max and aria-valuetext.
- Don’t rely on color alone—include a text label (e.g., “Fair”).
- Ensure contrast for filled bars and text (WCAG AA or better).
- Announce suggestion updates with aria-live=“polite”.
Privacy and Security Notes
- Never log the password (no console.log, no analytics events with input values).
- Avoid sending passwords to the server until the user submits the form.
- Disable client‑side telemetry for password fields; don’t attach generic input listeners that capture content.
- Normalize inputs only in memory; don’t persist.
Performance Tips
- Wrap evaluation in useMemo and compute only when the value changes.
- Debounce expensive estimators by ~150–250 ms.
- Lazy‑load heavy libraries; optionally offload to a Web Worker.
Internationalization (i18n)
Keep labels and suggestions in a dictionary so you can localize strings without changing logic:
// i18n.ts
export const en = {
labels: ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'],
suggestions: {
longer: 'Make it longer (12+ characters recommended).',
mix: 'Mix UPPER/lowercase, digits, and symbols.',
repeats: 'Avoid repeated characters.',
seq: 'Avoid obvious sequences (e.g., abcd, 1234).',
kb: 'Avoid keyboard patterns (e.g., qwerty).',
common: 'Avoid common words and phrases.',
personal: 'Don’t include personal info (name, email, site).',
passphrase: 'Consider a passphrase of 3–5 random words.'
}
};
Testing the Logic and UI
Unit‑test the evaluator and check accessibility for the component.
// strength.test.ts (Vitest/Jest)
import { describe, it, expect } from 'vitest';
import { evaluateStrength } from './strength';
describe('evaluateStrength', () => {
it('flags common words', () => {
const r = evaluateStrength('password');
expect(r.score).toBeLessThan(2);
});
it('rewards length and variety', () => {
const r = evaluateStrength('S0mething!Longer#2026');
expect(r.score).toBeGreaterThanOrEqual(3);
});
it('penalizes sequences', () => {
const r = evaluateStrength('abcdEF12!');
expect(r.score).toBeLessThanOrEqual(2);
});
});
// StrengthMeter.a11y.test.tsx
import { render, screen } from '@testing-library/react';
import { StrengthMeter } from './StrengthMeter';
test('announces strength with aria-valuetext', () => {
render(<StrengthMeter value="passphrase example" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemin', '0');
expect(meter).toHaveAttribute('aria-valuemax', '4');
expect(meter).toHaveAttribute('aria-valuetext');
});
Integrating with Form Libraries
With React Hook Form, compute strength in a controlled input and use the result to enable/disable submission or to warn users.
import { useForm } from 'react-hook-form';
import { StrengthMeter } from './StrengthMeter';
export function Signup() {
const { register, watch, handleSubmit } = useForm();
const pwd = watch('password') ?? '';
return (
<form onSubmit={handleSubmit(console.log)} autoComplete="on">
<input type="email" {...register('email', { required: true })} autoComplete="email" />
<input type="password" {...register('password', { required: true })} autoComplete="new-password" />
<StrengthMeter value={pwd} userInputs={[watch('email') ?? '']} />
<button type="submit">Create account</button>
</form>
);
}
Handling Edge Cases
- Unicode and emoji: Treat them as symbols; the evaluator still rewards length.
- Whitespace: Allow spaces; passphrases benefit from them.
- Clipboard flows: Support paste and password managers; don’t strip characters.
- Mobile keyboards: Avoid transformations (auto‑capitalize, auto‑correct) on password inputs.
Conclusion
A great React password strength indicator balances clarity, accessibility, and realism. Start with a transparent, fast evaluator; layer on a rich estimator if needed; prioritize user privacy and usability. The result guides users toward long, unique passphrases—without unnecessary friction.
Related Posts
Building an Accessible React kbd Shortcut Component
Build an accessible React kbd shortcut component with cross‑platform Mod keys, a useHotkeys hook, and tested, flexible APIs.
Build an Accessible, Production‑Ready React OTP/PIN Input
Build an accessible, secure, paste-aware React OTP/PIN input with Web OTP, mobile keypad, and full keyboard support.
Build a Rock-Solid React Scroll Spy Navigation with Hooks and TypeScript
Build a robust React scroll spy navigation with hooks, a11y, smooth scrolling, SSR tips, and TypeScript—plus an optional IntersectionObserver version.