Password input — About
The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.
The strength estimator
Section titled “The strength estimator”What it models
Section titled “What it models”Four things a wordlist-free estimator can model honestly:
| Signal | How |
|---|---|
| Character pool | Which of five classes the password draws from — lowercase, uppercase, digits, ASCII punctuation, non-ASCII |
| Length | Counted in codepoints, so four emoji are four characters and not eight |
| Structural repetition | aaaa, abcd, 987654, and keyboard walks like asdf that codepoint arithmetic cannot see |
| Supplied context | blocklist (your product name) and userInputs (their email, their name) |
Plus ~50 corpus staples, matched through a leet table — so P4ssw0rd scores zero, and so do
trustno1 and abc123.
import { estimateStrength } from '@rxova/react-password-input'
estimateStrength('P4ssw0rd')// { score: 0, entropy: 8, penalties: ['blocklisted'], effectiveLength: 8 }
estimateStrength('correct-horse-battery-staple')// { score: 4, entropy: 164.71, penalties: [], effectiveLength: 28 }What it does not model
Section titled “What it does not model”It does not know English. Tr0ub4dor&3 scores 3 here, because nothing on the client knows
“troubadour” is a word. Catching that is exactly what a few hundred kilobytes of wordlists buys, and if you need it,
buy it on purpose:
// Any dictionary-based estimator fits here; `estimate` only cares about the// shape it returns.import { estimateWithWordlists } from 'your-estimator-of-choice'import { PasswordInput } from '@rxova/react-password-input'import type { PasswordScore } from '@rxova/react-password-input'
function Field() { return ( <PasswordInput label="Password" showStrength estimate={(password) => { const result = estimateWithWordlists(password) return { score: result.score as PasswordScore, entropy: Math.log2(result.guesses), penalties: [], effectiveLength: password.length, } }} /> )}An estimate that throws is contained: the meter falls back to the built-in estimator and onWarn
reports estimate-threw. One bad adapter does not take the login form down.
The entropy figure is a comparator, not a measurement. A real offline-attack cost model needs
the attacker’s wordlist, which no client-side estimator has. The meter is a nudge; the defences that
matter are a length floor, a breach-corpus check, and server-side rate limiting.
Why the default rule set is one rule
Section titled “Why the default rule set is one rule”NIST SP 800-63B requires a length minimum, requires accepting long passphrases, and says verifiers
“SHOULD NOT impose other composition rules”. The upper/lower/digit/symbol checklist trains users
into Password1! and measurably lowers real entropy.
So the default is a single length rule, and commonRules is there for products under an older
compliance regime.
maxLength defaults to 128 and cannot be removed, only moved. An unbounded field is a
denial-of-service surface: the estimator runs over the whole value on every keystroke, and whatever
the form posts to runs a deliberately slow KDF over it, so a single paste sets both costs. NIST
requires accepting at least 64 characters and says nothing about accepting unlimited ones, and
OWASP ASVS asks for a documented maximum for exactly this reason. 128 clears the NIST floor twice
over and sits well past the longest passphrase anyone types — ten diceware words is about 60
characters — so a silently truncated passphrase, which is a support ticket nobody can diagnose,
stays out of reach.
Breach checks
Section titled “Breach checks”checkCompromised is the only way this component learns anything about the outside world, and it
is entirely yours. The library issues no request of its own — that is the whole point of the
shape. An adversarial test asserts fetch is never called.
import { PasswordInput } from '@rxova/react-password-input'
/** Have I Been Pwned's k-anonymity API: only a 5-character hash prefix is sent. */async function checkPwned(password: string, signal: AbortSignal) { const bytes = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(password)) const hash = [...new Uint8Array(bytes)] .map((b) => b.toString(16).padStart(2, '0')) .join('') .toUpperCase() const response = await fetch(`https://api.pwnedpasswords.com/range/${hash.slice(0, 5)}`, { signal, }) return (await response.text()).includes(hash.slice(5))}
function Field() { return <PasswordInput label="Password" showStrength checkCompromised={checkPwned} />}Three properties worth knowing:
- Debounced and abortable. The
AbortSignalfires when the password changes again or the component unmounts. - A verdict is stamped with the password it belongs to. A slow answer for an old password simply stops applying — the stale-response race is structurally impossible, not merely guarded.
- A failed lookup reports unknown, never safe. Telling a user their password is fine because the network was down is a lie in the one direction that gets people hurt.
The check is skipped entirely while the field is disabled.
The caret
Section titled “The caret”This is the least obvious code in the package, and it is empirical.
Every engine collapses an input’s selection during the mousedown that precedes the toggle click.
Preventing that default keeps focus in the input but does not preserve the selection, and
listening on the input’s own select event is no help either — the collapse fires one. So the
range is captured during the mousedown dispatch, before any default action runs.
Then it has to be restored twice. Tracing a real click shows the restored range correct in a microtask and gone by the next frame: React re-syncs the controlled input’s value after the click finishes dispatching, which clobbers a single restore. So the layout effect restores it (before paint, so there is no visible jump) and an animation frame re-applies it (after React has finished). Dropping either one is a visible bug.
If you use the headless hook, wire captureSelection to the toggle’s onMouseDown or you will not
get this behaviour.
Accessibility
Section titled “Accessibility”- A real
<input>, named bylabel(oraria-label). The browser supplies keyboard behaviour, form participation and password-manager integration. The name is not rendered — pair it with your own<label htmlFor={id}>for visible text. - The reveal control is a toggle button —
aria-pressed,aria-controls, and an explicittabindex="0"so Safari includes it in the tab order without Full Keyboard Access. Without that attribute the toggle is unreachable by keyboard on a default macOS install. - The toggle’s hit area meets WCAG 2.5.8 Target Size (Minimum) at 24×24 CSS pixels.
- Caps Lock is
role="status", notrole="alert"— worth saying, not worth interrupting. - The checklist states met/unmet in text, so meaning never rides on colour alone (WCAG 1.4.1).
- The meter carries
aria-valuetext(“Fair”); the bare number reads as “2” with no unit. - One polite live region announces the strength bucket and the rule tally. Because the caption is bucketed rather than continuous, React only rewrites that text when the bucket changes — so it announces once on crossing into “Fair”, not on all six characters that stayed inside it.
aria-describedbyis assembled from the parts that actually rendered, so it never dangles.
axe (WCAG 2.1 A/AA) runs over the component in the browser suite and over the whole demo page in Chromium, Firefox and WebKit.
Diagnostics
Section titled “Diagnostics”onWarn receives { code, prop, message } whenever the component keeps itself working despite a
prop it cannot use as given.
| Code | Meaning |
|---|---|
min-length-negative | minLength was negative or non-finite |
min-length-non-integer | minLength was fractional |
max-length-below-min | maxLength under minLength or non-finite; the default cap is used instead |
duplicate-rule-id | Two rules share an id, which are used as React keys and data-rule values |
autocomplete-missing | autoComplete is empty or off, which breaks password managers |
estimate-threw | A custom estimate threw; the built-in estimator was used instead |
With no handler these go to console.warn. The entire path is stripped from production builds —
it sits behind a process.env.NODE_ENV !== 'production' branch, so there is no runtime cost and no
console noise in production. The E2E suite asserts this against a real production bundle.
Styling
Section titled “Styling”There is no stylesheet to import. Only layout-critical declarations are inlined; everything visual
is a CSS custom property or a data-* hook.
Custom properties
Section titled “Custom properties”Set them on [data-rx-password-root] or any ancestor:
[data-rx-password-root] { --rx-password-gap: 0.375rem; --rx-password-field-gap: 0.25rem; --rx-password-toggle-size: 1.75rem; --rx-password-meter-height: 0.25rem; --rx-password-meter-gap: 0.125rem; --rx-password-meter-radius: 999px; --rx-password-meter-track: rgba(0 0 0 / 0.15); --rx-password-meter-fill: currentColor; --rx-password-rules-indent: 1.25rem;}--rx-password-meter-fill-1 through -4 colour the meter per score, each falling back to
--rx-password-meter-fill. Keep --rx-password-toggle-size at 1.5rem or above: at the default
font size that is the 24×24 CSS pixels WCAG 2.5.8 Target Size (Minimum) requires.
Stable data-* hooks
Section titled “Stable data-* hooks”These are public API, covered by semver.
| Attribute | On | Meaning |
|---|---|---|
data-rx-password-root | wrapper | Always present |
data-revealed | wrapper | Password is showing as text |
data-valid | wrapper | All rules met and minScore hit |
data-score | wrapper | 0–4, only when showStrength |
data-disabled / data-readonly | wrapper | Mirrors the props |
data-invalid | wrapper | Mirrors the prop |
data-rx-password-field | input row | Input plus toggle |
data-rx-password-input | <input> | The control itself |
data-rx-password-toggle | <button> | The reveal toggle |
data-rx-password-caps-lock | warning | Present only while Caps Lock is on |
data-rx-password-meter | meter | role="meter" |
data-rx-password-segment | meter segment | 0–3 |
data-filled | meter segment | Segment is lit |
data-rx-password-strength-label | caption | The score caption |
data-rx-password-rules | <ul> | The requirement checklist |
data-rule / data-met | rule row | The rule’s id, and whether it passes |
data-rx-password-compromised | message | Shown when checkCompromised resolves true |
data-rx-password-announcement | live region | Off-screen, aria-live="polite" |
Form libraries
Section titled “Form libraries”label names the field but renders nothing, so pair it with your own <label htmlFor={id}> when
the design calls for visible text. Every recipe below is transcribed from
src/__tests__/form.browser.test.tsx, so it is code that runs on every commit.
Native forms
Section titled “Native forms”With a name, the input posts itself. No adapter, no controlled state:
<form action="/sign-in" method="post"> <PasswordInput name="password" label="Password" autoComplete="current-password" /> <button type="submit">Sign in</button></form>React Hook Form
Section titled “React Hook Form”onChange emits a string rather than an event, so field.onChange binds directly. The forwarded
ref lands on the <input>, which is what setFocus() and focus-first-error patterns expect:
<Controller name="password" control={control} render={({ field }) => ( <PasswordInput label="Password" value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )}/>Formik
Section titled “Formik”function PasswordField() { const [field, , helpers] = useField<string>('password') return ( <PasswordInput label="Password" name="password" value={field.value} onChange={(value) => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} /> )}onBlur fires when focus leaves the whole field, not when it moves from the input to the
reveal toggle — so a field is not marked touched the moment someone clicks the eye.
React Final Form
Section titled “React Final Form”<Field name="password"> {({ input }) => ( <PasswordInput label="Password" name={input.name} value={String(input.value)} onChange={input.onChange} onBlur={input.onBlur} /> )}</Field>TanStack Form
Section titled “TanStack Form”<form.Field name="password"> {(field) => ( <PasswordInput label="Password" name="password" value={field.state.value} onChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> )}</form.Field>UI-library recipes
Section titled “UI-library recipes”Keep the password input, reveal button, meter, and announcements together. Use the design system around that composite control rather than rebuilding only its visible input.
shadcn/ui
'use client'
import { PasswordField } from '@/components/rxova/password-field'
export function ShadcnPassword() {
return (
<PasswordField
label="Password"
description="Use at least eight characters."
name="password"
showStrength
/>
)
}
Radix Themes
'use client'
import { Box, Text } from '@radix-ui/themes'
import { PasswordInput } from '@rxova/react-password-input'
export function RadixPassword() {
return (
<Box>
<Text as="div" size="2" weight="bold" mb="1">
Password
</Text>
<PasswordInput label="Password" name="password" showStrength />
<Text as="div" size="1" color="gray" mt="1">
Use at least eight characters.
</Text>
</Box>
)
}
Material UI
'use client'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { PasswordInput } from '@rxova/react-password-input'
export function MuiPassword() {
return (
<FormControl>
<FormLabel>Password</FormLabel>
<PasswordInput label="Password" name="password" showStrength />
<FormHelperText>Use at least eight characters.</FormHelperText>
</FormControl>
)
}
Chakra UI
'use client'
import { Field } from '@chakra-ui/react'
import { PasswordInput } from '@rxova/react-password-input'
export function ChakraPassword() {
return (
<Field.Root>
<Field.Label>Password</Field.Label>
<PasswordInput label="Password" name="password" showStrength />
<Field.HelperText>Use at least eight characters.</Field.HelperText>
</Field.Root>
)
}
Mantine
'use client'
import { Input } from '@mantine/core'
import { PasswordInput } from '@rxova/react-password-input'
export function MantinePassword() {
return (
<Input.Wrapper label="Password" description="Use at least eight characters.">
<PasswordInput label="Password" name="password" showStrength />
</Input.Wrapper>
)
}
Ant Design
'use client'
import { Form } from 'antd'
import { PasswordInput } from '@rxova/react-password-input'
export function AntPassword() {
return (
<Form.Item label="Password" extra="Use at least eight characters.">
<PasswordInput label="Password" name="password" showStrength />
</Form.Item>
)
}