{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-strength",
  "type": "registry:ui",
  "title": "Password Strength",
  "description": "A password field with show/hide, a four-segment strength meter and a live requirement checklist.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/password-strength.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface PasswordRequirement {\n  id: string;\n  label: string;\n  test: (value: string) => boolean;\n}\n\nexport type PasswordScore = 0 | 1 | 2 | 3 | 4;\n\nexport interface PasswordStrengthResult {\n  /** 0 very weak · 1 weak · 2 fair · 3 good · 4 strong. */\n  score: PasswordScore;\n  /** Rough entropy estimate in bits, after pattern penalties. */\n  bits: number;\n  /** Short, human-readable suggestions (most important first). */\n  feedback: string[];\n}\n\nexport interface PasswordStrengthProps extends Omit<React.ComponentPropsWithoutRef<\"input\">, \"type\" | \"size\"> {\n  /** Visible label, rendered as a real <label htmlFor>. */\n  label?: React.ReactNode;\n  /** Helper text below the field (linked via aria-describedby). */\n  hint?: React.ReactNode;\n  /** Error message. Any truthy value marks the field invalid; a node is also shown. */\n  error?: React.ReactNode;\n  /** Minimum length, used by the default requirements, the heuristic and native validation. */\n  minLength?: number;\n  /** Checklist rules. Defaults to length, upper + lower case, a number and a symbol. */\n  requirements?: PasswordRequirement[];\n  /** Show the live requirement checklist. */\n  showRequirements?: boolean;\n  /** Show the heuristic's top suggestion under the meter. */\n  showFeedback?: boolean;\n  /** Show the show/hide toggle. */\n  allowReveal?: boolean;\n  /** Labels for scores 0–4. */\n  strengthLabels?: [string, string, string, string, string];\n  /** Called when typing changes the score (or empties the field). */\n  onStrengthChange?: (result: PasswordStrengthResult) => void;\n  /** Focus ring and toggle colour (any CSS colour). */\n  accent?: string;\n  /** Class for the <input>; `className` styles the outer wrapper. */\n  inputClassName?: string;\n}\n\nexport const DEFAULT_STRENGTH_LABELS: [string, string, string, string, string] = [\"Very weak\", \"Weak\", \"Fair\", \"Good\", \"Strong\"];\n\n/** The default checklist for a given minimum length. */\nexport function defaultPasswordRequirements(minLength = 8): PasswordRequirement[] {\n  return [\n    { id: \"length\", label: `At least ${minLength} characters`, test: (v) => [...v].length >= minLength },\n    { id: \"case\", label: \"Upper and lower case\", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },\n    { id: \"number\", label: \"A number\", test: (v) => /\\d/.test(v) },\n    { id: \"symbol\", label: \"A symbol\", test: (v) => /[^A-Za-z0-9\\s]/.test(v) },\n  ];\n}\n\n/* ------------------------------ Heuristic -------------------------------- */\n\n// A deliberately small list: the most common passwords and keyboard walks.\nconst COMMON = [\n  \"password\", \"passw0rd\", \"qwerty\", \"qwertz\", \"azerty\", \"asdf\", \"zxcv\", \"letmein\", \"welcome\", \"admin\", \"login\",\n  \"iloveyou\", \"monkey\", \"dragon\", \"football\", \"baseball\", \"master\", \"sunshine\", \"princess\", \"shadow\", \"superman\",\n  \"trustno1\", \"abc123\", \"123456\", \"111111\", \"000000\", \"654321\", \"123123\", \"secret\", \"changeme\", \"hello\",\n];\n\n/**\n * A transparent strength estimate (no dictionary download, nothing leaves the browser):\n * 1. Character pool from the classes used (lower 26, upper 26, digits 10, symbols 33, other 100).\n * 2. Effective length = length minus penalties: repeats (aaa) and sequences (abc, 321) count a quarter,\n *    common passwords / keyboard walks count nothing, a year (19xx/20xx) counts half.\n * 3. bits = effectiveLength × log2(pool) → score thresholds 28 / 40 / 56 / 72 bits.\n * 4. Caps: shorter than minLength → at most 1; contains a common password → at most 1.\n * It is a rough guide for UI feedback, not a security guarantee. Always validate server-side.\n */\nexport function estimatePasswordStrength(password: string, minLength = 8): PasswordStrengthResult {\n  const chars = [...password];\n  const length = chars.length;\n  if (!length) return { score: 0, bits: 0, feedback: [] };\n\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 (/[!-/:-@[-`{-~ ]/.test(password)) pool += 33;\n  if (chars.some((c) => (c.codePointAt(0) ?? 0) > 127)) pool += 100;\n\n  const feedback: string[] = [];\n  let penalty = 0;\n\n  // Repeats and straight sequences (by char code, either direction).\n  let repeats = 0;\n  let sequential = 0;\n  for (let i = 1; i < length; i++) {\n    const a = chars[i - 1].toLowerCase().codePointAt(0) ?? 0;\n    const b = chars[i].toLowerCase().codePointAt(0) ?? 0;\n    if (a === b) repeats++;\n    else if (Math.abs(a - b) === 1) sequential++;\n  }\n  penalty += (repeats + sequential) * 0.75;\n  if (repeats >= 2) feedback.push(\"Avoid repeated characters\");\n  if (sequential >= 3) feedback.push(\"Avoid sequences like abc or 123\");\n\n  const lower = password.toLowerCase();\n  const common = COMMON.filter((w) => lower.includes(w));\n  if (common.length) {\n    penalty += Math.max(...common.map((w) => w.length));\n    feedback.unshift(\"Avoid common passwords and keyboard patterns\");\n  }\n  if (/(19|20)\\d\\d/.test(password)) {\n    penalty += 2;\n    feedback.push(\"Years are easy to guess\");\n  }\n\n  const effective = Math.max(0, length - penalty);\n  const bits = effective * Math.log2(Math.max(pool, 2));\n  let score: PasswordScore = bits < 28 ? 0 : bits < 40 ? 1 : bits < 56 ? 2 : bits < 72 ? 3 : 4;\n  if (length < minLength) {\n    score = Math.min(score, 1) as PasswordScore;\n    feedback.unshift(`Use at least ${minLength} characters`);\n  }\n  if (common.length) score = Math.min(score, 1) as PasswordScore;\n  if (score < 3 && length >= minLength && pool <= 36) feedback.push(\"Mix in other character types, or make it longer\");\n\n  return { score, bits: Math.round(bits), feedback };\n}\n\n/* -------------------------------------------------------------------------- */\n\nconst LEVEL_COLORS = [\"#E11D48\", \"#F43F5E\", \"#F59E0B\", \"#84CC16\", \"#10B981\"];\n\n/**\n * A password field with a show/hide toggle, a four-segment strength meter driven by a small,\n * documented heuristic, and a live requirement checklist. The strength label is announced\n * politely (debounced) and the meter and checklist are linked with aria-describedby.\n */\nexport const PasswordStrength = React.forwardRef<HTMLInputElement, PasswordStrengthProps>(function PasswordStrength(\n  {\n    label = \"Password\",\n    hint,\n    error,\n    minLength = 8,\n    requirements,\n    showRequirements = true,\n    showFeedback = true,\n    allowReveal = true,\n    strengthLabels = DEFAULT_STRENGTH_LABELS,\n    onStrengthChange,\n    accent = \"#FF7448\",\n    inputClassName = \"\",\n    className = \"\",\n    style,\n    id: idProp,\n    value,\n    defaultValue,\n    onChange,\n    disabled,\n    autoComplete = \"new-password\",\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const autoId = React.useId();\n  const id = idProp ?? `pw-${autoId}`;\n  const meterId = `${id}-strength`;\n  const listId = `${id}-requirements`;\n  const hintId = `${id}-hint`;\n  const errorId = `${id}-error`;\n\n  // Uncontrolled inputs are mirrored so the meter can follow them.\n  const [inner, setInner] = React.useState(() => (defaultValue === undefined ? \"\" : String(defaultValue)));\n  const password = value === undefined ? inner : String(value);\n  const [revealed, setRevealed] = React.useState(false);\n\n  const rules = requirements ?? defaultPasswordRequirements(minLength);\n  const checks = rules.map((r) => ({ ...r, met: r.test(password) }));\n  const metCount = checks.filter((c) => c.met).length;\n  const result = estimatePasswordStrength(password, minLength);\n  const { score } = result;\n  const empty = password.length === 0;\n  const strengthText = empty ? \"Not entered\" : strengthLabels[score];\n  const color = LEVEL_COLORS[score];\n\n  // Debounced announcement so every keystroke isn't read out.\n  const spoken = empty\n    ? \"\"\n    : `Password strength: ${strengthText}.${showRequirements && rules.length ? ` ${metCount} of ${rules.length} requirements met.` : \"\"}`;\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  React.useEffect(() => {\n    const t = setTimeout(() => setAnnouncement(spoken), 900);\n    return () => clearTimeout(t);\n  }, [spoken]);\n\n  const invalid = Boolean(error);\n  const hasErrorText = invalid && error !== true;\n  const describedBy =\n    [meterId, showRequirements ? listId : null, hint ? hintId : null, hasErrorText ? errorId : null, props[\"aria-describedby\"]]\n      .filter(Boolean)\n      .join(\" \") || undefined;\n\n  const filled = empty ? 0 : Math.max(1, score); // a typed password always lights at least one segment\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--pw-accent\" as string]: accent, [\"--pw-level\" as string]: color, ...style }}>\n      <label htmlFor={id} className=\"mb-2 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n        {label}\n      </label>\n\n      <div\n        className={`relative flex h-12 items-center rounded-xl border bg-white transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${\n          invalid\n            ? \"border-rose-500/80 dark:border-rose-400/80\"\n            : \"border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--pw-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--pw-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30\"\n        } ${disabled ? \"opacity-50\" : \"\"}`}\n      >\n        <input\n          ref={ref}\n          id={id}\n          type={revealed ? \"text\" : \"password\"}\n          autoComplete={autoComplete}\n          autoCapitalize=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          minLength={minLength}\n          disabled={disabled}\n          {...(value === undefined ? { defaultValue } : { value })}\n          onChange={(event) => {\n            const next = event.target.value;\n            if (value === undefined) setInner(next);\n            onChange?.(event);\n            // Report score changes caused by typing (an empty field counts as its own state).\n            const nextResult = estimatePasswordStrength(next, minLength);\n            if (nextResult.score !== score || (next.length === 0) !== empty) onStrengthChange?.(nextResult);\n          }}\n          {...props}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          className={`h-full min-w-0 flex-1 rounded-xl bg-transparent pl-3.5 text-[15px] text-zinc-950 outline-none placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500 ${\n            allowReveal ? \"pr-1\" : \"pr-3.5\"\n          } ${revealed ? \"\" : \"tracking-[0.08em] placeholder:tracking-normal\"} ${inputClassName}`}\n        />\n        {allowReveal && (\n          <button\n            type=\"button\"\n            aria-label=\"Show password\"\n            aria-pressed={revealed}\n            aria-controls={id}\n            disabled={disabled}\n            onClick={() => setRevealed((r) => !r)}\n            className=\"mr-1.5 grid size-9 shrink-0 place-items-center rounded-lg text-zinc-500 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--pw-accent)] disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n          >\n            <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" className=\"size-[18px]\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M1.75 10s3-5.75 8.25-5.75S18.25 10 18.25 10 15.25 15.75 10 15.75 1.75 10 1.75 10Z\" />\n              <circle cx=\"10\" cy=\"10\" r=\"2.6\" />\n              {/* The slash draws in while the password is visible. */}\n              <motion.path\n                d=\"M3.5 16.5 16.5 3.5\"\n                initial={false}\n                animate={{ pathLength: revealed ? 1 : 0, opacity: revealed ? 1 : 0 }}\n                transition={{ duration: reduceMotion ? 0 : 0.22, ease: \"easeOut\" }}\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Strength meter: four segments filling left to right, plus a text label (never colour alone). */}\n      <div className=\"mt-2.5 flex items-center gap-3\">\n        <div\n          role=\"meter\"\n          aria-label=\"Password strength\"\n          aria-valuemin={0}\n          aria-valuemax={4}\n          aria-valuenow={empty ? 0 : score}\n          aria-valuetext={strengthText}\n          className=\"grid flex-1 grid-cols-4 gap-1\"\n        >\n          {[0, 1, 2, 3].map((i) => (\n            <span key={i} className=\"relative h-1.5 overflow-hidden rounded-full bg-zinc-900/[0.08] dark:bg-white/[0.1]\">\n              <span\n                className=\"absolute inset-0 origin-left rounded-full bg-[var(--pw-level)] transition-[scale,background-color] duration-300 ease-[cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:transition-none\"\n                style={{ scale: i < filled ? \"1 1\" : \"0 1\", transitionDelay: reduceMotion ? \"0ms\" : `${(i < filled ? i : 3 - i) * 45}ms` }}\n              />\n            </span>\n          ))}\n        </div>\n        <span\n          id={meterId}\n          className={`min-w-[5.5rem] text-right font-mono text-[10px] uppercase tracking-[0.14em] ${\n            empty ? \"text-zinc-400 dark:text-zinc-500\" : \"text-zinc-800 dark:text-zinc-200\"\n          }`}\n        >\n          <span className=\"sr-only\">Strength: </span>\n          {empty ? \"—\" : strengthText}\n        </span>\n      </div>\n\n      {showFeedback && (\n        <p className=\"mt-1.5 min-h-[18px] text-[12.5px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {!empty && result.feedback[0] ? result.feedback[0] + \".\" : null}\n        </p>\n      )}\n\n      {showRequirements && rules.length > 0 && (\n        <ul id={listId} aria-label=\"Password requirements\" className=\"mt-2 grid gap-1.5 sm:grid-cols-2\">\n          {checks.map((c) => (\n            <li key={c.id} className=\"flex items-center gap-2 text-[13px] leading-snug\">\n              <span\n                aria-hidden=\"true\"\n                className={`relative grid size-4 shrink-0 place-items-center rounded-full border transition-colors duration-200 motion-reduce:transition-none ${\n                  c.met\n                    ? \"border-emerald-500 bg-emerald-500 text-white dark:border-emerald-400 dark:bg-emerald-400 dark:text-zinc-950\"\n                    : \"border-zinc-900/20 text-transparent dark:border-white/20\"\n                }`}\n              >\n                <svg viewBox=\"0 0 12 12\" className=\"size-2.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <motion.path\n                    d=\"M2.5 6.2 5 8.5l4.5-5\"\n                    initial={false}\n                    animate={{ pathLength: c.met ? 1 : 0 }}\n                    transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 30 }}\n                  />\n                </svg>\n              </span>\n              <span className={c.met ? \"text-zinc-900 dark:text-zinc-100\" : \"text-zinc-500 dark:text-zinc-400\"}>\n                {c.label}\n                <span className=\"sr-only\">{c.met ? \" — met\" : \" — not met\"}</span>\n              </span>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      {hint && (\n        <p id={hintId} className=\"mt-2 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {hint}\n        </p>\n      )}\n\n      <div aria-live=\"polite\">\n        <AnimatePresence initial={false}>\n          {hasErrorText && (\n            <motion.p\n              key=\"error\"\n              id={errorId}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: \"auto\", y: 0 }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}\n              transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}\n              className=\"overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400\"\n            >\n              <span className=\"block pt-1.5\">{error}</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </div>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/password-strength"
}
