Rxova
Skip to content

Password input — About

The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.

Four things a wordlist-free estimator can model honestly:

SignalHow
Character poolWhich of five classes the password draws from — lowercase, uppercase, digits, ASCII punctuation, non-ASCII
LengthCounted in codepoints, so four emoji are four characters and not eight
Structural repetitionaaaa, abcd, 987654, and keyboard walks like asdf that codepoint arithmetic cannot see
Supplied contextblocklist (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 }

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.

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.

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:

  1. Debounced and abortable. The AbortSignal fires when the password changes again or the component unmounts.
  2. 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.
  3. 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.

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.

  • A real <input>, named by label (or aria-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 buttonaria-pressed, aria-controls, and an explicit tabindex="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", not role="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-describedby is 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.

onWarn receives { code, prop, message } whenever the component keeps itself working despite a prop it cannot use as given.

CodeMeaning
min-length-negativeminLength was negative or non-finite
min-length-non-integerminLength was fractional
max-length-below-minmaxLength under minLength or non-finite; the default cap is used instead
duplicate-rule-idTwo rules share an id, which are used as React keys and data-rule values
autocomplete-missingautoComplete is empty or off, which breaks password managers
estimate-threwA 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.

There is no stylesheet to import. Only layout-critical declarations are inlined; everything visual is a CSS custom property or a data-* hook.

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.

These are public API, covered by semver.

AttributeOnMeaning
data-rx-password-rootwrapperAlways present
data-revealedwrapperPassword is showing as text
data-validwrapperAll rules met and minScore hit
data-scorewrapper04, only when showStrength
data-disabled / data-readonlywrapperMirrors the props
data-invalidwrapperMirrors the prop
data-rx-password-fieldinput rowInput plus toggle
data-rx-password-input<input>The control itself
data-rx-password-toggle<button>The reveal toggle
data-rx-password-caps-lockwarningPresent only while Caps Lock is on
data-rx-password-metermeterrole="meter"
data-rx-password-segmentmeter segment03
data-filledmeter segmentSegment is lit
data-rx-password-strength-labelcaptionThe score caption
data-rx-password-rules<ul>The requirement checklist
data-rule / data-metrule rowThe rule’s id, and whether it passes
data-rx-password-compromisedmessageShown when checkCompromised resolves true
data-rx-password-announcementlive regionOff-screen, aria-live="polite"

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.

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>

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}
/>
)}
/>
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.

<Field name="password">
{({ input }) => (
<PasswordInput
label="Password"
name={input.name}
value={String(input.value)}
onChange={input.onChange}
onBlur={input.onBlur}
/>
)}
</Field>
<form.Field name="password">
{(field) => (
<PasswordInput
label="Password"
name="password"
value={field.state.value}
onChange={(value) => field.handleChange(value)}
onBlur={field.handleBlur}
/>
)}
</form.Field>

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>
  )
}