React Components API
The @sentinel-password/react-components package provides an accessible, headless PasswordInput component.
Installation
npm install @sentinel-password/react-components@sentinel-password/core is pulled in transitively as a regular dependency of @sentinel-password/react-components — you don't need to install it directly.
Peer Dependencies (^18.0.0 || ^19.0.0 each):
- React 18 or 19
- React DOM 18 or 19
Components
<PasswordInput />
A headless password input that runs validation through @sentinel-password/core and exposes the result via callback.
Features:
- Designed for WCAG 2.1 AAA (see Accessibility guide for what the component covers vs. what's the consumer's responsibility)
- Full ARIA support (
aria-invalid,aria-describedby,aria-live,aria-pressed) - Keyboard navigation, including
Escapeto clear - Show/hide password toggle
- Real-time validation with debouncing
- Controlled and uncontrolled modes
- Completely unstyled — bring your own CSS
Configure validation through validatorOptions
Pass policy and i18n options via the validatorOptions prop — they're forwarded to every internal validatePassword(...) call. Covers minLength, requireUppercase, personalInfo, plus the v1.2.0 i18n options messages and formatMessage. The component also re-validates the current value when the validatorOptions reference changes (so a locale switch refreshes feedback without the user editing the field). Memoize the object if it contains closures.
Props
PasswordInput extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'>. Most standard input attributes are forwarded to the underlying <input> — but the component reserves several for its own a11y and controlled-state logic (see Reserved props below).
Forwarded (consumer values reach the <input>): name, placeholder, className, style, autoFocus, required, minLength, maxLength, pattern, inputMode, onFocus, onBlur, data-*, etc.
interface PasswordInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
// Required
label: string
// Optional content
description?: string
// Callbacks
onChange?: (value: string) => void
onValidationChange?: (result: ValidationResult) => void
onShowPasswordChange?: (show: boolean) => void
// Behavior
showPassword?: boolean
validateOnMount?: boolean // Default: false
validateOnChange?: boolean // Default: true
debounceMs?: number // Default: 300
// Visibility flags
showValidationMessages?: boolean // Default: true
showToggleButton?: boolean // Default: true
// Validator policy + i18n (forwarded to validatePassword)
validatorOptions?: ValidatorOptions
// Toggle button i18n
toggleShowText?: string // Default: 'Show'
toggleHideText?: string // Default: 'Hide'
toggleShowLabel?: string // Default: 'Show password' (aria-label)
toggleHideLabel?: string // Default: 'Hide password' (aria-label)
// Class names (each targets a specific subtree)
containerClassName?: string
labelClassName?: string
descriptionClassName?: string
inputWrapperClassName?: string
toggleButtonClassName?: string
validationClassName?: string
}| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — (required) | Accessible label rendered as <label> |
description | string | — | Helper text below the label, linked via aria-describedby |
onChange | (value: string) => void | — | Fired with the new string value (not the event) |
onValidationChange | (result: ValidationResult) => void | — | Fired whenever validation completes |
showPassword | boolean | uncontrolled | Controlled show/hide state |
onShowPasswordChange | (show: boolean) => void | — | Fired when the toggle button changes state |
validateOnMount | boolean | false | Validate the initial value once on mount — but only if value/defaultValue is non-empty (an empty initial input silently skips). Mount validation goes through the same debounce path as normal validation, so the result lands ~debounceMs after mount; set debounceMs: 0 for synchronous mount validation. |
validateOnChange | boolean | true | Validate on every change (debounced) |
debounceMs | number | 300 | Debounce delay for validation. 0 validates synchronously. |
showValidationMessages | boolean | true | Render the validation <ul> |
showToggleButton | boolean | true | Render the show/hide button |
validatorOptions | ValidatorOptions | undefined | Forwarded to every validatePassword(...) call inside the component. Covers minLength, requireUppercase, personalInfo, plus the i18n options messages / formatMessage from core@1.2.0. Nested rather than spread because React.InputHTMLAttributes already defines minLength / maxLength as HTML attributes. Memoize this object if it contains closures. |
toggleShowText | string | 'Show' | Visible button text when the password is hidden |
toggleHideText | string | 'Hide' | Visible button text when the password is visible |
toggleShowLabel | string | 'Show password' | aria-label when hidden |
toggleHideLabel | string | 'Hide password' | aria-label when visible |
containerClassName | string | '' | Class on the outer <div> wrapper |
labelClassName | string | '' | Class on <label> |
descriptionClassName | string | '' | Class on the description <div> |
inputWrapperClassName | string | '' | Class on the <input> + toggle wrapper |
toggleButtonClassName | string | '' | Class on the show/hide <button> |
validationClassName | string | '' | Class on the validation messages <div> |
className | string | — | Forwarded to the <input> itself (via spread) |
| ...standard input props | — | — | name, placeholder, autoFocus, required, etc. — see Reserved props for what doesn't pass through |
Reserved Props
The component owns these and overrides any consumer value. Passing them is harmless but has no effect:
| Prop | Why it's reserved |
|---|---|
id | Generated via useId() so the <label> can be associated with the input |
onChange | Replaced with a (value: string) => void signature — see the onChange row above |
aria-describedby | Built dynamically from description and validation message IDs |
aria-invalid | Set automatically when validation fails |
autoComplete | Hardcoded to "new-password" — appropriate for the password use case and prevents browsers from autofilling other credentials |
ref | Not forwarded — the component is not wrapped in React.forwardRef |
type | Toggled internally between "password" and "text"; Omit-ed from the props type so you can't pass it |
onKeyDown is wrapped, not overridden: the component handles Escape (clears the input) and then calls your handler for all keys, so user onKeyDown continues to receive events.
Controlled vs Uncontrolled
PasswordInput decides controlled mode by checking value !== undefined — not by checking whether onChange is also supplied. The rule:
| What you pass | Mode | Notes |
|---|---|---|
Neither value nor defaultValue | Uncontrolled | Internal state starts as "". |
defaultValue="…" only | Uncontrolled | Internal state seeded from defaultValue. |
value="…" + onChange | Controlled | Standard React pattern. You own the state. |
value="…" without onChange | Controlled — but broken | React owns the value but you never update it, so the input is effectively read-only. defaultValue is silently ignored in this case too. Almost always a bug. |
If you find yourself wanting defaultValue and value together, you actually want either fully controlled (drop defaultValue, lift state to your component) or fully uncontrolled (drop value, keep defaultValue).
Basic Usage
Simple
import { PasswordInput } from '@sentinel-password/react-components'
function SignupForm() {
return (
<form>
<PasswordInput
label="Create Password"
description="At least 8 characters"
onValidationChange={(result) => console.log(result.strength)}
/>
</form>
)
}Controlled
import { PasswordInput } from '@sentinel-password/react-components'
import { useState } from 'react'
import type { ValidationResult } from '@sentinel-password/core'
function SignupForm() {
const [password, setPassword] = useState('')
const [result, setResult] = useState<ValidationResult | undefined>()
return (
<form>
<PasswordInput
label="Create Password"
value={password}
onChange={setPassword}
onValidationChange={setResult}
/>
<button type="submit" disabled={!result?.valid}>
Create Account
</button>
</form>
)
}Uncontrolled With Default Value
PasswordInput does not currently forward refs. For uncontrolled usage, give the input a name (it flows through to the underlying <input>) and read the value from FormData on submit:
import { PasswordInput } from '@sentinel-password/react-components'
function ResetForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const data = new FormData(e.currentTarget)
console.log('Submitted:', data.get('password'))
}
return (
<form onSubmit={handleSubmit}>
<PasswordInput label="New Password" name="password" defaultValue="" />
<button type="submit">Reset</button>
</form>
)
}Refs are not forwarded
The component is a plain function component, not wrapped in React.forwardRef. Passing ref={...} will be silently ignored. Use the controlled pattern (value + onChange) when you need direct access to the value, or the FormData pattern above for uncontrolled forms.
Styling
The component renders only structural HTML and ARIA — every subtree gets its own *ClassName prop so you can attach styles selectively. There is no built-in CSS.
Plain CSS
<PasswordInput
label="Password"
containerClassName="password-field"
labelClassName="password-label"
inputWrapperClassName="password-wrapper"
className="password-input"
toggleButtonClassName="password-toggle"
validationClassName="password-feedback"
/>.password-field { margin-bottom: 1rem; }
.password-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
.password-wrapper { position: relative; }
.password-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
}
.password-input:focus { border-color: #3c8772; outline: none; }
.password-input[aria-invalid='true'] { border-color: #e53e3e; }
.password-toggle {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
}
.password-feedback { margin-top: 0.5rem; color: #e53e3e; font-size: 0.875rem; }Tailwind CSS
<PasswordInput
label="Password"
containerClassName="mb-4"
labelClassName="block text-sm font-medium text-gray-700 mb-2"
inputWrapperClassName="relative"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
toggleButtonClassName="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-sm"
validationClassName="mt-2 text-sm text-red-600"
/>Behavior Details
Validation
The component calls validatePassword(value, validatorOptions) from @sentinel-password/core. With validatorOptions omitted, the built-in defaults apply (minLength: 8, all check* flags on, no required character types, no personalInfo). Pass any subset of ValidatorOptions — including the v1.2.0 i18n options messages and formatMessage — to customize policy or localize the rendered messages. The returned ValidationResult is forwarded to onValidationChange and rendered as a list of messages inside the validationClassName container.
When the validatorOptions reference changes (e.g. a locale switch in the consumer), the component re-validates the current value automatically so the visible feedback refreshes without the user editing the field. Memoize the object in the consumer if it contains closures to avoid spurious re-validation.
If you'd rather drive validation yourself and render your own input, use usePasswordValidator instead.
Validation Messages
When showValidationMessages is true and the validation result has any feedback to show, the component renders:
<div role="alert" aria-live="polite">
<ul>
<li data-severity="warning">{feedback.suggestions[0]}</li>
<!-- one <li data-severity="error"> per remaining suggestion -->
</ul>
</div>feedback.warning is always equal to feedback.suggestions[0] (the first failure, surfaced for prominent display), so the component renders it once with data-severity="warning" and the remaining suggestions with data-severity="error" — the warning is not rendered as a separate row.
A valid password produces an empty feedback.suggestions array and no feedback.warning, so the component renders nothing — the entire <ul> is skipped. In practice only warning and error severities are emitted today.
The data-severity attribute lets you style each message kind:
li[data-severity='warning'] { color: orange; }
li[data-severity='error'] { color: red; }Want a "Password OK" message?
The component doesn't render a success row today (feedback.suggestions is empty on valid passwords). If you want a green confirmation, render it yourself from onValidationChange:
const [valid, setValid] = useState(false)
<PasswordInput
label="Password"
onValidationChange={(result) => setValid(result.valid)}
/>
{valid && <p style={{ color: 'green' }}>Password meets all requirements</p>}ValidationMessageSeverity includes a 'success' variant for forward compatibility, but no built-in rendering path produces it today.
Keyboard Shortcuts
Tab/Shift+Tab: move between input, toggle button, and surrounding form controlsEscape: clear the input and reset validation (only when input has focus)Space/Enteron the toggle button: show/hide the password
Show/Hide Toggle
The toggle is a real <button type="button"> with aria-pressed reflecting visibility. Hide it with showToggleButton={false} if you don't want it.
<PasswordInput label="Password" showToggleButton={false} />You can also control visibility yourself:
const [show, setShow] = useState(false)
<PasswordInput
label="Password"
showPassword={show}
onShowPasswordChange={setShow}
/>TypeScript
import { PasswordInput } from '@sentinel-password/react-components'
import type {
PasswordInputProps,
ValidationMessage,
ValidationMessageSeverity,
} from '@sentinel-password/react-components'