{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "textarea",
  "type": "registry:ui",
  "title": "Textarea",
  "description": "A multiline field for notes, descriptions, and longer text.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/textarea/textarea.tsx",
      "type": "registry:component",
      "target": "@components/xzero/textarea/textarea.tsx",
      "content": "\"use client\";\nimport { forwardRef, useEffect, useId, useRef, useState } from \"react\";\nimport type { TextareaHTMLAttributes } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./textarea.module.css\";\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> { label: string; description?: string; error?: string }\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.fast, 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  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 Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ label, description, error, id, className, ...props }, ref) {\n  const generatedId = useId(); const controlId = id ?? generatedId;\n  const hintId = description ? `${controlId}-description` : undefined; const errorId = error ? `${controlId}-error` : undefined;\n  return <div className={styles.field}><label htmlFor={controlId}>{label}</label><textarea {...props} id={controlId} ref={ref} className={[styles.control, className].filter(Boolean).join(\" \")} aria-invalid={error ? true : props[\"aria-invalid\"]} aria-describedby={[props[\"aria-describedby\"], hintId, errorId].filter(Boolean).join(\" \") || undefined}/><FieldMessage id={hintId} text={description} className={styles.hint} /><FieldMessage id={errorId} text={error} className={styles.error} alert /></div>;\n});\n"
    },
    {
      "path": "registry/components/textarea/textarea.module.css",
      "type": "registry:component",
      "target": "@components/xzero/textarea/textarea.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/* Same focus language as Input: the ring contracts onto the border while it fades in, and the box never changes size. */\n.control { width: 100%; min-height: 110px; resize: vertical; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: none; box-shadow: 0 0 0 6px transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.control::placeholder { color: var(--text-muted); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not(:disabled, [aria-invalid=\"true\"]) { border-color: var(--foreground); } }\n.control:focus-visible { border-color: var(--foreground); box-shadow: 0 0 0 3px var(--focus-ring); outline: 2px solid transparent; outline-offset: 2px; }\n.control[aria-invalid=\"true\"] { border-color: var(--danger); }\n.control:disabled { cursor: not-allowed; background: var(--surface-muted); opacity: .6; }\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, .error { display: block; padding-top: var(--space-2); font-size: var(--text-xs); line-height: var(--leading-body); }\n.hint { 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) { .control { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/textarea",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "form"
    ],
    "docs": "https://ui.xzero.co.uk/components/textarea",
    "markdown": "https://ui.xzero.co.uk/components/textarea/markdown"
  }
}