{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n  label: string;\n  description?: string;\n  error?: string;\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) => ({\n    opacity: 0,\n    y: `${direction * 0.6}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.6}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\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)\n    setShown({\n      word,\n      direction: parseFloat(word.replace(/,/g, \"\")) < parseFloat(shown.word.replace(/,/g, \"\")) ? -1 : 1,\n    });\n  const characters = word.split(\"\");\n  const transition = reduced\n    ? { duration: 0 }\n    : {\n        y: motionTokens.spring.snappy,\n        width: motionTokens.spring.morph,\n        opacity: { duration: motionTokens.duration.fast },\n        filter: { duration: motionTokens.duration.fast },\n      };\n  return (\n    <AnimatePresence initial={false}>\n      {characters.map((character, index) => (\n        <motion.span\n          key={characters.length - index}\n          className=\"relative inline-flex justify-center tabular-nums [&>span]:inline-block\"\n          initial={{ width: 0, opacity: 0 }}\n          animate={{ width: \"auto\", opacity: 1 }}\n          exit={{ width: 0, opacity: 0 }}\n          transition={transition}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={shown.direction}>\n            <motion.span\n              key={character}\n              custom={shown.direction}\n              variants={digit}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={transition}\n            >\n              {character}\n            </motion.span>\n          </AnimatePresence>\n        </motion.span>\n      ))}\n    </AnimatePresence>\n  );\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 (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${isNumber(word) ? \"#\" : word}`}\n              className=\"inline-block whitespace-pre\"\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: \"-0.35em\",\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {isNumber(word) ? <RollingNumber word={word} /> : word}\n              {index < words.length - 1 ? \" \" : null}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\n/** Helper and error copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text?: string;\n  className?: string;\n  alert?: boolean;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {text ? <MessageRow key=\"message\" id={id} text={text} className={className} alert={alert} /> : null}\n    </AnimatePresence>\n  );\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text: string;\n  className?: string;\n  alert?: boolean;\n}) {\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]) => {\n      if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <motion.span\n      className=\"block overflow-hidden\"\n      initial={reduced ? false : { height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <motion.span\n        ref={copyRef}\n        id={id}\n        className={className}\n        role={alert ? \"alert\" : undefined}\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n      >\n        <MotionText text={text} />\n      </motion.span>\n    </motion.span>\n  );\n}\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n  { label, description, error, id, className, ...props },\n  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  // 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\n  // instead. Same focus language as Input: the ring contracts onto the border while it fades in, and the box never changes size.\n  return (\n    <div className=\"grid min-w-0\">\n      <label className=\"mb-2 text-sm/[inherit] font-medium\" htmlFor={controlId}>\n        {label}\n      </label>\n      <textarea\n        {...props}\n        id={controlId}\n        ref={ref}\n        className={cn(\n          \"min-h-[110px] w-full resize-y rounded-control border border-line-strong bg-surface p-3 text-sm leading-body text-ink shadow-[0_0_0_6px_transparent] outline-none [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)] placeholder:text-ink-3 hover:not-disabled:not-aria-[invalid=true]:border-ink focus-visible:border-ink focus-visible:shadow-[0_0_0_3px_var(--focus-ring)] focus-visible:outline-hidden aria-[invalid=true]:border-red disabled:cursor-not-allowed disabled:bg-hover disabled:opacity-60 motion-reduce:transition-none\",\n          className,\n        )}\n        aria-invalid={error ? true : props[\"aria-invalid\"]}\n        aria-describedby={[props[\"aria-describedby\"], hintId, errorId].filter(Boolean).join(\" \") || undefined}\n      />\n      <FieldMessage id={hintId} text={description} className=\"block pt-2 text-xs leading-body text-ink-3\" />\n      <FieldMessage id={errorId} text={error} className=\"block pt-2 text-xs leading-body text-red\" alert />\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "@components/iris/textarea/textarea.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/textarea",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "Textarea",
    "tags": [
      "field",
      "form"
    ],
    "docs": "https://irisui.dev/components/textarea"
  }
}
