{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/password-field/password-field.tsx","type":"registry:component","target":"@components/arc/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, \"type\"> {\n label: string;\n description?: string;\n /** Translated or reworded toggle names. */\n messages?: Partial;\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 \n \n \n \n ;\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 <>{text}{words.map((word, index) => {index < words.length - 1 ? `${word} ` : word})};\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 {text ? : null};\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(null);\n const [height, setHeight] = useState(\"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 \n \n ;\n}\n\nexport const PasswordField = forwardRef(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
;\n});\n"},{"path":"registry/components/password-field/password-field.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/password-field","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/password-field","markdown":"https://uiarc.dev/components/password-field/markdown","tags":["field","security"]}}