Password
Password input patterns — show/hide toggle and a live strength meter.
Examples
Show/hide toggle
Lets the user reveal what they typed — useful on mobile, or when the password
manager didn't autofill. The toggle swaps the <input> type between
password and text and updates its own label so screen readers announce the
new state.
"use client";
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function PasswordToggleDemo() {
const id = React.useId();
const [show, setShow] = React.useState(false);
const [value, setValue] = React.useState("");
return (
<div className="grid w-full gap-2">
<Label htmlFor={id}>Password</Label>
<div className="relative">
<Input
id={id}
type={show ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
aria-label={show ? "Hide password" : "Show password"}
aria-pressed={show}
aria-controls={id}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring absolute inset-y-0 right-0 flex items-center justify-center rounded-md px-3 outline-none focus-visible:ring-[3px]",
)}
>
{show ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</button>
</div>
</div>
);
}Notes:
- Use a real
<button type="button">, not a clickable<div>or<span>, so the control is focusable and operable with Enter/Space. - Update
aria-label(not just the icon) when state flips, and usearia-pressedto expose the toggle state to assistive tech. aria-controls={id}points at the input the button operates on.- Keep the default
autoComplete="current-password"(ornew-passwordon signup) so password managers still fill the field — they key off the input name/autocomplete, not thetype. pr-10on the input leaves room for the button so text does not run under the icon.
Strength meter
Gives the user live feedback on how their password is scoring. Instead of
pulling in a dictionary-based library like zxcvbn,
this demo estimates entropy with a tiny LZ77-style compressor. The flat
length × log₂(poolSize) formula is a bad lie — it rates aaaaaaaa and
k7!mQp#2 at the same 37 bits. Compression fixes that: a repeated substring
(aaaaaaaa, abcabcabc, passpass) encodes as a cheap back-reference, and
a run of consecutive or adjacent code points (abcdef, 111111) collapses
to ~1–2 bits per character. A long passphrase like correct horse battery staple still lands in the hundreds of bits. See xkcd 936
for why length beats character variety.
Caveats: compression catches self-similarity, but it doesn't know that
password is in every leaked-credential list on earth or that qwerty is a
keyboard walk. Reach for zxcvbn when you need dictionary coverage.
Long passphrases beat short, complex passwords. See xkcd 936.
"use client";
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function poolSize(password: string): number {
let pool = 0;
if (/[a-z]/.test(password)) pool += 26;
if (/[A-Z]/.test(password)) pool += 26;
if (/\d/.test(password)) pool += 10;
if (/[^A-Za-z0-9\s]/.test(password)) pool += 32;
if (/\s/.test(password)) pool += 1;
if (/[^\x20-\x7e]/.test(password)) pool += 100;
return pool;
}
// Estimate entropy with a tiny LZ77-style compressor. A naive
// `length * log2(poolSize)` formula rates `aaaaaaaa` and `k7!mQp#2` the same;
// compression correctly charges near-zero bits for runs and back-references,
// and a consecutive-character penalty catches sequences like `abcdef` or
// `111111` that LZ alone wouldn't discount.
function estimateEntropy(password: string): number {
if (!password) return 0;
const perChar = Math.log2(poolSize(password));
let bits = 0;
let i = 0;
while (i < password.length) {
// 1. Longest back-reference into the prefix (LZ77).
let matchLen = 0;
for (let off = 1; off <= i; off++) {
let len = 0;
while (
i + len < password.length &&
password[i - off + len] === password[i + len]
)
len++;
if (len > matchLen) matchLen = len;
}
if (matchLen >= 2) {
bits += Math.log2(i + 1) + Math.log2(matchLen + 1);
i += matchLen;
continue;
}
// 2. Consecutive same or adjacent code-point (`aa`, `ab`, `12`).
if (i > 0) {
const delta = Math.abs(
password.charCodeAt(i) - password.charCodeAt(i - 1),
);
if (delta <= 1) {
bits += 1 + delta;
i += 1;
continue;
}
}
// 3. Otherwise pay the full per-character cost.
bits += perChar;
i += 1;
}
return bits;
}
const levels = [
{ label: "Too weak", color: "bg-destructive", max: 28 },
{ label: "Weak", color: "bg-destructive", max: 40 },
{ label: "Fair", color: "bg-amber-500", max: 60 },
{ label: "Good", color: "bg-lime-500", max: 80 },
{ label: "Strong", color: "bg-emerald-500", max: Infinity },
] as const;
function scoreFromEntropy(entropy: number): number {
return levels.findIndex((l) => entropy < l.max);
}
export function PasswordStrengthDemo() {
const id = React.useId();
const descId = `${id}-desc`;
const [show, setShow] = React.useState(false);
const [value, setValue] = React.useState("");
const entropy = estimateEntropy(value);
const score = scoreFromEntropy(entropy);
const { label, color } = levels[score];
return (
<div className="grid w-full gap-3">
<Label htmlFor={id}>Password</Label>
<div className="relative">
<Input
id={id}
type={show ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Try 'correct horse battery staple'"
autoComplete="new-password"
aria-describedby={descId}
className="pr-10"
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
aria-label={show ? "Hide password" : "Show password"}
aria-pressed={show}
aria-controls={id}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring absolute inset-y-0 right-0 flex items-center justify-center rounded-md px-3 outline-none focus-visible:ring-[3px]"
>
{show ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</button>
</div>
<div id={descId} className="grid gap-2">
<div
className="flex gap-1"
role="progressbar"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={score}
aria-label={`Password strength: ${label}`}
>
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
value && i <= score ? color : "bg-muted",
)}
/>
))}
</div>
{value ? (
<p className="text-muted-foreground text-xs">
Strength:{" "}
<span className="text-foreground font-medium">{label}</span>
<span className="ml-2 tabular-nums">
~{entropy.toFixed(0)} bits of entropy
</span>
</p>
) : (
<p className="text-muted-foreground text-xs">
Long passphrases beat short, complex passwords. See{" "}
<a
href="https://xkcd.com/936/"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2"
>
xkcd 936
</a>
.
</p>
)}
</div>
</div>
);
}Notes:
- For each position in the password the estimator tries three rules in order:
(1) longest back-reference into the prefix — if the next 2+ characters
already appeared earlier, charge
log₂(offset) + log₂(length)bits and skip ahead; (2) consecutive or adjacent code point — if the previous character is equal (delta 0) or next to (delta 1) the current one, charge 1–2 bits; (3) otherwise charge the full per-character cost. - Pool size feeds the per-character cost: 26 lowercase, 26 uppercase, 10 digits, 32 ASCII symbols, plus small bonuses for whitespace and non-ASCII.
- Quick sanity check:
aaaaaaaa≈ 8 bits,abcdefgh≈ 19 bits,password≈ 34 bits,correct horse battery staple≈ 130 bits. - Entropy → label thresholds (
28 / 40 / 60 / 80bits) roughly match the NIST SP 800-63B reference points for weak / fair / good / strong. - The bar is wrapped in
role="progressbar"witharia-valuemin/max/now/labelso screen readers hear the strength estimate. - The whole block is linked to the input via
aria-describedby, so focusing the field reads the current strength aloud. - Displaying the raw bit count (
~72 bits of entropy) is optional — useful for technical audiences, noise for consumer flows. - Do not block submission silently — disabled submit buttons with no explanation are worse than letting the server reject the value. If you must gate, surface the threshold the user needs to clear.