A password input with a reveal toggle and a local strength indicator.
Strength: Empty. Use 12+ characters with varied letters, numbers and symbols.
pnpm dlx shadcn@latest add @latheui/password-fieldFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-reactAdd the Lathe items this component imports.
hooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { EyeIcon, EyeOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
export type PasswordFieldProps = Omit<
React.ComponentProps<"input">,
"type" | "value" | "defaultValue"
> & {
label: string
value?: string
defaultValue?: string
strength?: boolean
/**
* One CSS color per strength bar, weakest first. The last one repeats if there are fewer
* than four. Leave it out for bars in the primary color.
*/
colors?: readonly string[]
}
export function PasswordField({
label,
value: valueProp,
defaultValue = "",
strength = true,
colors,
onChange,
id: idProp,
className,
...props
}: PasswordFieldProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
})
const [visible, setVisible] = React.useState(false)
const generated = React.useId()
const id = idProp ?? generated
const score =
value.length === 0
? 0
: Number(value.length >= 8) +
Number(value.length >= 12) +
Number(/[a-z]/.test(value) && /[A-Z]/.test(value)) +
Number(/[0-9]/.test(value) && /[^a-zA-Z0-9]/.test(value))
const levels = ["Empty", "Weak", "Fair", "Good", "Strong"]
return (
<div className="w-full space-y-2">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<div
className={cn(
"flex items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
className
)}
>
<input
autoComplete="new-password"
{...props}
id={id}
type={visible ? "text" : "password"}
value={value}
onChange={(e) => {
setValue(e.target.value)
onChange?.(e)
}}
aria-describedby={
[props["aria-describedby"], strength ? id + "-strength" : ""]
.filter(Boolean)
.join(" ") || undefined
}
className="h-9 min-w-0 flex-1 bg-transparent px-3 text-base outline-none disabled:opacity-50 md:text-sm"
/>
<button
type="button"
disabled={props.disabled}
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
onClick={() => setVisible(!visible)}
className="m-1 grid size-7 cursor-pointer place-items-center rounded-sm text-muted-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{visible ? (
<EyeOffIcon aria-hidden="true" className="size-4" />
) : (
<EyeIcon aria-hidden="true" className="size-4" />
)}
</button>
</div>
{strength && (
<div id={id + "-strength"} className="space-y-1.5">
<div aria-hidden="true" className="flex gap-1">
{[1, 2, 3, 4].map((i) => (
<span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-muted">
{/* The fill glides in from the left and back out to it, one bar after another. */}
<span
className={cn(
"block size-full origin-left rounded-full bg-primary transition-transform duration-300 ease-out motion-reduce:transition-none",
i <= score ? "scale-x-100" : "scale-x-0"
)}
style={{
backgroundColor: colors?.[Math.min(i, colors.length) - 1],
transitionDelay: (i <= score ? i - 1 : 4 - i) * 60 + "ms",
}}
/>
</span>
))}
</div>
<p className="text-xs text-muted-foreground">
Strength: {levels[score]}. Use 12+ characters with varied letters,
numbers and symbols.
</p>
</div>
)}
</div>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | string | — | Visible field label. |
| strength | boolean | true | Show the local heuristic strength meter. |
| colors | string[] | — | One CSS color per strength bar, weakest first. The last repeats if there are fewer than four. Defaults to the primary color. |
| value / defaultValue | string | — | Controlled or initial password. |
| autoComplete | string | "new-password" | Use current-password for sign-in forms. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { PasswordField } from "@/components/ui/password-field"
export default function Demo(){return <div className="w-full max-w-sm"><PasswordField label="Create a password" placeholder="Enter your password"/></div>}