{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-strength",
  "title": "Password Strength",
  "description": "A password input with an animated four-segment strength meter, crossfading strength label, requirements checklist with drawing check marks, and a visibility toggle",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/password-strength/password-strength.tsx",
      "content": "/**\n * Spectrum UI — PasswordStrengthInput\n *\n * A password input with an animated strength meter and requirements\n * checklist. Typing fills a four-segment meter sequentially with a spring,\n * the strength label crossfades between \"Too weak\" and \"Strong\", and each\n * satisfied rule draws a check into an emerald circle. Includes an eye\n * visibility toggle, works controlled or uncontrolled, honors\n * prefers-reduced-motion, and announces strength changes politely to\n * screen readers.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <PasswordStrengthInput onValueChange={(value) => setPassword(value)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { Eye, EyeOff } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface PasswordRule {\n  /** Human-readable requirement shown in the checklist */\n  label: string\n  /** Returns true when the current value satisfies the rule */\n  test: (value: string) => boolean\n}\n\nexport interface PasswordStrengthInputProps {\n  /** Controlled value. Leave undefined for uncontrolled usage */\n  value?: string\n  /** Initial value when uncontrolled. Default \"\" */\n  defaultValue?: string\n  /** Fires with the next value on every keystroke */\n  onValueChange?: (value: string) => void\n  /** Rules scored by the meter and listed in the checklist */\n  rules?: PasswordRule[]\n  /** Render the requirements checklist. Default true */\n  showChecklist?: boolean\n  /** Render the strength meter and label. Default true */\n  showMeter?: boolean\n  /** Placeholder of the input. Default \"Enter password\" */\n  placeholder?: string\n  /** Id of the input element */\n  id?: string\n  /** Name of the input element */\n  name?: string\n  /** Autocomplete hint of the input. Default \"new-password\" */\n  autoComplete?: string\n  /** Disables the input and the visibility toggle */\n  disabled?: boolean\n  /** Forwarded to the input element */\n  onFocus?: React.FocusEventHandler<HTMLInputElement>\n  /** Forwarded to the input element */\n  onBlur?: React.FocusEventHandler<HTMLInputElement>\n  /** Additional classes merged onto the wrapper */\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nexport const DEFAULT_RULES: PasswordRule[] = [\n  { label: \"At least 8 characters\", test: (value) => value.length >= 8 },\n  { label: \"One uppercase letter\", test: (value) => /[A-Z]/.test(value) },\n  { label: \"One number\", test: (value) => /[0-9]/.test(value) },\n  { label: \"One symbol\", test: (value) => /[^A-Za-z0-9]/.test(value) },\n]\n\nconst SEGMENT_COUNT = 4\n/** Delay between segments when several fill or empty in a single change */\nconst SEGMENT_STAGGER = 0.06\n/** Check mark draw time once a rule flips to satisfied */\nconst CHECK_DRAW_DURATION = 0.2\n/** In-place crossfade when already-filled segments change color */\nconst COLOR_CROSSFADE_DURATION = 0.25\n\nconst SEGMENT_SPRING = { type: \"spring\", stiffness: 500, damping: 32 } as const\nconst LABEL_SPRING = { type: \"spring\", stiffness: 400, damping: 30 } as const\nconst TOGGLE_SPRING = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n// Indexed by score; score 0 shows no fill so its color never renders\nconst SCORE_COLORS = [\n  \"#f43f5e\", // unused (score 0)\n  \"#f43f5e\", // rose-500\n  \"#f97316\", // orange-500\n  \"#fbbf24\", // amber-400\n  \"#10b981\", // emerald-500\n] as const\n\nconst STRENGTH_LABELS = [\"Too weak\", \"Too weak\", \"Fair\", \"Good\", \"Strong\"] as const\n\nconst LABEL_CLASSES = [\n  \"text-neutral-400 dark:text-neutral-500\",\n  \"text-rose-500\",\n  \"text-orange-500\",\n  \"text-amber-500 dark:text-amber-400\",\n  \"text-emerald-500\",\n] as const\n\nconst labelVariants = {\n  enter: (direction: number) => ({ y: direction * 8, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  exit: (direction: number) => ({ y: direction * -8, opacity: 0 }),\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function PasswordStrengthInput({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  rules = DEFAULT_RULES,\n  showChecklist = true,\n  showMeter = true,\n  placeholder = \"Enter password\",\n  id,\n  name,\n  autoComplete = \"new-password\",\n  disabled = false,\n  onFocus,\n  onBlur,\n  className,\n}: PasswordStrengthInputProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalValue, setInternalValue] = useState(defaultValue)\n  const [visible, setVisible] = useState(false)\n\n  const value = valueProp ?? internalValue\n  const autoId = useId()\n  const inputId = id ?? `${autoId}-password`\n  const meterId = `${inputId}-strength`\n\n  const satisfied = rules.map((rule) => rule.test(value))\n  const satisfiedCount = satisfied.filter(Boolean).length\n  const score =\n    rules.length === 0\n      ? 0\n      : Math.ceil((satisfiedCount / rules.length) * SEGMENT_COUNT)\n\n  const strengthLabel = STRENGTH_LABELS[score]\n  const fillColor = SCORE_COLORS[score]\n\n  // The previous committed score decides which segments actually changed,\n  // and the direction drives the label slide and the segment stagger order\n  const prevScoreRef = useRef(score)\n  const prevScore = prevScoreRef.current\n  const direction = score >= prevScore ? 1 : -1\n  useEffect(() => {\n    prevScoreRef.current = score\n  }, [score])\n\n  const handleChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => {\n      const next = event.target.value\n      if (valueProp === undefined) setInternalValue(next)\n      onValueChange?.(next)\n    },\n    [valueProp, onValueChange],\n  )\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-3\", className)}>\n      {/* Input + visibility toggle */}\n      <div className=\"relative\">\n        <input\n          type={visible ? \"text\" : \"password\"}\n          id={inputId}\n          name={name}\n          value={value}\n          onChange={handleChange}\n          onFocus={onFocus}\n          onBlur={onBlur}\n          placeholder={placeholder}\n          autoComplete={autoComplete}\n          disabled={disabled}\n          aria-describedby={showMeter ? meterId : undefined}\n          className={cn(\n            \"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n            \"pr-10\",\n          )}\n        />\n        <button\n          type=\"button\"\n          onClick={() => setVisible((current) => !current)}\n          disabled={disabled}\n          aria-label={visible ? \"Hide password\" : \"Show password\"}\n          aria-pressed={visible}\n          className={cn(\n            \"absolute right-1 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 touch-manipulation select-none items-center justify-center rounded-md text-neutral-500 transition-colors\",\n            \"hover:bg-neutral-100 hover:text-neutral-900 active:bg-neutral-200/70 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100 dark:active:bg-neutral-700/60\",\n            \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n            \"disabled:pointer-events-none disabled:opacity-50\",\n          )}\n        >\n          {/* Fixed 32px button + popLayout keep the swap free of layout shift;\n              the icon scales from its own center */}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={visible ? \"hide\" : \"show\"}\n              className=\"inline-flex\"\n              initial={{ opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.6 }}\n              transition={shouldReduceMotion ? { duration: 0 } : TOGGLE_SPRING}\n            >\n              {visible ? (\n                <EyeOff size={16} aria-hidden=\"true\" />\n              ) : (\n                <Eye size={16} aria-hidden=\"true\" />\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </button>\n      </div>\n\n      {/* Strength meter + label */}\n      {showMeter && (\n        <div id={meterId} role=\"status\" aria-live=\"polite\">\n          <span className=\"sr-only\">Password strength: {strengthLabel}</span>\n          <div className=\"flex items-center gap-3\" aria-hidden=\"true\">\n            <div className=\"flex flex-1 gap-1.5\">\n              {Array.from({ length: SEGMENT_COUNT }).map((_, index) => {\n                const filled = index < score\n                const wasFilled = index < prevScore\n                // Only segments whose fill state changed animate, staggered\n                // outward from the previous score — 3→4 moves just the new\n                // segment with zero delay (no replay of settled segments),\n                // and a decrease empties only the removed ones right-to-left\n                const order =\n                  filled === wasFilled\n                    ? 0\n                    : direction > 0\n                      ? index - Math.min(prevScore, score)\n                      : Math.max(prevScore, score) - 1 - index\n                return (\n                  <div\n                    key={index}\n                    className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800\"\n                  >\n                    <motion.div\n                      className=\"h-full w-full origin-left rounded-full\"\n                      initial={false}\n                      animate={{\n                        scaleX: filled ? 1 : 0,\n                        backgroundColor: fillColor,\n                      }}\n                      transition={\n                        shouldReduceMotion\n                          ? { duration: 0 }\n                          : {\n                              scaleX: {\n                                ...SEGMENT_SPRING,\n                                delay: order * SEGMENT_STAGGER,\n                              },\n                              // Color always crossfades in place, undelayed,\n                              // so settled segments re-tint without moving\n                              backgroundColor: {\n                                duration: COLOR_CROSSFADE_DURATION,\n                              },\n                            }\n                      }\n                    />\n                  </div>\n                )\n              })}\n            </div>\n            <span className=\"relative inline-grid h-4 shrink-0 items-center justify-items-end overflow-hidden\">\n              {/* Invisible spacers pin the column to the widest label so the\n                  meter never shifts while the label crossfades */}\n              {Array.from(new Set(STRENGTH_LABELS)).map((spacer) => (\n                <span\n                  key={spacer}\n                  aria-hidden=\"true\"\n                  className=\"invisible col-start-1 row-start-1 text-xs font-medium\"\n                >\n                  {spacer}\n                </span>\n              ))}\n              <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n                <motion.span\n                  key={strengthLabel}\n                  className={cn(\n                    \"col-start-1 row-start-1 text-xs font-medium transition-colors\",\n                    LABEL_CLASSES[score],\n                  )}\n                  custom={direction}\n                  variants={labelVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={shouldReduceMotion ? { duration: 0 } : LABEL_SPRING}\n                >\n                  {strengthLabel}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Requirements checklist */}\n      {showChecklist && rules.length > 0 && (\n        <ul aria-label=\"Password requirements\" className=\"flex flex-col gap-1.5\">\n          {rules.map((rule, index) => {\n            const done = satisfied[index]\n            return (\n              <li\n                key={rule.label}\n                className={cn(\n                  \"flex items-center gap-2 text-[13px] transition-colors duration-200\",\n                  done\n                    ? \"text-neutral-900 dark:text-neutral-100\"\n                    : \"text-neutral-500 dark:text-neutral-400\",\n                )}\n              >\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  className=\"h-3.5 w-3.5 shrink-0\"\n                  aria-hidden=\"true\"\n                >\n                  <circle\n                    cx=\"8\"\n                    cy=\"8\"\n                    r=\"6.5\"\n                    strokeWidth=\"1.5\"\n                    className={cn(\n                      \"transition-colors duration-200\",\n                      done\n                        ? \"fill-emerald-500 stroke-emerald-500\"\n                        : \"fill-transparent stroke-neutral-300 dark:stroke-neutral-700\",\n                    )}\n                  />\n                  <motion.path\n                    d=\"M4.5 8.5 7 11l4.5-5\"\n                    fill=\"none\"\n                    stroke=\"#fff\"\n                    strokeWidth=\"1.5\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    initial={false}\n                    animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0 }\n                        : {\n                            pathLength: {\n                              duration: CHECK_DRAW_DURATION,\n                              ease: \"easeOut\",\n                            },\n                            opacity: { duration: 0.15 },\n                          }\n                    }\n                  />\n                </svg>\n                <span>\n                  {rule.label}\n                  {done && <span className=\"sr-only\"> satisfied</span>}\n                </span>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/password-strength.tsx"
    }
  ],
  "type": "registry:component"
}
