컴포넌트
비밀번호 강도
입력한 비밀번호의 강도와 충족한 규칙을 보여 주는 표시기예요.
- #arc
- #component
- #password
- #strength
- #meter
- #validation
- #form
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/password-strength.json파일
components/arc/password-strength/password-strength.module.csscomponents/arc/password-strength/password-strength.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
password-strength.module.csspassword-strength.module.csspassword-strength.module.csscss
/* No row gap: a closing error row would still pay it. Each row carries its own space instead. */
.field { --tone: var(--border-strong); display: grid; min-width: 0; }
.field[data-level="1"] { --tone: var(--danger); }
.field[data-level="2"] { --tone: var(--warning); }
.field[data-level="3"], .field[data-level="4"] { --tone: var(--success); }
.label { margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: var(--label-weight); line-height: var(--leading-body); }
/* The shell only transitions color; its sideways shake is a spring on transform, so the two never fight. Focus fills it with the accent and error with danger, exactly like Input, and it never changes size. */
.shell { display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-1); padding: 0 5px 0 var(--space-4); border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .shell:hover:not(:has(.input:focus-visible, .input[aria-invalid="true"])) { border-color: var(--foreground); } }
.shell:has(.input:focus-visible) { border-color: var(--accent); background: var(--accent); }
.shell:has(.input[aria-invalid="true"]) { border-color: var(--danger); background: var(--danger); }
.shell:has(.input:focus-visible) .input, .shell:has(.input[aria-invalid="true"]) .input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.shell:has(.input:focus-visible) .input::placeholder, .shell:has(.input[aria-invalid="true"]) .input::placeholder { color: var(--accent-foreground-muted); }
.shell:has(.input:focus-visible) .reveal, .shell:has(.input[aria-invalid="true"]) .reveal { color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) { .shell:has(.input:focus-visible) .reveal:hover, .shell:has(.input[aria-invalid="true"]) .reveal:hover { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); } }
.input { width: 100%; min-width: 0; height: calc(var(--control-height-md) - 2px); padding: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); outline: 0; }
.input::placeholder { color: var(--text-muted); }
/* Touch keyboards zoom into fields under 16px, so coarse pointers get the larger size. */
@media (pointer: coarse) { .input { font-size: var(--text-base); } }
/* Dots and characters resolve into each other instead of snapping. Swapping the keyframe name restarts it. */
.input[data-reveal="shown"] { animation: resolve-shown var(--duration-standard) var(--ease-enter); }
.input[data-reveal="hidden"] { animation: resolve-hidden var(--duration-standard) var(--ease-enter); }
@keyframes resolve-shown { from { opacity: .35; filter: blur(2px); } }
@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }
.reveal { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: calc(var(--radius-control) - 5px); background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
@media (hover: hover) and (pointer: fine) { .reveal:hover { background: var(--surface-muted); color: var(--foreground); } }
.reveal:active { transform: scale(.94); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.reveal[aria-pressed="true"] { color: var(--foreground); }
/* The row's space is padding inside the clipped slot, so its height opens from a true zero. */
.errorSlot { display: block; overflow: hidden; }
.error { position: relative; display: block; padding-top: var(--space-2); color: var(--danger); font-size: var(--text-xs); line-height: var(--leading-body); }
.errorText { display: block; }
.meterRow { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-3); }
.meter { display: grid; flex: 1 1 auto; min-width: 0; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; }
/* Each fill slides in from the left inside its clipped track, so the rounded ends keep their shape at every frame. */
.segment { position: relative; height: 4px; overflow: hidden; isolation: isolate; border-radius: var(--radius-pill); background: var(--border); }
.fill { position: absolute; inset: 0; border-radius: inherit; background: var(--tone); transition: background-color var(--duration-standard) var(--ease-standard); }
/* A fixed slot: the meter never resizes when the word changes, and the word stays flush with the field's edge. */
.word { position: relative; display: block; flex: 0 0 4.5rem; height: 1.4em; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-align: right; white-space: nowrap; }
.wordText { display: inline-block; }
.rules { display: grid; gap: 6px; margin: var(--space-4) 0 0; padding: 0; list-style: none; }
.rule { display: flex; min-height: 1.25rem; align-items: center; gap: 10px; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); transition: color var(--duration-standard) var(--ease-standard); }
.rule[data-met] { color: var(--foreground); }
.ruleLabel { min-width: 0; }
/* The outline hands over to a faint tint while the tick draws on top; most of the color sits on the tick. */
.mark { position: relative; display: block; width: 16px; height: 16px; flex: 0 0 16px; border: 1.25px solid var(--border-strong); border-radius: var(--radius-pill); color: var(--success); transition: border-color var(--duration-fast) var(--ease-standard); }
.rule[data-met] .mark { border-color: transparent; }
.markFill { position: absolute; inset: -1.25px; border-radius: inherit; background: color-mix(in oklab, var(--success) 16%, transparent); }
.tick { position: absolute; inset: -1.25px; width: 16px; height: 16px; }
.remaining { position: relative; display: block; margin-left: auto; padding-left: var(--space-2); color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }
.remainingText { display: inline-flex; align-items: baseline; gap: .28em; }
.number { display: inline-flex; font-variant-numeric: tabular-nums; }
/* Places clip sideways only, so a narrowing column never shows half a digit and a turning digit is never cut. */
.place { position: relative; display: inline-block; overflow-x: clip; }
.digit { display: inline-block; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.shell, .reveal, .fill, .rule, .mark { transition: none; }
.reveal:active { transform: none; }
.input[data-reveal] { animation: none; }
}
password-strength.tsxpassword-strength.tsxpassword-strength.tsxtsx
"use client";
import { forwardRef, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { InputHTMLAttributes } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, Transition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./password-strength.module.css";
import { playUiSound } from "../sound/play";
export interface PasswordRule {
id: string;
label: string;
test: (password: string) => boolean;
/** Characters still missing. Shown as a rolling count beside the rule while it is unmet. */
remaining?: (password: string) => number;
}
export interface PasswordStrengthResult {
level: 0 | 1 | 2 | 3 | 4;
label: string;
met: string[];
/** True while the password is shorter than the short length, so the meter holds at its first step. */
short?: boolean;
}
/** The strength words, one per meter step. Override them to translate the meter. */
export interface PasswordStrengthLevels {
tooShort: string;
weak: string;
fair: string;
good: string;
strong: string;
}
/** Every user-facing string in PasswordStrength. 모두 한국어 기본값이 있어요. 바꿀 것만 넘겨요. */
export interface PasswordStrengthMessages {
levels: PasswordStrengthLevels;
/** Name of the show password toggle. It is a pressed toggle (aria-pressed), so the name stays the same in both states. */
showPassword: string;
/** Accessible name of the strength meter. */
meterLabel: string;
/** Meter value text while the field is empty. */
noPassword: string;
/** Accessible name of the requirements list. */
rulesLabel: string;
/** Read after a rule's label for screen readers, including any leading punctuation. */
ruleMet: string;
ruleNotMet: string;
/** Words around the rolling count beside an unmet rule, 예: "4개 더". */
remainingPrefix: string;
remainingSuffix: string;
/** The polite live-region summary read after each change. */
summary: (result: { label: string; met: number; total: number }) => string;
}
export const defaultPasswordStrengthMessages: PasswordStrengthMessages = {
levels: { tooShort: "너무 짧아요", weak: "약해요", fair: "보통이에요", good: "좋아요", strong: "강해요" },
showPassword: "비밀번호 보기",
meterLabel: "비밀번호 강도",
noPassword: "아직 입력하지 않았어요",
rulesLabel: "비밀번호 조건",
ruleMet: ", 채웠어요",
ruleNotMet: ", 아직이에요",
remainingPrefix: "",
remainingSuffix: "개 더",
summary: ({ label, met, total }) => `강도: ${label}. 조건 ${total}개 중 ${met}개를 채웠어요.`,
};
/** Partial messages, with partial strength words, for the `messages` prop. */
export type PasswordStrengthMessagesInput = Partial<Omit<PasswordStrengthMessages, "levels">> & { levels?: Partial<PasswordStrengthLevels> };
/**
* A new password field that shows how strong the password is while it is typed: four segments fill and change tone,
* each rule checks off with a drawn tick, and the strength word morphs in place. Use it when creating or changing a password;
* use a plain password field for sign in. Scoring happens on the device, nothing is sent anywhere.
*/
export interface PasswordStrengthProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "children"> {
label: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string, strength: PasswordStrengthResult) => void;
/** Rules to check. Strength is the share of rules met, spread over four steps. */
rules?: PasswordRule[];
/** Error copy tied to the field. The field shakes once each time a new error appears. */
error?: string;
revealed?: boolean;
onRevealedChange?: (revealed: boolean) => void;
/** Translated or reworded copy. 모든 문구에 한국어 기본값이 있어요. PasswordStrengthMessages를 참고해요. */
messages?: PasswordStrengthMessagesInput;
/** Below this many characters the meter holds at its first step and reads "너무 짧아요". Defaults to 8. */
shortLength?: number;
/** How characters are counted for shortLength. Defaults to code points, so an emoji counts once; pass `password => password.length` to count UTF-16 units like a server might. */
countCharacters?: (password: string) => number;
}
/** Counts code points, so an emoji or other astral character counts as one. */
export const countCodePoints = (password: string) => Array.from(password).length;
/** Labels for the built-in rules. `length` receives the minimum length. */
export interface PasswordRuleLabels {
length: (minLength: number) => string;
case: string;
number: string;
symbol: string;
}
export interface PasswordRuleOptions {
/** Characters the length rule asks for. Defaults to 12. */
minLength?: number;
/** Translated or reworded rule labels. */
labels?: Partial<PasswordRuleLabels>;
/** How the length rule counts characters. Defaults to code points. */
countCharacters?: (password: string) => number;
}
const defaultRuleLabels: PasswordRuleLabels = {
length: minLength => `${minLength}자 이상`,
case: "대문자와 소문자",
number: "숫자 1개 이상",
symbol: "기호 1개 이상",
};
/** The built-in rules (length, mixed case, number, symbol) with your own minimum length, labels and character count. */
export function createPasswordRules({ minLength = 12, labels, countCharacters = countCodePoints }: PasswordRuleOptions = {}): PasswordRule[] {
const copy = { ...defaultRuleLabels, ...labels };
return [
{ id: "length", label: copy.length(minLength), test: password => countCharacters(password) >= minLength, remaining: password => Math.max(0, minLength - countCharacters(password)) },
{ id: "case", label: copy.case, test: password => /\p{Ll}/u.test(password) && /\p{Lu}/u.test(password) },
{ id: "number", label: copy.number, test: password => /\p{N}/u.test(password) },
{ id: "symbol", label: copy.symbol, test: password => /[^\p{L}\p{N}\s]/u.test(password) },
];
}
export const defaultPasswordRules: PasswordRule[] = createPasswordRules();
export interface EstimateStrengthOptions {
/** Below this many characters a password stays at the first step, however varied it is. Defaults to 8. */
shortLength?: number;
/** Strength words for the result's label. */
levels?: Partial<PasswordStrengthLevels>;
/** How characters are counted for shortLength. Defaults to code points. */
countCharacters?: (password: string) => number;
}
/** Scores a password on the device: one step per share of rules met, capped at the first step while it is shorter than the short length (8 by default). */
export function estimateStrength(password: string, rules: PasswordRule[] = defaultPasswordRules, { shortLength = 8, levels, countCharacters = countCodePoints }: EstimateStrengthOptions = {}): PasswordStrengthResult {
if (!password) return { level: 0, label: "", met: [] };
const words = { ...defaultPasswordStrengthMessages.levels, ...levels };
const met = rules.filter(rule => rule.test(password)).map(rule => rule.id);
if (countCharacters(password) < shortLength) return { level: 1, label: words.tooShort, met, short: true };
const level = Math.min(4, Math.max(1, Math.round((met.length / Math.max(rules.length, 1)) * 4))) as 1 | 2 | 3 | 4;
return { level, label: [words.weak, words.fair, words.good, words.strong][level - 1] ?? words.weak, met };
}
const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const subscribe = () => () => {};
/** False on the server and during hydration, so reduced motion never changes the first client render. */
const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);
/** The word rises when strength improves and drops when it falls, so the direction reads without looking at the meter. */
const rise: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }),
center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: enter } },
exit: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }),
};
const fade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
/** Digits turn like a counter: a shrinking count drops in from above, a growing one rises from below. */
const roll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${.45 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),
center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { y: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } } },
exit: (direction: number) => ({ opacity: 0, y: `${-.45 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }),
};
function RollingNumber({ value, reduced }: { value: number; reduced: boolean }) {
const [track, setTrack] = useState({ value, direction: 1 });
if (track.value !== value) setTrack({ value, direction: value > track.value ? 1 : -1 });
const digits = String(value).split("");
// Columns are keyed by place value, so 10 → 9 lets the tens column narrow away while the ones column turns.
return <span className={styles.number}>
<AnimatePresence initial={false} custom={track.direction}>
{digits.map((digit, index) => <motion.span key={digits.length - 1 - index} className={styles.place} initial={{ width: 0, opacity: 0 }} animate={{ width: "auto", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : { width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } }}>
<AnimatePresence mode="popLayout" initial={false} custom={track.direction}>
<motion.span key={digit} className={styles.digit} custom={track.direction} variants={reduced ? fade : roll} initial="enter" animate="center" exit="exit">{digit}</motion.span>
</AnimatePresence>
</motion.span>)}
</AnimatePresence>
</span>;
}
/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */
function EyeMorph({ slashed, reduced }: { slashed: boolean; reduced: boolean }) {
const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };
const transition: Transition = reduced ? { duration: 0 } : { pathLength: { duration: motionTokens.duration.standard, ease: standard }, opacity: { duration: motionTokens.duration.instant } };
return <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><rect width="24" height="24" fill="white" stroke="none" /><motion.path d="M3 3l18 18" stroke="black" strokeWidth={5} initial={false} animate={slash} transition={transition} /></mask>
<g mask={`url(#${maskId})`}><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g>
<motion.path d="M3 3l18 18" initial={false} animate={slash} transition={transition} />
</svg>;
}
/** The tint grows under the tick while the tick draws from its short stroke; unchecking retracts it faster than it drew. */
function RuleMark({ met, delay, reduced }: { met: boolean; delay: number; reduced: boolean }) {
return <span className={styles.mark} aria-hidden="true">
<motion.span className={styles.markFill} initial={false} animate={{ scale: met ? 1 : .5, opacity: met ? 1 : 0 }} transition={reduced ? { duration: 0 } : { scale: { ...motionTokens.spring.snappy, delay }, opacity: { duration: met ? motionTokens.duration.fast : motionTokens.duration.instant, delay } }} />
<svg className={styles.tick} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M4.75 8.25 7 10.5l4.25-4.75" initial={false} animate={{ pathLength: met ? 1 : 0, opacity: met ? 1 : 0 }} transition={reduced ? { duration: 0 } : met ? { pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: delay + .06 }, opacity: { duration: motionTokens.duration.micro, delay: delay + .06 } } : { pathLength: { duration: motionTokens.duration.fast, ease: standard }, opacity: { duration: motionTokens.duration.fast, delay: motionTokens.delay.micro } }} />
</svg>
</span>;
}
/** Error copy opens its row on a spring, then the words settle in. The row follows the measured copy, so a wrap never snaps. */
function ErrorRow({ id, text, reduced }: { id: string; text: string; reduced: boolean }) {
const copy = useRef<HTMLSpanElement>(null);
const [height, setHeight] = useState<number | "auto">("auto");
useEffect(() => {
const node = copy.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => { if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });
observer.observe(node);
return () => observer.disconnect();
}, []);
return <motion.span className={styles.errorSlot} initial={{ height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>
<span ref={copy} id={id} className={styles.error}>
<AnimatePresence mode="popLayout" initial={false} custom={1}>
<motion.span key={text} className={styles.errorText} custom={1} variants={reduced ? fade : rise} initial="enter" animate="center" exit="exit">{text}</motion.span>
</AnimatePresence>
</span>
</motion.span>;
}
export const PasswordStrength = forwardRef<HTMLInputElement, PasswordStrengthProps>(function PasswordStrength({ label, value: valueProp, defaultValue = "", onValueChange, onChange, rules = defaultPasswordRules, error, revealed: revealedProp, onRevealedChange, messages: messagesProp, shortLength, countCharacters, id, className, ...props }, ref) {
const messages: PasswordStrengthMessages = { ...defaultPasswordStrengthMessages, ...messagesProp, levels: { ...defaultPasswordStrengthMessages.levels, ...messagesProp?.levels } };
const options: EstimateStrengthOptions = { shortLength, levels: messages.levels, countCharacters };
const generated = useId();
const controlId = id ?? generated;
const rulesId = `${controlId}-rules`, errorId = `${controlId}-error`;
const hydrated = useHydrated();
const prefersReduced = useReducedMotion();
const reduced = hydrated && !!prefersReduced;
const [internal, setInternal] = useState(defaultValue);
const value = valueProp ?? internal;
const [revealedInternal, setRevealedInternal] = useState(false);
const revealed = revealedProp ?? revealedInternal;
const strength = estimateStrength(value, rules, options);
const met = new Set(strength.met);
// The previous level and rule states tell which way the word moves and which segments and ticks join a staggered wave.
const rank = strength.short ? .5 : strength.level;
const metKey = strength.met.join(" ");
const [track, setTrack] = useState({ rank, level: strength.level, previousLevel: strength.level, direction: 1, metKey, previousMet: metKey });
if (track.rank !== rank || track.metKey !== metKey) setTrack({ rank, level: strength.level, previousLevel: track.rank !== rank ? track.level : track.previousLevel, direction: track.rank === rank ? track.direction : rank > track.rank ? 1 : -1, metKey, previousMet: track.metKey !== metKey ? track.metKey : track.previousMet });
const previousMet = new Set(track.previousMet.split(" ").filter(Boolean));
const changed = rules.filter(rule => met.has(rule.id) !== previousMet.has(rule.id)).map(rule => rule.id);
// Only a change after mount resolves the text, so the field never blurs in on first paint.
const [reveal, setReveal] = useState({ revealed, changed: false });
if (reveal.revealed !== revealed) setReveal({ revealed, changed: true });
const shake = useMotionValue(0);
const shaking = useRef<AnimationPlaybackControls | null>(null);
const lastError = useRef(error);
useEffect(() => {
const previous = lastError.current;
lastError.current = error;
if (!error || error === previous || reduced) return;
// A spring released with sideways velocity rings out on its own, the way a refused field shakes on a phone.
shaking.current?.stop();
shaking.current = animate(shake, 0, { type: "spring", velocity: -240, stiffness: 900, damping: 15, restDelta: .1 });
}, [error, reduced, shake]);
function toggleReveal() {
playUiSound(revealed ? "toggle-off" : "toggle-on");
if (revealedProp === undefined) setRevealedInternal(!revealed);
onRevealedChange?.(!revealed);
}
const summary = strength.level ? messages.summary({ label: strength.label, met: strength.met.length, total: rules.length }) : "";
const describedBy = [props["aria-describedby"], error ? errorId : undefined, rulesId].filter(Boolean).join(" ");
return <div className={styles.field} data-level={strength.level}>
<label className={styles.label} htmlFor={controlId}>{label}</label>
<motion.div className={styles.shell} style={{ x: shake }}>
<input autoComplete="new-password" autoCapitalize="off" autoCorrect="off" spellCheck={false} {...props} ref={ref} id={controlId} type={revealed ? "text" : "password"} value={value}
onChange={event => { const next = event.target.value; if (valueProp === undefined) setInternal(next); onChange?.(event); onValueChange?.(next, estimateStrength(next, rules, options)); }}
aria-invalid={error ? true : props["aria-invalid"]} aria-describedby={describedBy} data-reveal={reveal.changed ? (revealed ? "shown" : "hidden") : undefined} className={[styles.input, className].filter(Boolean).join(" ")} />
<button type="button" className={styles.reveal} onClick={toggleReveal} aria-label={messages.showPassword} aria-pressed={revealed} aria-controls={controlId}><EyeMorph slashed={revealed} reduced={reduced} /></button>
</motion.div>
<AnimatePresence initial={false}>{error ? <ErrorRow key="error" id={errorId} text={error} reduced={reduced} /> : null}</AnimatePresence>
<div className={styles.meterRow}>
<div className={styles.meter} role="meter" aria-label={messages.meterLabel} aria-valuemin={0} aria-valuemax={4} aria-valuenow={strength.level} aria-valuetext={strength.level ? strength.label : messages.noPassword}>
{[0, 1, 2, 3].map(index => {
const on = index < strength.level;
// A pasted password fills left to right; clearing empties right to left.
const wave = on ? index - track.previousLevel : track.previousLevel - 1 - index;
return <span key={index} className={styles.segment}><motion.span className={styles.fill} initial={false} animate={{ x: on ? "0%" : "-101%" }} transition={reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, delay: Math.max(0, wave) * (on ? .05 : .03) }} /></span>;
})}
</div>
<span className={styles.word} aria-hidden="true">
<AnimatePresence mode="popLayout" initial={false} custom={track.direction}>
{strength.level ? <motion.span key={strength.label} className={styles.wordText} custom={track.direction} variants={reduced ? fade : rise} initial="enter" animate="center" exit="exit">{strength.label}</motion.span> : null}
</AnimatePresence>
</span>
</div>
<ul id={rulesId} className={styles.rules} aria-label={messages.rulesLabel}>
{rules.map(rule => {
const ok = met.has(rule.id);
const remaining = value && !ok ? rule.remaining?.(value) ?? 0 : 0;
const order = changed.indexOf(rule.id);
return <li key={rule.id} className={styles.rule} data-met={ok || undefined}>
<RuleMark met={ok} delay={order > 0 ? order * .05 : 0} reduced={reduced} />
<span className={styles.ruleLabel}>{rule.label}<span className={styles.srOnly}>{ok ? messages.ruleMet : messages.ruleNotMet}</span></span>
<span className={styles.remaining} aria-hidden="true">
<AnimatePresence mode="popLayout" initial={false} custom={1}>
{remaining > 0 ? <motion.span key="remaining" className={styles.remainingText} custom={1} variants={reduced ? fade : rise} initial="enter" animate="center" exit="exit">{messages.remainingPrefix ? `${messages.remainingPrefix} ` : null}<RollingNumber value={remaining} reduced={reduced} />{messages.remainingSuffix}</motion.span> : null}
</AnimatePresence>
</span>
</li>;
})}
</ul>
<span className={styles.srOnly} role="status">{summary}</span>
</div>;
});
PasswordStrength.displayName = "PasswordStrength";
export default PasswordStrength;