# 비밀번호 강도

입력한 비밀번호의 강도와 충족한 규칙을 보여 주는 표시기예요.

- Type: component
- Theme: base
- Tags: arc, component, password, strength, meter, validation, form
- English title: Password strength
- URL: https://dsui.vercel.app/items/password-strength

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| label | text | 라벨 | 새 비밀번호 |
| error | text | 오류 문구 |  |
| revealed | boolean | 비밀번호 보기 | false |
| disabled | boolean | 비활성 | false |

## Source

### password-strength.module.css

```css
/* 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.tsx

```tsx
"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;

```
