{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input",
  "type": "registry:ui",
  "title": "Input",
  "description": "A single line field with clear labels and useful states.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/input/input.tsx",
      "type": "registry:component",
      "target": "@components/xzero/input/input.tsx",
      "content": "\"use client\";\n\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 \"./input.module.css\";\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n  label: string;\n  description?: string;\n  error?: string;\n}\n\n/* Digits roll up when a number grows and down when it shrinks; `custom` hands the latest direction to digits already leaving. */\nconst digit = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.6}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.6}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),\n};\nconst isNumber = (word: string) => /^\\d[\\d.,/:]*%?$/.test(word);\n\n/** A count in the copy, such as \"12 characters\", rolls only the digits that changed; a gained or lost digit opens or closes its width. */\nfunction RollingNumber({ word }: { word: string }) {\n  const reduced = useReducedMotion();\n  const [shown, setShown] = useState({ word, direction: 1 });\n  if (shown.word !== word) setShown({ word, direction: parseFloat(word.replace(/,/g, \"\")) < parseFloat(shown.word.replace(/,/g, \"\")) ? -1 : 1 });\n  const characters = word.split(\"\");\n  const transition = reduced ? { duration: 0 } : { y: motionTokens.spring.snappy, width: motionTokens.spring.morph, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } };\n  return <AnimatePresence initial={false}>{characters.map((character, index) => <motion.span key={characters.length - index} className={styles.column} initial={{ width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={transition}>\n    <AnimatePresence initial={false} mode=\"popLayout\" custom={shown.direction}><motion.span key={character} custom={shown.direction} variants={digit} initial=\"enter\" animate=\"center\" exit=\"exit\" transition={transition}>{character}</motion.span></AnimatePresence>\n  </motion.span>)}</AnimatePresence>;\n}\n\n/** Changed words rise in and unblur while unchanged words hold still, and numbers roll. 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}:${isNumber(word) ? \"#\" : 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.standard] } }}\n    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{isNumber(word) ? <RollingNumber word={word} /> : word}{index < words.length - 1 ? \" \" : null}</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  // Reduced motion mounts the row at full height: a zero-duration open would still paint one collapsed frame. The presence starts\n  // with initial={false}, so rows present at hydration render the same on server and client either way.\n  return <motion.span className={styles.messageSlot} initial={reduced ? false : { 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 Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  { label, description, error, id, className, ...props }, ref,\n) {\n  const generatedId = useId();\n  const controlId = id ?? generatedId;\n  const hintId = description ? `${controlId}-description` : undefined;\n  const errorId = error ? `${controlId}-error` : undefined;\n  const describedBy = [props[\"aria-describedby\"], hintId, errorId].filter(Boolean).join(\" \") || undefined;\n  return <div className={styles.field}>\n    <label className={styles.label} htmlFor={controlId}>{label}</label>\n    <input {...props} id={controlId} ref={ref} className={[styles.input, className].filter(Boolean).join(\" \")} aria-invalid={error ? true : props[\"aria-invalid\"]} aria-describedby={describedBy}/>\n    <FieldMessage id={hintId} text={description} className={styles.description} />\n    <FieldMessage id={errorId} text={error} className={styles.error} alert />\n  </div>;\n});\n\nInput.displayName = \"Input\";\n"
    },
    {
      "path": "registry/components/input/input.module.css",
      "type": "registry:component",
      "target": "@components/xzero/input/input.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.label { margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n/* Focus answers with the border colour alone (no rings or halos); the field itself never changes size. */\n.input { width: 100%; min-height: var(--control-height-md); padding: 0 var(--space-3); border: 1px solid var(--border-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.input::placeholder { color: var(--text-muted); }\n@media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [aria-invalid=\"true\"]) { border-color: var(--foreground); } }\n.input:focus-visible { border-color: var(--foreground); }\n.input[aria-invalid=\"true\"] { border-color: var(--danger); }\n.input:disabled { cursor: not-allowed; opacity: .5; background: var(--surface-muted); }\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.description, .error { display: block; padding-top: var(--space-2); font-size: var(--text-xs); line-height: var(--leading-body); }\n.description { color: var(--text-muted); } .error { color: var(--danger); }\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/* One column per character of a count. Tabular digits keep columns equal, so only a gained or lost digit changes the width. */\n.column { position: relative; display: inline-flex; justify-content: center; font-variant-numeric: tabular-nums; }\n.column > span { display: inline-block; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .input { transition-duration: .01ms; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/input",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "form"
    ],
    "docs": "https://ui.xzero.co.uk/components/input",
    "markdown": "https://ui.xzero.co.uk/components/input/markdown"
  }
}