Build a Password Input with Strength Meter and Caps Lock Detection in React
A practical guide to adding a zxcvbn strength meter, Caps Lock warning, and show/hide toggle to a shadcn/ui password input. Full code included, install in one command.
The standard HTML <input type="password"> does one thing: hides what the user types. That is the floor, not the ceiling. A good password input tells users when their Caps Lock is on, how strong their password is as they type it, and gives them a way to verify what they entered — all without surprising them with behaviour that most libraries get subtly wrong.
This post walks through the PasswordInput component from tent ui — what it does, why each feature is built the way it is, and how to drop it into a Next.js signup form in under five minutes.
What the component ships
- Show/hide toggle — an eye icon that reveals the password on click, removed from the tab order so keyboard users go directly to the next field
- Caps Lock indicator — a small "Caps" pill appears inside the input while the field is focused with Caps Lock on
- zxcvbn strength meter — four segments that fill based on the Dropbox zxcvbn entropy score, with a human-readable label ("Weak", "Fair", "Good", "Strong")
- Paste blocking — paste is disabled by default, but the component gives you a callback to observe (and optionally log) every paste attempt
- Accessible by default — the strength meter uses
role="progressbar"witharia-valuetext, strength messages render insidearia-live="polite", and the toggle exposesaria-pressed
Installation
That pulls in the component plus its registry dependencies (Input, InputGroup, Button). The only external dependency it adds to your project is @zxcvbn-ts/core and @zxcvbn-ts/language-common — about 100 KB gzipped, loaded lazily.
Basic signup field
After installation, import the component and wire it to state:
"use client";
import * as React from "react";
import { PasswordInput } from "@/components/password-input";
export function SignupForm() {
const [password, setPassword] = React.useState("");
return (
<form>
<label htmlFor="password">Password</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
placeholder="At least 12 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Create account</button>
</form>
);
}The strength meter and Caps Lock indicator appear automatically. The autoComplete="new-password" attribute tells the browser (and password managers) this is a new password field, not a sign-in field — important for correct autofill behaviour.
Confirm-password field
For the classic "type it twice" pattern, disable the strength meter on the confirm field and add an error state when the two values diverge:
"use client";
import * as React from "react";
import { PasswordInput } from "@/components/password-input";
export function SignupForm() {
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const mismatch = confirm.length > 0 && confirm !== password;
return (
<form>
<PasswordInput
name="password"
autoComplete="new-password"
placeholder="At least 12 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordInput
name="passwordConfirm"
autoComplete="new-password"
showStrengthMeter={false}
aria-invalid={mismatch}
placeholder="Confirm your password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{mismatch && (
<p role="alert" className="text-destructive text-sm">
Passwords do not match.
</p>
)}
</form>
);
}aria-invalid on the confirm field wires into the shadcn/ui Input styles — it picks up the destructive ring without any extra CSS. The strength meter is suppressed because the first field already shows it, and showing it on the confirm field would confuse users.
Observing paste attempts
Paste is blocked by default. Most security teams ask for this because pasted passwords often come with invisible characters, trailing spaces, or were copied from plain-text documents. But sometimes you want to know when a user tried:
<PasswordInput
onPasswordPaste={(event, pastedText) => {
console.log("paste attempted, length:", pastedText.length);
}}
/>The event has already been preventDefault'd — the paste never actually reaches the input. The callback is for telemetry or soft UX warnings. If your flow genuinely needs paste (for example a randomly-generated recovery code), use a plain <Input type="password" /> for that specific field.
How the strength meter works
The meter uses @zxcvbn-ts, the maintained TypeScript port of Dropbox's zxcvbn library. It scores passwords from 0 (very weak) to 4 (strong) based on entropy, not character class rules. A password like correct-horse-battery-staple scores 4. P@ssw0rd1 scores 1.
The dictionary (@zxcvbn-ts/language-common) is loaded lazily on first render and cached — you pay the ~100 KB network cost exactly once per page load, only on pages that render a PasswordInput.
Props reference
All standard <input> attributes are forwarded (except native onPaste — use onPasswordPaste instead).
Install it in your project
The full source, props documentation, and live demo are at ui.srb.codes/docs/components/password-input. The component lives in your codebase after install — customize the strength thresholds, change the indicator colours, or strip out features you do not need.