Password field
A password input with a morphing show/hide eye, an entropy-style strength meter, live rule checklist, Caps Lock warning, strong password generator with copy, and a confirm-match mode.
Preview
Change password
Very strong~109 bits
- 12+ characters (met)
- Upper and lower case (met)
- A number (not met)
- A symbol (met)
Type the same password again
Why “Summer2026” is weak
Very weak~23 bits
Contains a very common password.
The meter estimates entropy from the character mix and length, then subtracts for common words, repeats, runs like “abc”, and word-plus-year patterns.
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/password-field.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | string | "" | Controlled or initial password. |
| onValueChange | (value: string) => void | — | Called on typing or Generate. |
| showStrength | boolean | true | Strength meter with level word and estimated bits. |
| rules | { id, label, test }[] | defaultRules | Checklist ticked live; [] hides it. |
| generate | boolean | false | Show Generate, which fills a strong random password and offers Copy. |
| generateLength | number | 18 | Length of generated passwords. |
| match | string | — | Confirm mode: the password this field must equal. |
| invalid | boolean | false | Force the invalid state. |
| rootClassName | string | — | Classes for the wrapper. |
Build notes
- `estimateStrength`: pool size from classes present (26/26/10/33, +100 for
non-ASCII), bits = length × log2(pool), then penalties: common word
(length × 3.5), each extra repeated char (3), each step of an alphabet or
digit run of 3+ (3), word-plus-year shape (16). Score bands at 28/40/60/80
bits.
- `generatePassword` picks one from each class, fills from the union
(ambiguous characters like l/1/O/0 removed), then Fisher–Yates shuffles,
all with `crypto.getRandomValues`. It runs in handlers only.
- Caps Lock comes from `KeyboardEvent.getModifierState("CapsLock")` on key
down and up.
- The eye morph animates the slash's `pathLength` 0 → 1 and the pupil's `r`
2 → 1.25.
<!-- PEBBLE-LICENSE -->View source
registry/input/password-field/password-field.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: password-field.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Strength heuristic (ours; no dictionary library) */
/* -------------------------------------------------------------------------- */
const COMMON = [
"password",
"passw0rd",
"qwerty",
"letmein",
"welcome",
"admin",
"iloveyou",
"monkey",
"dragon",
"sunshine",
"123456",
"abc123",
"football",
"login",
"summer",
"winter",
"spring",
"autumn",
];
export type Strength = {
/** Rough entropy estimate after penalties. */
bits: number;
/** 0 very weak … 4 very strong. */
score: 0 | 1 | 2 | 3 | 4;
label: "Very weak" | "Weak" | "Fair" | "Strong" | "Very strong";
/** Short hints about what lowered the score. */
warnings: string[];
};
const LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"] as const;
/**
* Character-pool entropy (length × log2 pool) minus penalties for common
* words, repeats and keyboard/alphabet runs. Not a cracker model, but it
* ranks "Summer2026!" below "tidal-orbit-mango-42" for the right reasons.
*/
export function estimateStrength(password: string): Strength {
if (!password) return { bits: 0, score: 0, label: LABELS[0], warnings: [] };
let pool = 0;
if (/[a-z]/.test(password)) pool += 26;
if (/[A-Z]/.test(password)) pool += 26;
if (/\d/.test(password)) pool += 10;
if (/[^\w\s]|_/.test(password)) pool += 33;
if (/\s/.test(password)) pool += 1;
if ([...password].some((c) => c.charCodeAt(0) > 127)) pool += 100;
let bits = password.length * Math.log2(Math.max(pool, 2));
const warnings: string[] = [];
const lower = password.toLowerCase();
const common = COMMON.find((w) => lower.includes(w));
if (common) {
bits -= common.length * 3.5;
warnings.push("Contains a very common password");
}
const repeats = lower.match(/(.)\1{2,}/g);
if (repeats) {
bits -= repeats.reduce((n, r) => n + (r.length - 1) * 3, 0);
warnings.push("Avoid repeated characters");
}
let run = 1;
let runPenalty = 0;
for (let i = 1; i < lower.length; i++) {
const d = lower.charCodeAt(i) - lower.charCodeAt(i - 1);
run = d === 1 || d === -1 ? run + 1 : 1;
if (run >= 3) runPenalty += 3;
}
if (runPenalty) {
bits -= runPenalty;
warnings.push("Avoid sequences like abc or 123");
}
if (/^[a-z]+\d{1,4}[^\w]?$/i.test(password)) {
bits -= 16;
warnings.push("A word plus a year is easy to guess");
}
bits = Math.max(0, Math.round(bits));
const score = (
bits < 28 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4
) as Strength["score"];
return { bits, score, label: LABELS[score], warnings };
}
export type PasswordRule = { id: string; label: string; test: (value: string) => boolean };
export const defaultRules: PasswordRule[] = [
{ id: "length", label: "12+ characters", test: (v) => v.length >= 12 },
{ id: "case", label: "Upper and lower case", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
{ id: "number", label: "A number", test: (v) => /\d/.test(v) },
{ id: "symbol", label: "A symbol", test: (v) => /[^A-Za-z0-9]/.test(v) },
];
/** A strong random password from crypto.getRandomValues (call in handlers only). */
export function generatePassword(length = 18) {
const sets = [
"abcdefghijkmnpqrstuvwxyz",
"ABCDEFGHJKLMNPQRSTUVWXYZ",
"23456789",
"!#$%&*+-=?@^_~",
];
const all = sets.join("");
const rand = (n: number) => {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % n;
};
// One from each set, the rest from all, then shuffle.
const chars = sets.map((s) => s[rand(s.length)]);
while (chars.length < length) chars.push(all[rand(all.length)]);
for (let i = chars.length - 1; i > 0; i--) {
const j = rand(i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join("");
}
/* -------------------------------------------------------------------------- */
/* Icons (ours) */
/* -------------------------------------------------------------------------- */
/** Eye that morphs to "hidden": the slash draws across and the pupil shrinks. */
function EyeIcon({ hidden }: { hidden: boolean }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-4"
>
<path d="M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8Z" />
<motion.circle
cx="8"
cy="8"
initial={false}
animate={{ r: hidden ? 1.25 : 2 }}
transition={{ duration: 0.2 }}
/>
<motion.path
d="M2.5 2.5 13.5 13.5"
initial={false}
animate={{ pathLength: hidden ? 1 : 0, opacity: hidden ? 1 : 0 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
/>
</svg>
);
}
const icon = "size-3.5 shrink-0";
const Check = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={icon}
>
<path d="M3.5 8.5 6.5 11.5l6-7" />
</svg>
);
const Dot = () => (
<svg viewBox="0 0 16 16" aria-hidden className={icon}>
<circle cx="8" cy="8" r="2" fill="currentColor" />
</svg>
);
/* -------------------------------------------------------------------------- */
/* PasswordField */
/* -------------------------------------------------------------------------- */
export type PasswordFieldProps = Omit<
React.ComponentProps<"input">,
"type" | "value" | "defaultValue" | "onChange"
> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Show the strength meter. */
showStrength?: boolean;
/** Rules ticked live under the field; pass [] to hide. */
rules?: PasswordRule[];
/** Show a "Generate" button that fills a strong password. */
generate?: boolean;
generateLength?: number;
/** Confirm mode: the password this field must match. */
match?: string;
invalid?: boolean;
/** Classes for the outer wrapper. */
rootClassName?: string;
};
export function PasswordField({
value: valueProp,
defaultValue = "",
onValueChange,
showStrength = true,
rules = defaultRules,
generate = false,
generateLength = 18,
match,
invalid = false,
rootClassName,
className,
id,
autoComplete = "new-password",
onKeyDown,
onKeyUp,
onFocus,
onBlur,
...props
}: PasswordFieldProps) {
const uid = React.useId();
const inputId = id ?? `${uid}-input`;
const [internal, setInternal] = React.useState(defaultValue);
const value = valueProp ?? internal;
const [visible, setVisible] = React.useState(false);
const [caps, setCaps] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [touched, setTouched] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [generated, setGenerated] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const confirm = match !== undefined;
const strength = estimateStrength(value);
const set = (v: string) => {
setInternal(v);
onValueChange?.(v);
};
// Confirm state: quiet while typing a prefix of the target, loud once it diverges or on blur.
const matches = confirm && value.length > 0 && value === match;
const mismatch =
confirm && value.length > 0 && !matches && (touched || !match!.startsWith(value));
const isInvalid = invalid || mismatch;
const describedBy = [
showStrength && !confirm && `${uid}-strength`,
rules.length > 0 && !confirm && `${uid}-rules`,
caps && focused && `${uid}-caps`,
confirm && `${uid}-match`,
props["aria-describedby"],
]
.filter(Boolean)
.join(" ");
const readCaps = (e: React.KeyboardEvent) => setCaps(e.getModifierState?.("CapsLock") ?? false);
return (
<MotionConfig reducedMotion="user">
<div data-slot="password-field" className={cn("flex w-full flex-col gap-2", rootClassName)}>
<div className="group/pw relative">
<input
ref={inputRef}
id={inputId}
type={visible ? "text" : "password"}
autoComplete={confirm ? "new-password" : autoComplete}
spellCheck={false}
autoCapitalize="off"
value={value}
aria-invalid={isInvalid || undefined}
aria-describedby={describedBy || undefined}
onChange={(e) => {
set(e.target.value);
setGenerated(false);
}}
onKeyDown={(e) => {
readCaps(e);
onKeyDown?.(e);
}}
onKeyUp={(e) => {
readCaps(e);
onKeyUp?.(e);
}}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
setTouched(true);
onBlur?.(e);
}}
className={cn(
"h-9 w-full rounded-md border border-border-control bg-card ps-3 text-base text-foreground inset-shadow-xs md:text-sm",
visible
? "font-mono tracking-normal"
: "tracking-[0.12em] placeholder:tracking-normal",
generate ? "pe-[7.25rem]" : "pe-10",
"transition-[border-color,box-shadow] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"placeholder:text-muted-foreground hover:border-muted-foreground",
"outline-none focus-visible:border-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"aria-invalid:border-danger aria-invalid:ring-[3px] aria-invalid:ring-danger/20 aria-invalid:focus-visible:ring-2 aria-invalid:focus-visible:ring-danger/40",
matches && "border-success",
"disabled:pointer-events-none disabled:bg-muted disabled:opacity-50",
className,
)}
{...props}
/>
<div className="absolute inset-y-0 end-1 flex items-center gap-0.5">
{confirm && (
<AnimatePresence initial={false}>
{matches && (
<motion.span
key="ok"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
className="me-1 grid size-4 place-items-center rounded-full bg-success text-card"
>
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-2.5"
>
<path d="M3 6.25 5 8.25l4-4.5" />
</svg>
</motion.span>
)}
</AnimatePresence>
)}
{generate && (
<button
type="button"
data-slot="password-generate"
onClick={() => {
set(generatePassword(generateLength));
setVisible(true);
setGenerated(true);
setCopied(false);
}}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:scale-[0.97]"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-3.5"
>
<path d="M13 8a5 5 0 1 1-1.5-3.55M13 2.75v2.5h-2.5" />
</svg>
Generate
</button>
)}
<button
type="button"
aria-label={visible ? "Hide password" : "Show password"}
aria-pressed={visible}
aria-controls={inputId}
data-slot="password-toggle"
onClick={() => setVisible((v) => !v)}
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<EyeIcon hidden={!visible} />
</button>
</div>
</div>
<AnimatePresence initial={false}>
{caps && focused && (
<motion.p
key="caps"
id={`${uid}-caps`}
role="status"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="-mt-0.5 flex items-center gap-1.5 overflow-hidden text-xs text-warning-strong"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-3.5"
>
<path d="M8 2.5 13 8h-2.75v3.5h-4.5V8H3L8 2.5ZM5.75 13.5h4.5" />
</svg>
Caps Lock is on
</motion.p>
)}
</AnimatePresence>
{generated && (
<div className="-mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>Strong password generated.</span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
} catch {
setCopied(false);
}
}}
className="cursor-pointer rounded px-1 font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{copied ? "Copied" : "Copy"}
</button>
<span aria-live="polite" className="sr-only">
{copied ? "Password copied" : ""}
</span>
</div>
)}
{confirm ? (
<p
id={`${uid}-match`}
aria-live="polite"
className={cn(
"flex min-h-4 items-center gap-1.5 text-xs",
matches
? "text-success-strong"
: mismatch
? "text-danger-strong"
: "text-muted-foreground",
)}
>
{matches
? "Passwords match"
: mismatch
? "Passwords don’t match"
: "Type the same password again"}
</p>
) : (
<>
{showStrength && (
<div
id={`${uid}-strength`}
data-slot="password-strength"
className="flex items-center gap-3"
>
<span
aria-hidden
className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted inset-shadow-xs"
>
<motion.span
className={cn(
"absolute inset-y-0 start-0 rounded-full",
["bg-danger", "bg-danger", "bg-warning", "bg-success", "bg-success"][
strength.score
],
)}
initial={false}
animate={{
width: value
? `${Math.max(6, Math.min(100, (strength.bits / 90) * 100))}%`
: "0%",
}}
transition={{ type: "spring", stiffness: 420, damping: 32, mass: 0.8 }}
/>
</span>
<span className="flex shrink-0 items-baseline justify-end gap-1.5 text-xs whitespace-nowrap">
<span
className={cn(
"font-medium",
!value
? "text-muted-foreground"
: [
"text-danger-strong",
"text-danger-strong",
"text-warning-strong",
"text-success-strong",
"text-success-strong",
][strength.score],
)}
>
{value ? strength.label : "No password"}
</span>
{value && (
<span className="font-mono text-[10px] text-muted-foreground tabular-nums">
~{strength.bits} bits
</span>
)}
</span>
</div>
)}
{value && strength.warnings[0] && (
<p className="-mt-1 text-xs text-muted-foreground">{strength.warnings[0]}.</p>
)}
{rules.length > 0 && (
<ul
id={`${uid}-rules`}
aria-label="Password requirements"
className="grid grid-cols-2 gap-x-3 gap-y-1"
>
{rules.map((r) => {
const ok = r.test(value);
return (
<li
key={r.id}
data-met={ok || undefined}
className={cn(
"flex items-center gap-1.5 text-xs transition-colors duration-(--motion-fast)",
ok ? "text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"grid size-4 place-items-center rounded-full transition-[background-color,color] duration-(--motion-base)",
ok ? "bg-success/15 text-success-strong" : "text-muted-foreground/50",
)}
>
{ok ? <Check /> : <Dot />}
</span>
{r.label}
<span className="sr-only">{ok ? " (met)" : " (not met)"}</span>
</li>
);
})}
</ul>
)}
</>
)}
</div>
</MotionConfig>
);
}
