{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-strength",
  "type": "registry:ui",
  "title": "Password strength",
  "description": "Show how strong a new password is while it is typed.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/password-strength/password-strength.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { AnimationPlaybackControls, Transition, Variants } from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport interface PasswordRule {\n  id: string;\n  label: string;\n  test: (password: string) => boolean;\n  /** Characters still missing. Shown as a rolling count beside the rule while it is unmet. */\n  remaining?: (password: string) => number;\n}\n\nexport interface PasswordStrengthResult {\n  level: 0 | 1 | 2 | 3 | 4;\n  label: string;\n  met: string[];\n  /** True while the password is shorter than the short length, so the meter holds at its first step. */\n  short?: boolean;\n}\n\n/** The strength words, one per meter step. Override them to translate the meter. */\nexport interface PasswordStrengthLevels {\n  tooShort: string;\n  weak: string;\n  fair: string;\n  good: string;\n  strong: string;\n}\n\n/** Every user-facing string in PasswordStrength. All have English defaults; pass only the ones you change. */\nexport interface PasswordStrengthMessages {\n  levels: PasswordStrengthLevels;\n  /** Name of the show password toggle. It is a pressed toggle (aria-pressed), so the name stays the same in both states. */\n  showPassword: string;\n  /** Accessible name of the strength meter. */\n  meterLabel: string;\n  /** Meter value text while the field is empty. */\n  noPassword: string;\n  /** Accessible name of the requirements list. */\n  rulesLabel: string;\n  /** Read after a rule's label for screen readers, including any leading punctuation. */\n  ruleMet: string;\n  ruleNotMet: string;\n  /** Words around the rolling count beside an unmet rule, as in \"4 more\". */\n  remainingPrefix: string;\n  remainingSuffix: string;\n  /** The polite live-region summary read after each change. */\n  summary: (result: { label: string; met: number; total: number }) => string;\n}\n\nexport const defaultPasswordStrengthMessages: PasswordStrengthMessages = {\n  levels: { tooShort: \"Too short\", weak: \"Weak\", fair: \"Fair\", good: \"Good\", strong: \"Strong\" },\n  showPassword: \"Show password\",\n  meterLabel: \"Password strength\",\n  noPassword: \"No password yet\",\n  rulesLabel: \"Password requirements\",\n  ruleMet: \", met\",\n  ruleNotMet: \", not met\",\n  remainingPrefix: \"\",\n  remainingSuffix: \"more\",\n  summary: ({ label, met, total }) => `Strength: ${label}. ${met} of ${total} requirements met.`,\n};\n\n/** Partial messages, with partial strength words, for the `messages` prop. */\nexport type PasswordStrengthMessagesInput = Partial<Omit<PasswordStrengthMessages, \"levels\">> & {\n  levels?: Partial<PasswordStrengthLevels>;\n};\n\n/**\n * A new password field that shows how strong the password is while it is typed: four segments fill and change tone,\n * each rule checks off with a drawn tick, and the strength word morphs in place. Use it when creating or changing a password;\n * use a plain password field for sign in. Scoring happens on the device, nothing is sent anywhere.\n */\nexport interface PasswordStrengthProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"type\" | \"value\" | \"defaultValue\" | \"children\"\n> {\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, strength: PasswordStrengthResult) => void;\n  /** Rules to check. Strength is the share of rules met, spread over four steps. */\n  rules?: PasswordRule[];\n  /** Error copy tied to the field. The field shakes once each time a new error appears. */\n  error?: string;\n  revealed?: boolean;\n  onRevealedChange?: (revealed: boolean) => void;\n  /** Translated or reworded copy. Every string has an English default; see PasswordStrengthMessages. */\n  messages?: PasswordStrengthMessagesInput;\n  /** Below this many characters the meter holds at its first step and reads \"Too short\". Defaults to 8. */\n  shortLength?: number;\n  /** 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. */\n  countCharacters?: (password: string) => number;\n}\n\n/** Counts code points, so an emoji or other astral character counts as one. */\nexport const countCodePoints = (password: string) => Array.from(password).length;\n\n/** Labels for the built-in rules. `length` receives the minimum length. */\nexport interface PasswordRuleLabels {\n  length: (minLength: number) => string;\n  case: string;\n  number: string;\n  symbol: string;\n}\n\nexport interface PasswordRuleOptions {\n  /** Characters the length rule asks for. Defaults to 12. */\n  minLength?: number;\n  /** Translated or reworded rule labels. */\n  labels?: Partial<PasswordRuleLabels>;\n  /** How the length rule counts characters. Defaults to code points. */\n  countCharacters?: (password: string) => number;\n}\n\nconst defaultRuleLabels: PasswordRuleLabels = {\n  length: (minLength) => `At least ${minLength} characters`,\n  case: \"Upper and lowercase letters\",\n  number: \"At least one number\",\n  symbol: \"At least one symbol\",\n};\n\n/** The built-in rules (length, mixed case, number, symbol) with your own minimum length, labels and character count. */\nexport function createPasswordRules({\n  minLength = 12,\n  labels,\n  countCharacters = countCodePoints,\n}: PasswordRuleOptions = {}): PasswordRule[] {\n  const copy = { ...defaultRuleLabels, ...labels };\n  return [\n    {\n      id: \"length\",\n      label: copy.length(minLength),\n      test: (password) => countCharacters(password) >= minLength,\n      remaining: (password) => Math.max(0, minLength - countCharacters(password)),\n    },\n    { id: \"case\", label: copy.case, test: (password) => /\\p{Ll}/u.test(password) && /\\p{Lu}/u.test(password) },\n    { id: \"number\", label: copy.number, test: (password) => /\\p{N}/u.test(password) },\n    { id: \"symbol\", label: copy.symbol, test: (password) => /[^\\p{L}\\p{N}\\s]/u.test(password) },\n  ];\n}\n\nexport const defaultPasswordRules: PasswordRule[] = createPasswordRules();\n\nexport interface EstimateStrengthOptions {\n  /** Below this many characters a password stays at the first step, however varied it is. Defaults to 8. */\n  shortLength?: number;\n  /** Strength words for the result's label. */\n  levels?: Partial<PasswordStrengthLevels>;\n  /** How characters are counted for shortLength. Defaults to code points. */\n  countCharacters?: (password: string) => number;\n}\n\n/** 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). */\nexport function estimateStrength(\n  password: string,\n  rules: PasswordRule[] = defaultPasswordRules,\n  { shortLength = 8, levels, countCharacters = countCodePoints }: EstimateStrengthOptions = {},\n): PasswordStrengthResult {\n  if (!password) return { level: 0, label: \"\", met: [] };\n  const words = { ...defaultPasswordStrengthMessages.levels, ...levels };\n  const met = rules.filter((rule) => rule.test(password)).map((rule) => rule.id);\n  if (countCharacters(password) < shortLength) return { level: 1, label: words.tooShort, met, short: true };\n  const level = Math.min(4, Math.max(1, Math.round((met.length / Math.max(rules.length, 1)) * 4))) as 1 | 2 | 3 | 4;\n  return { level, label: [words.weak, words.fair, words.good, words.strong][level - 1] ?? words.weak, met };\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst subscribe = () => () => {};\n/** False on the server and during hydration, so reduced motion never changes the first client render. */\nconst useHydrated = () =>\n  useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n\n/** The word rises when strength improves and drops when it falls, so the direction reads without looking at the meter. */\nconst rise: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  center: {\n    opacity: 1,\n    y: \"0em\",\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: enter },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1, transition: { duration: motionTokens.duration.fast } },\n  exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\n/** Digits turn like a counter: a shrinking count drops in from above, a growing one rises from below. */\nconst roll: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${0.45 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: {\n    opacity: 1,\n    y: \"0em\",\n    filter: \"blur(0px)\",\n    transition: {\n      y: motionTokens.spring.snappy,\n      opacity: { duration: motionTokens.duration.fast },\n      filter: { duration: motionTokens.duration.fast },\n    },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.45 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\n\nfunction RollingNumber({ value, reduced }: { value: number; reduced: boolean }) {\n  const [track, setTrack] = useState({ value, direction: 1 });\n  if (track.value !== value) setTrack({ value, direction: value > track.value ? 1 : -1 });\n  const digits = String(value).split(\"\");\n  // Columns are keyed by place value, so 10 → 9 lets the tens column narrow away while the ones column turns.\n  return (\n    <span className=\"inline-flex tabular-nums\">\n      <AnimatePresence initial={false} custom={track.direction}>\n        {digits.map((digit, index) => (\n          <motion.span\n            key={digits.length - 1 - index}\n            className=\"relative inline-block overflow-x-clip\"\n            initial={{ width: 0, opacity: 0 }}\n            animate={{ width: \"auto\", opacity: 1 }}\n            exit={{ width: 0, opacity: 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast } }\n            }\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={track.direction}>\n              <motion.span\n                key={digit}\n                className=\"inline-block\"\n                custom={track.direction}\n                variants={reduced ? fade : roll}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n              >\n                {digit}\n              </motion.span>\n            </AnimatePresence>\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */\nfunction EyeMorph({ slashed, reduced }: { slashed: boolean; reduced: boolean }) {\n  const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };\n  const transition: Transition = reduced\n    ? { duration: 0 }\n    : {\n        pathLength: { duration: motionTokens.duration.standard, ease: standard },\n        opacity: { duration: motionTokens.duration.instant },\n      };\n  return (\n    <svg\n      width={18}\n      height={18}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <mask id={maskId} maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"24\" height=\"24\">\n        <rect width=\"24\" height=\"24\" fill=\"white\" stroke=\"none\" />\n        <motion.path\n          d=\"M3 3l18 18\"\n          stroke=\"black\"\n          strokeWidth={5}\n          initial={false}\n          animate={slash}\n          transition={transition}\n        />\n      </mask>\n      <g mask={`url(#${maskId})`}>\n        <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\" />\n        <circle cx=\"12\" cy=\"12\" r=\"3\" />\n      </g>\n      <motion.path d=\"M3 3l18 18\" initial={false} animate={slash} transition={transition} />\n    </svg>\n  );\n}\n\n/** The tint grows under the tick while the tick draws from its short stroke; unchecking retracts it faster than it drew. */\nfunction RuleMark({ met, delay, reduced }: { met: boolean; delay: number; reduced: boolean }) {\n  // The outline hands over to a faint tint while the tick draws on top; most of the color sits on the tick.\n  return (\n    <span\n      className=\"relative block size-4 flex-[0_0_16px] rounded-full border-[1.25px] border-line-strong text-green transition-[border-color] duration-fast ease-standard group-data-met/rule:border-transparent motion-reduce:transition-none\"\n      aria-hidden=\"true\"\n    >\n      <motion.span\n        className=\"absolute -inset-[1.25px] rounded-[inherit] bg-[color-mix(in_oklab,var(--green)_16%,transparent)]\"\n        initial={false}\n        animate={{ scale: met ? 1 : 0.5, opacity: met ? 1 : 0 }}\n        transition={\n          reduced\n            ? { duration: 0 }\n            : {\n                scale: { ...motionTokens.spring.snappy, delay },\n                opacity: { duration: met ? motionTokens.duration.fast : motionTokens.duration.instant, delay },\n              }\n        }\n      />\n      <svg\n        className=\"absolute -inset-[1.25px] size-4\"\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        <motion.path\n          d=\"M4.75 8.25 7 10.5l4.25-4.75\"\n          initial={false}\n          animate={{ pathLength: met ? 1 : 0, opacity: met ? 1 : 0 }}\n          transition={\n            reduced\n              ? { duration: 0 }\n              : met\n                ? {\n                    pathLength: { duration: motionTokens.duration.standard, ease: enter, delay: delay + 0.06 },\n                    opacity: { duration: 0.05, delay: delay + 0.06 },\n                  }\n                : {\n                    pathLength: { duration: motionTokens.duration.fast, ease: standard },\n                    opacity: { duration: motionTokens.duration.fast, delay: 0.06 },\n                  }\n          }\n        />\n      </svg>\n    </span>\n  );\n}\n\n/** Error copy opens its row on a spring, then the words settle in. The row follows the measured copy, so a wrap never snaps. */\nfunction ErrorRow({ id, text, reduced }: { id: string; text: string; reduced: boolean }) {\n  const copy = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copy.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // The row's space is padding inside the clipped slot, so its height opens from a true zero.\n  return (\n    <motion.span\n      className=\"block overflow-hidden\"\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <span ref={copy} id={id} className=\"relative block pt-2 text-xs leading-body text-red\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n          <motion.span\n            key={text}\n            className=\"block\"\n            custom={1}\n            variants={reduced ? fade : rise}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n          >\n            {text}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\nexport const PasswordStrength = forwardRef<HTMLInputElement, PasswordStrengthProps>(function PasswordStrength(\n  {\n    label,\n    value: valueProp,\n    defaultValue = \"\",\n    onValueChange,\n    onChange,\n    rules = defaultPasswordRules,\n    error,\n    revealed: revealedProp,\n    onRevealedChange,\n    messages: messagesProp,\n    shortLength,\n    countCharacters,\n    id,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const messages: PasswordStrengthMessages = {\n    ...defaultPasswordStrengthMessages,\n    ...messagesProp,\n    levels: { ...defaultPasswordStrengthMessages.levels, ...messagesProp?.levels },\n  };\n  const options: EstimateStrengthOptions = { shortLength, levels: messages.levels, countCharacters };\n  const generated = useId();\n  const controlId = id ?? generated;\n  const rulesId = `${controlId}-rules`,\n    errorId = `${controlId}-error`;\n  const hydrated = useHydrated();\n  const prefersReduced = useReducedMotion();\n  const reduced = hydrated && !!prefersReduced;\n  const [internal, setInternal] = useState(defaultValue);\n  const value = valueProp ?? internal;\n  const [revealedInternal, setRevealedInternal] = useState(false);\n  const revealed = revealedProp ?? revealedInternal;\n  const strength = estimateStrength(value, rules, options);\n  const met = new Set(strength.met);\n\n  // The previous level and rule states tell which way the word moves and which segments and ticks join a staggered wave.\n  const rank = strength.short ? 0.5 : strength.level;\n  const metKey = strength.met.join(\" \");\n  const [track, setTrack] = useState({\n    rank,\n    level: strength.level,\n    previousLevel: strength.level,\n    direction: 1,\n    metKey,\n    previousMet: metKey,\n  });\n  if (track.rank !== rank || track.metKey !== metKey)\n    setTrack({\n      rank,\n      level: strength.level,\n      previousLevel: track.rank !== rank ? track.level : track.previousLevel,\n      direction: track.rank === rank ? track.direction : rank > track.rank ? 1 : -1,\n      metKey,\n      previousMet: track.metKey !== metKey ? track.metKey : track.previousMet,\n    });\n  const previousMet = new Set(track.previousMet.split(\" \").filter(Boolean));\n  const changed = rules.filter((rule) => met.has(rule.id) !== previousMet.has(rule.id)).map((rule) => rule.id);\n\n  // Only a change after mount resolves the text, so the field never blurs in on first paint.\n  const [reveal, setReveal] = useState({ revealed, changed: false });\n  if (reveal.revealed !== revealed) setReveal({ revealed, changed: true });\n\n  const shake = useMotionValue(0);\n  const shaking = useRef<AnimationPlaybackControls | null>(null);\n  const lastError = useRef(error);\n  useEffect(() => {\n    const previous = lastError.current;\n    lastError.current = error;\n    if (!error || error === previous || reduced) return;\n    // A spring released with sideways velocity rings out on its own, the way a refused field shakes on a phone.\n    shaking.current?.stop();\n    shaking.current = animate(shake, 0, {\n      type: \"spring\",\n      velocity: -240,\n      stiffness: 900,\n      damping: 57,\n      restDelta: 0.1,\n    });\n  }, [error, reduced, shake]);\n\n  function toggleReveal() {\n    if (revealedProp === undefined) setRevealedInternal(!revealed);\n    onRevealedChange?.(!revealed);\n  }\n\n  const summary = strength.level\n    ? messages.summary({ label: strength.label, met: strength.met.length, total: rules.length })\n    : \"\";\n  const describedBy = [props[\"aria-describedby\"], error ? errorId : undefined, rulesId].filter(Boolean).join(\" \");\n\n  return (\n    <div\n      className=\"grid min-w-0 [--tone:var(--line-strong)] data-[level=1]:[--tone:var(--red)] data-[level=2]:[--tone:var(--orange)] data-[level=3]:[--tone:var(--green)] data-[level=4]:[--tone:var(--green)]\"\n      data-level={strength.level}\n    >\n      <label className=\"mb-2 text-sm leading-body font-medium text-ink\" htmlFor={controlId}>\n        {label}\n      </label>\n      {/* The shell only transitions color; its sideways shake is a spring on transform, so the two never fight. */}\n      <motion.div\n        className=\"flex min-h-control-md items-center gap-1 rounded-control border border-line-strong bg-surface pr-[5px] pl-4 transition-[border-color] duration-fast ease-standard hover:not-focus-within:border-ink-3 focus-within:border-ink has-[input[aria-invalid=true]]:border-red motion-reduce:transition-none\"\n        style={{ x: shake }}\n      >\n        <input\n          autoComplete=\"new-password\"\n          autoCapitalize=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          {...props}\n          ref={ref}\n          id={controlId}\n          type={revealed ? \"text\" : \"password\"}\n          value={value}\n          onChange={(event) => {\n            const next = event.target.value;\n            if (valueProp === undefined) setInternal(next);\n            onChange?.(event);\n            onValueChange?.(next, estimateStrength(next, rules, options));\n          }}\n          aria-invalid={error ? true : props[\"aria-invalid\"]}\n          aria-describedby={describedBy}\n          data-reveal={reveal.changed ? (revealed ? \"shown\" : \"hidden\") : undefined}\n          className={cn(\n            \"h-[calc(var(--control-height-md)-2px)] w-full min-w-0 border-0 bg-transparent p-0 text-sm/[inherit] tracking-body text-ink outline-0 placeholder:text-ink-3 pointer-coarse:text-base/[inherit] data-[reveal=hidden]:animate-password-strength-resolve-hidden data-[reveal=shown]:animate-password-strength-resolve-shown motion-reduce:animate-none\",\n            className,\n          )}\n        />\n        <button\n          type=\"button\"\n          className=\"grid size-[34px] flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-3 [-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)] hover:bg-hover hover:text-ink active:[transform:scale(.94)] active:[transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_100ms_var(--ease-standard)] aria-pressed:text-ink motion-reduce:transition-none motion-reduce:active:[transform:none]\"\n          onClick={toggleReveal}\n          aria-label={messages.showPassword}\n          aria-pressed={revealed}\n          aria-controls={controlId}\n        >\n          <EyeMorph slashed={revealed} reduced={reduced} />\n        </button>\n      </motion.div>\n      <AnimatePresence initial={false}>\n        {error ? <ErrorRow key=\"error\" id={errorId} text={error} reduced={reduced} /> : null}\n      </AnimatePresence>\n      <div className=\"mt-3 flex items-center gap-3\">\n        <div\n          className=\"grid min-w-0 flex-auto grid-cols-4 gap-[5px]\"\n          role=\"meter\"\n          aria-label={messages.meterLabel}\n          aria-valuemin={0}\n          aria-valuemax={4}\n          aria-valuenow={strength.level}\n          aria-valuetext={strength.level ? strength.label : messages.noPassword}\n        >\n          {[0, 1, 2, 3].map((index) => {\n            const on = index < strength.level;\n            // A pasted password fills left to right; clearing empties right to left.\n            const wave = on ? index - track.previousLevel : track.previousLevel - 1 - index;\n            return (\n              <span key={index} className=\"relative isolate h-1 overflow-hidden rounded-full bg-line\">\n                <motion.span\n                  className=\"absolute inset-0 rounded-[inherit] bg-(--tone) transition-[background-color] duration-standard ease-standard motion-reduce:transition-none\"\n                  initial={false}\n                  animate={{ x: on ? \"0%\" : \"-101%\" }}\n                  transition={\n                    reduced\n                      ? { duration: 0 }\n                      : { ...motionTokens.spring.smooth, delay: Math.max(0, wave) * (on ? 0.05 : 0.03) }\n                  }\n                />\n              </span>\n            );\n          })}\n        </div>\n        {/* A fixed slot: the meter never resizes when the word changes, and the word stays flush with the field's edge. */}\n        <span\n          className=\"relative block h-[1.4em] flex-[0_0_4.5rem] text-right text-sm leading-body font-medium whitespace-nowrap text-ink\"\n          aria-hidden=\"true\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false} custom={track.direction}>\n            {strength.level ? (\n              <motion.span\n                key={strength.label}\n                className=\"inline-block\"\n                custom={track.direction}\n                variants={reduced ? fade : rise}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n              >\n                {strength.label}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      </div>\n      <ul id={rulesId} className=\"mt-4 grid list-none gap-1.5 p-0\" aria-label={messages.rulesLabel}>\n        {rules.map((rule) => {\n          const ok = met.has(rule.id);\n          const remaining = value && !ok ? (rule.remaining?.(value) ?? 0) : 0;\n          const order = changed.indexOf(rule.id);\n          return (\n            <li\n              key={rule.id}\n              className=\"group/rule flex min-h-5 items-center gap-2.5 text-sm leading-body text-ink-3 transition-[color] duration-standard ease-standard data-met:text-ink motion-reduce:transition-none\"\n              data-met={ok || undefined}\n            >\n              <RuleMark met={ok} delay={order > 0 ? order * 0.05 : 0} reduced={reduced} />\n              <span className=\"min-w-0\">\n                {rule.label}\n                <span className=\"sr-only\">{ok ? messages.ruleMet : messages.ruleNotMet}</span>\n              </span>\n              <span\n                className=\"relative ml-auto block pl-2 text-xs/[inherit] whitespace-nowrap text-ink-3\"\n                aria-hidden=\"true\"\n              >\n                <AnimatePresence mode=\"popLayout\" initial={false} custom={1}>\n                  {remaining > 0 ? (\n                    <motion.span\n                      key=\"remaining\"\n                      className=\"inline-flex items-baseline gap-[.28em]\"\n                      custom={1}\n                      variants={reduced ? fade : rise}\n                      initial=\"enter\"\n                      animate=\"center\"\n                      exit=\"exit\"\n                    >\n                      {messages.remainingPrefix ? `${messages.remainingPrefix} ` : null}\n                      <RollingNumber value={remaining} reduced={reduced} />\n                      {messages.remainingSuffix ? ` ${messages.remainingSuffix}` : null}\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n              </span>\n            </li>\n          );\n        })}\n      </ul>\n      <output className=\"sr-only\">{summary}</output>\n    </div>\n  );\n});\n\nPasswordStrength.displayName = \"PasswordStrength\";\n\nexport default PasswordStrength;\n",
      "type": "registry:component",
      "target": "@components/iris/password-strength/password-strength.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/password-strength",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "PasswordStrength",
    "tags": [
      "password",
      "strength meter",
      "validation"
    ],
    "docs": "https://irisui.dev/components/password-strength"
  }
}
