Password input
A password field with a reveal toggle and an optional four-segment strength meter.
Controlled when value is set, uncontrolled otherwise; the meter reads whichever is in force. Only the wording sits in the polite live region, and it changes only when the score does, so a screen reader hears a step up rather than every keystroke. Every filled segment takes the tone of the score reached — all four green at Strong rather than a red-to-green gradient — so the bar reads as one verdict. The spoken form says what the word is about (Password strength: Weak), since the bar carrying that context is not announced. getPasswordStrength is exported so a form can score a password without rendering the field.
Install
npx shadcn@latest add @vibra/password-inputNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"
export default function PasswordInputDemo() {
const [password, setPassword] = React.useState("")
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password">New password</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
placeholder="At least 12 characters"
showStrength
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="current-password">Current password</Label>
<PasswordInput id="current-password" size="sm" defaultValue="hunter2" />
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| showStrength | boolean | false | Shows the four-segment meter and its label under the field. |
| size | "sm" | "default" | "default" | sm drops the field to h-7 for dense forms. |
| value | string | — | The password; setting it makes the field controlled. |
| defaultValue | string | — | The starting password for an uncontrolled field. |
| onChange | React.ChangeEventHandler<HTMLInputElement> | — | The input's own change handler; every other input prop passes through too. |
| getPasswordStrength | (password: string) => { score: 0 | 1 | 2 | 3 | 4; label: string } | — | Scores a password: a point each for eight characters, twelve, mixed case, a digit, and a symbol, capped at four. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { EyeIcon, EyeOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const STRENGTH_LABELS = ["Too weak", "Weak", "Fair", "Good", "Strong"] as const
// The whole meter takes the tone of the score it reached, so the bar reads as
// one verdict rather than a gradient.
const STRENGTH_TONES = [
"bg-muted",
"bg-danger",
"bg-warning",
"bg-warning",
"bg-success",
] as const
export type PasswordStrength = {
score: 0 | 1 | 2 | 3 | 4
label: (typeof STRENGTH_LABELS)[number]
}
/** Scores a password out of four: a point each for eight characters, twelve characters, mixed case, a digit, and a symbol. */
export function getPasswordStrength(password: string): PasswordStrength {
if (!password) return { score: 0, label: STRENGTH_LABELS[0] }
const checks = [
password.length >= 8,
password.length >= 12,
/[a-z]/.test(password) && /[A-Z]/.test(password),
/\d/.test(password),
/[^\w\s]|_/.test(password),
]
const score = Math.min(4, checks.filter(Boolean).length) as PasswordStrength["score"]
return { score, label: STRENGTH_LABELS[score] }
}
export type PasswordInputProps = Omit<React.ComponentProps<"input">, "type" | "size"> & {
/** Shows a four-segment strength meter under the field. */
showStrength?: boolean
size?: "sm" | "default"
}
/** A password field with a reveal toggle and an optional strength meter. */
function PasswordInput({
className,
showStrength = false,
size = "default",
value,
defaultValue,
onChange,
disabled,
...props
}: PasswordInputProps) {
const isControlled = value !== undefined
const [visible, setVisible] = React.useState(false)
const [uncontrolled, setUncontrolled] = React.useState(String(defaultValue ?? ""))
const current = isControlled ? String(value ?? "") : uncontrolled
const { score, label } = getPasswordStrength(current)
return (
<div
data-slot="password-input"
data-size={size}
data-visible={visible || undefined}
className={cn("flex w-full flex-col gap-2", className)}
>
<div className="relative">
<Input
type={visible ? "text" : "password"}
autoComplete="current-password"
disabled={disabled}
className={cn("pe-8", size === "sm" ? "h-7" : "h-8")}
{...(isControlled ? { value } : { defaultValue })}
onChange={(event) => {
if (!isControlled) setUncontrolled(event.target.value)
onChange?.(event)
}}
{...props}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={disabled}
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
onClick={() => setVisible((previous) => !previous)}
className="absolute top-1/2 end-1 -translate-y-1/2 text-muted-foreground"
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</Button>
</div>
{showStrength ? (
<div
data-slot="password-input-strength"
data-score={score}
className="flex flex-col gap-1.5"
>
<div aria-hidden="true" className="flex items-center gap-1">
{[1, 2, 3, 4].map((segment) => (
<span
key={segment}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
segment <= score ? STRENGTH_TONES[score] : "bg-muted"
)}
/>
))}
</div>
{/* Only the wording lives in the live region, and it only changes when
the score does — so it is spoken on a step up, not on every key.
The bar carries the context for anyone who can see it; the spoken
form has to say what the one word is about. */}
<span
data-slot="password-input-strength-label"
aria-live="polite"
className="min-h-4 text-xs text-muted-foreground"
>
{current ? (
<>
<span className="sr-only">Password strength: </span>
{label}
</>
) : null}
</span>
</div>
) : null}
</div>
)
}
export { PasswordInput }