{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-field",
  "type": "registry:ui",
  "title": "Password field",
  "description": "Capture sensitive text with a visible reveal control.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/password-field/password-field.tsx",
      "type": "registry:component",
      "target": "@components/xzero/password-field/password-field.tsx",
      "content": "\"use client\";\nimport { forwardRef, useEffect, useId, useRef, useState } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./password-field.module.css\";\n/** The toggle's names. Both have English defaults; pass the ones you translate. */\nexport interface PasswordFieldMessages { showPassword: string; hidePassword: string }\nexport const defaultPasswordFieldMessages: PasswordFieldMessages = { showPassword: \"Show password\", hidePassword: \"Hide password\" };\nexport interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, \"type\"> {\n  label: string;\n  description?: string;\n  /** Translated or reworded toggle names. */\n  messages?: Partial<PasswordFieldMessages>;\n}\n/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */\nfunction EyeMorph({ slashed }: { slashed: boolean }) {\n  const reduced = useReducedMotion(); const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };\n  const transition = reduced ? { duration: 0 } : { pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] }, opacity: { duration: motionTokens.duration.instant } };\n  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\">\n    <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=\"M2 2l20 20\" stroke=\"black\" strokeWidth={5} initial={false} animate={slash} transition={transition} /></mask>\n    <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>\n    <motion.path d=\"M2 2l20 20\" initial={false} animate={slash} transition={transition} />\n  </svg>;\n}\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden=\"true\"><AnimatePresence initial={false} mode=\"popLayout\">{words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word}\n    initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n    exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.exit] } }}\n    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{index < words.length - 1 ? `${word} ` : word}</motion.span>)}</AnimatePresence></span></>;\n}\n\n/** Helper and error copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({ id, text, className, alert }: { id?: string; text?: string; className?: string; alert?: boolean }) {\n  return <AnimatePresence initial={false}>{text ? <MessageRow key=\"message\" id={id} text={text} className={className} alert={alert} /> : null}</AnimatePresence>;\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({ id, text, className, alert }: { id?: string; text: string; className?: string; alert?: boolean }) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) => { if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.messageSlot} 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 } }}>\n    <motion.span ref={copyRef} id={id} className={className} role={alert ? \"alert\" : undefined} initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ y: 0, filter: \"blur(0px)\" }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}><MotionText text={text} /></motion.span>\n  </motion.span>;\n}\n\nexport const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(function PasswordField({ label, description, messages: messagesProp, id, className, ...props }, ref) {\n  const messages = { ...defaultPasswordFieldMessages, ...messagesProp };\n  const generated = useId(); const controlId = id ?? generated; const [visible, setVisible] = useState(false); const [toggled, setToggled] = useState(false);\n  const hintId = description ? `${controlId}-description` : undefined;\n  // data-reveal only appears after the first toggle, so the value resolves on each change but never on mount.\n  return <div className={styles.field}><label htmlFor={controlId}>{label}</label><div className={styles.shell}><input {...props} ref={ref} id={controlId} type={visible ? \"text\" : \"password\"} data-reveal={toggled ? (visible ? \"shown\" : \"hidden\") : undefined} aria-describedby={[props[\"aria-describedby\"], hintId].filter(Boolean).join(\" \") || undefined} className={[styles.input, className].filter(Boolean).join(\" \")} /><button type=\"button\" onClick={() => { setVisible(current => !current); setToggled(true); }} aria-label={visible ? messages.hidePassword : messages.showPassword} aria-pressed={visible}><EyeMorph slashed={visible} /></button></div><FieldMessage id={hintId} text={description} className={styles.hint} /></div>;\n});\n"
    },
    {
      "path": "registry/components/password-field/password-field.module.css",
      "type": "registry:component",
      "target": "@components/xzero/password-field/password-field.module.css",
      "content": "/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */\n.field { display: grid; min-width: 0; }\n.field label { margin-bottom: var(--space-2); font-size: var(--text-sm); font-weight: 500; }\n/* Focus shows as a darker border only; the shell never changes size. The toggle sits with an even inset on top, bottom and right. */\n.shell { display: flex; min-height: var(--control-height-md); align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 calc((var(--control-height-md) - 2px - 30px) / 2) 0 var(--space-3); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .shell:hover:not(:focus-within) { border-color: var(--foreground); } }\n.shell:focus-within { border-color: var(--foreground); }\n.shell:has(.input[aria-invalid=\"true\"]) { border-color: var(--danger); }\n.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: 0; }\n.input::placeholder { color: var(--text-muted); }\n/* Switching between dots and characters resolves in place instead of snapping. Swapping the keyframe name restarts it. */\n.input[data-reveal=\"shown\"] { animation: resolve-shown var(--duration-standard) var(--ease-enter); }\n.input[data-reveal=\"hidden\"] { animation: resolve-hidden var(--duration-standard) var(--ease-enter); }\n@keyframes resolve-shown { from { opacity: .35; filter: blur(2px); } }\n@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }\n.shell button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .shell button:hover { background: var(--surface-muted); color: var(--foreground); } }\n.shell button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform 100ms var(--ease-standard); }\n.shell button[aria-pressed=\"true\"] { color: var(--foreground); }\n/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */\n.messageSlot { display: block; overflow: hidden; }\n.hint { display: block; padding-top: var(--space-2); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */\n.words { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .shell, .shell button { transition: none; } .shell button:active { transform: none; } .input[data-reveal] { animation: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/password-field",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "security"
    ],
    "docs": "https://ui.xzero.co.uk/components/password-field",
    "markdown": "https://ui.xzero.co.uk/components/password-field/markdown"
  }
}