{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-strength",
  "type": "registry:ui",
  "title": "Password Strength",
  "description": "Four bars that fill in order, a checklist that ticks, a settled announcement.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/password-strength.tsx",
      "content": "\"use client\";\nimport { Meter } from \"@base-ui/react/meter\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Warning } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type StrengthScore = 0 | 1 | 2 | 3 | 4;\n\nexport type StrengthResult = {\n  /** 0 for an empty password, then 1 (weak) to 4 (strong). */\n  score: StrengthScore;\n  /** Estimated guessing entropy in bits, after penalties. */\n  bits: number;\n  /** The single most useful thing to fix, if anything stands out. */\n  warning?: string;\n};\n\nexport type PasswordRequirement = {\n  id: string;\n  label: string;\n  test: (password: string, context: { userInputs: string[] }) => boolean;\n};\n\n// A short list of the passwords that show up first in every breach. Not a\n// replacement for a server-side breach check, just the obvious ones.\nconst COMMON = new Set(\n  \"password passw0rd password1 123456 1234567 12345678 123456789 1234567890 qwerty qwertyuiop abc123 111111 000000 letmein welcome monkey dragon iloveyou admin login sunshine princess football baseball master hello freedom whatever trustno1 starwars superman batman changeme secret shadow michael jennifer charlie 1q2w3e4r zaq12wsx asdfghjkl\"\n    .split(\" \"),\n);\nconst ROWS = [\"qwertyuiop\", \"asdfghjkl\", \"zxcvbnm\", \"1234567890\"];\nconst LEET: Record<string, string> = { \"0\": \"o\", \"1\": \"l\", \"3\": \"e\", \"4\": \"a\", \"5\": \"s\", \"7\": \"t\", \"@\": \"a\", \"$\": \"s\", \"!\": \"i\" };\n\nconst normalize = (pw: string) => pw.toLowerCase().replace(/[013457@$!]/g, (c) => LEET[c] ?? c);\n/** Common after stripping the digits and symbols people tack on the end (\"Password1!\"). */\nexport const isCommonPassword = (pw: string) => {\n  const lower = pw.toLowerCase();\n  const core = lower.replace(/[\\d\\W_]+$/, \"\");\n  return COMMON.has(lower) || COMMON.has(core) || COMMON.has(normalize(core)) || COMMON.has(normalize(lower));\n};\n\nconst tokens = (inputs: string[]) =>\n  inputs.flatMap((s) => s.toLowerCase().split(/[^a-z0-9]+/)).filter((t) => t.length >= 3);\n\n/**\n * A small, dependency-free heuristic. It estimates entropy from length and\n * character variety, then discounts the things attackers try first: repeats,\n * sequences, keyboard rows, common passwords, years and the person's own name.\n * Good enough to coach someone while they type; score on the server as well.\n */\nexport function scorePassword(password: string, { userInputs = [] }: { userInputs?: string[] } = {}): StrengthResult {\n  if (!password) return { score: 0, bits: 0 };\n  const chars = [...password];\n  let pool = 0;\n  if (/[a-z]/.test(password)) pool += 26;\n  if (/[A-Z]/.test(password)) pool += 26;\n  if (/\\d/.test(password)) pool += 10;\n  if (/[^\\w\\s]|[_\\s]/.test(password)) pool += 33;\n  if (/[^\\x00-\\x7F]/.test(password)) pool += 100;\n\n  // Characters that continue a repeat or a run beyond two count for a quarter.\n  let length = 0;\n  let warning: string | undefined;\n  const lower = password.toLowerCase();\n  for (let i = 0; i < chars.length; i++) {\n    const a = lower.charCodeAt(i - 2), b = lower.charCodeAt(i - 1), c = lower.charCodeAt(i);\n    const repeat = i >= 2 && a === b && b === c;\n    const run = i >= 2 && b - a === c - b && Math.abs(c - b) === 1;\n    const row = i >= 3 && ROWS.some((r) => r.includes(lower.slice(i - 3, i + 1)) || [...r].reverse().join(\"\").includes(lower.slice(i - 3, i + 1)));\n    if (repeat) warning ??= \"Repeated characters are easy to guess\";\n    else if (run || row) warning ??= \"Avoid sequences like abc, 123 or qwerty\";\n    length += repeat || run || row ? 0.25 : 1;\n  }\n  let bits = length * Math.log2(Math.max(pool, 10));\n\n  const norm = normalize(password);\n  if (isCommonPassword(password)) {\n    return { score: 1, bits: Math.min(bits, 10), warning: \"This is one of the most used passwords\" };\n  }\n  // Either of these is the first thing a targeted guess tries, so they cap the score at Good.\n  let cap: StrengthScore = 4;\n  for (const word of COMMON) {\n    if (word.length >= 6 && norm.includes(word)) {\n      bits -= 14;\n      cap = 3;\n      warning = \"Avoid common passwords, even inside longer ones\";\n      break;\n    }\n  }\n  if (tokens(userInputs).some((t) => lower.includes(t) || norm.includes(t))) {\n    bits -= 16;\n    cap = 3;\n    warning = \"Avoid your name or email address\";\n  }\n  if (/(19|20)\\d\\d/.test(password)) {\n    bits -= 5;\n    warning ??= \"Years are among the first things tried\";\n  }\n\n  const raw: StrengthScore = password.length < 8 || bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4;\n  const score = Math.min(raw, cap) as StrengthScore;\n  return { score, bits: Math.max(0, Math.round(bits)), warning: score === 4 ? undefined : warning };\n}\n\n/** Length first, then variety, then the obvious traps. The name check appears only when you pass userInputs. */\nexport const defaultRequirements: PasswordRequirement[] = [\n  { id: \"length\", label: \"12 or more characters\", test: (pw) => [...pw].length >= 12 },\n  { id: \"mix\", label: \"Letters and a number or symbol\", test: (pw) => /\\p{L}/u.test(pw) && /[^\\p{L}]/u.test(pw) },\n  { id: \"common\", label: \"Not a commonly used password\", test: (pw) => pw.length > 0 && !isCommonPassword(pw) },\n  {\n    id: \"personal\",\n    label: \"Doesn’t include your name or email\",\n    test: (pw, { userInputs }) => pw.length > 0 && !tokens(userInputs).some((t) => pw.toLowerCase().includes(t)),\n  },\n];\n\nexport function usePasswordStrength(\n  password: string,\n  { userInputs = [], requirements = defaultRequirements, scorer = scorePassword }: { userInputs?: string[]; requirements?: PasswordRequirement[]; scorer?: typeof scorePassword } = {},\n) {\n  const key = userInputs.join(\"\\u0000\");\n  return useMemo(() => {\n    const inputs = key ? key.split(\"\\u0000\") : [];\n    const result = scorer(password, { userInputs: inputs });\n    const list = requirements\n      .filter((r) => r.id !== \"personal\" || inputs.length > 0)\n      .map((r) => ({ id: r.id, label: r.label, met: r.test(password, { userInputs: inputs }) }));\n    return { ...result, requirements: list, metCount: list.filter((r) => r.met).length };\n  }, [password, key, requirements, scorer]);\n}\n\nexport type PasswordStrengthProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The password to score. Pass the field's current value. */\n  password: string;\n  /** Things the password shouldn't contain: the person's name, email, company. */\n  userInputs?: string[];\n  /** The checklist. Pass `[]` to show only the meter. */\n  requirements?: PasswordRequirement[];\n  /** Words for scores 1–4. */\n  labels?: [string, string, string, string];\n  /** Swap in your own scorer, for example one backed by a larger dictionary. */\n  scorer?: typeof scorePassword;\n};\n\nconst tints = [\"\", \"bg-danger\", \"bg-warning\", \"bg-success\", \"bg-success\"] as const;\nconst texts = [\"text-fg-4\", \"text-danger\", \"text-warning\", \"text-success\", \"text-success\"] as const;\n\nexport function PasswordStrength({\n  password,\n  userInputs,\n  requirements = defaultRequirements,\n  labels = [\"Weak\", \"Fair\", \"Good\", \"Strong\"],\n  scorer,\n  className,\n  ...rest\n}: PasswordStrengthProps) {\n  const reduce = useReducedMotion();\n  const { score, warning, requirements: list, metCount } = usePasswordStrength(password, { userInputs, requirements, scorer });\n  const label = score ? labels[score - 1] : \"\";\n\n  // Announce once typing settles, not on every keystroke.\n  const summary = score\n    ? `Password strength ${label.toLowerCase()}.${list.length ? ` ${metCount} of ${list.length} requirements met.` : \"\"}`\n    : \"\";\n  const [announced, setAnnounced] = useState(\"\");\n  useEffect(() => {\n    const t = window.setTimeout(() => setAnnounced(summary), 800);\n    return () => window.clearTimeout(t);\n  }, [summary]);\n\n  return (\n    <div data-score={score} className={cn(\"flex w-full min-w-0 flex-col gap-2.5\", className)} {...rest}>\n      <Meter.Root\n        value={score}\n        min={0}\n        max={4}\n        aria-valuetext={score ? label : \"No password entered\"}\n        className=\"flex items-center gap-3\"\n      >\n        <Meter.Label className=\"sr-only\">Password strength</Meter.Label>\n        <Meter.Track className=\"grid flex-1 grid-cols-4 gap-1\">\n          {[0, 1, 2, 3].map((i) => {\n            const on = i < score;\n            return (\n              <span key={i} className=\"relative h-1 overflow-hidden rounded-full bg-line-2\">\n                <span\n                  data-on={on || undefined}\n                  className={cn(\n                    \"absolute inset-0 origin-left scale-x-0 rounded-full transition-[scale,background-color] duration-300 ease-out-expo data-[on]:scale-x-100\",\n                    \"[transition-delay:var(--delay),0ms] motion-reduce:[transition-delay:0ms]\",\n                    tints[score] || \"bg-fg-4\",\n                  )}\n                  // Filling walks left to right, emptying walks back, one bar at a time.\n                  // The tint changes on every bar at once, so the meter never shows two verdicts.\n                  style={{ \"--delay\": `${(on ? i : 3 - i) * 45}ms` } as React.CSSProperties}\n                />\n              </span>\n            );\n          })}\n        </Meter.Track>\n        {/* Every label is stacked in one cell, so the meter's width never changes. */}\n        <span aria-hidden className=\"grid shrink-0 justify-items-end text-[12px] font-medium leading-4\">\n          {labels.map((l) => (\n            <span key={l} className=\"invisible col-start-1 row-start-1\">{l}</span>\n          ))}\n          <AnimatePresence initial={false}>\n            {label && (\n              <motion.span\n                key={label}\n                className={cn(\"col-start-1 row-start-1 transition-colors duration-200\", texts[score])}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n              >\n                {label}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </Meter.Root>\n\n      {list.length > 0 && (\n        <ul aria-label=\"Password requirements\" className=\"flex flex-col gap-1.5\">\n          {list.map((r) => (\n            <li\n              key={r.id}\n              data-met={r.met || undefined}\n              className=\"flex items-start gap-2 text-[12px] leading-4 text-fg-3 transition-colors duration-200 data-[met]:text-fg-2\"\n            >\n              <Tick met={r.met} reduce={!!reduce} />\n              <span className=\"min-w-0\">\n                {r.label}\n                <span className=\"sr-only\">{r.met ? \", met\" : \", not met\"}</span>\n              </span>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <AnimatePresence initial={false}>\n        {warning && (\n          <motion.p\n            key=\"warning\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.14, ease: ease.in } }}\n            transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n            className=\"-mt-1 overflow-hidden\"\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={warning}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.16 }}\n                className=\"flex items-start gap-2 pt-1 text-[12px] leading-4 text-fg-2\"\n              >\n                <span aria-hidden className=\"grid size-4 shrink-0 place-items-center text-warning\">\n                  <Warning size={14} />\n                </span>\n                {warning}\n              </motion.span>\n            </AnimatePresence>\n          </motion.p>\n        )}\n      </AnimatePresence>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n\n// Unmet is a quiet ring; met draws a tick over a soft success disc. Unmeeting\n// retracts the tick faster than it drew, because losing it is not an event.\nfunction Tick({ met, reduce }: { met: boolean; reduce: boolean }) {\n  return (\n    <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center\">\n      <motion.span\n        className=\"absolute inset-0 rounded-full bg-success-soft\"\n        initial={false}\n        animate={{ scale: met ? 1 : 0.4, opacity: met ? 1 : 0 }}\n        transition={reduce ? { duration: 0 } : met ? spring.pop : { duration: 0.12, ease: ease.in }}\n      />\n      <motion.span\n        className=\"absolute size-[7px] rounded-full border border-fg-4\"\n        initial={false}\n        animate={{ scale: met ? 0.4 : 1, opacity: met ? 0 : 1 }}\n        transition={{ duration: reduce ? 0 : 0.14 }}\n      />\n      <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"relative text-success\">\n        <motion.path\n          d=\"M4 8.5 6.75 11.25 12 5\"\n          initial={false}\n          animate={{ pathLength: met ? 1 : 0, opacity: met ? 1 : 0 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : met\n                ? { pathLength: { duration: 0.28, ease: ease.out, delay: 0.05 }, opacity: { duration: 0.05 } }\n                : { pathLength: { duration: 0.12, ease: ease.in }, opacity: { duration: 0.06, delay: 0.08 } }\n          }\n        />\n      </svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/password-strength"
}
