{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "textarea",
  "title": "Textarea",
  "description": "A textarea that grows with its text on a smooth height spring instead of jumping a line at a time. The character counter rolls its digits and turns warning-coloured at 90%.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-field.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/textarea/textarea.tsx",
      "content": "\"use client\";\n\nimport { Field as BaseField } from \"@base-ui/react/field\";\nimport { animate, motion, useMotionValue, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useLayoutEffect, useRef, useState, type ComponentProps } from \"react\";\nimport { InputGroup, controlClass, controlPadClass, type ControlSize } from \"../_primitives/field/field\";\nimport { NumberRoll } from \"../_primitives/number-roll/number-roll\";\nimport { smooth, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type TextareaProps = Omit<ComponentProps<\"textarea\">, \"className\" | \"rows\"> & {\n  /** Height when empty, in lines. Default 3. */\n  minRows?: number;\n  /** Grow up to this many lines, then scroll. Default 8. */\n  maxRows?: number;\n  /** Called with the new text on every change. */\n  onValueChange?: (value: string) => void;\n  /**\n   * Show a `count / maxLength` counter under the text. Default: on when `maxLength` is set.\n   * Its digits roll, and it turns warning-coloured at 90% of the limit.\n   */\n  showCount?: boolean;\n  /** Mark the textarea invalid outside a `Field` (inside one, the field's validity drives it). */\n  invalid?: boolean;\n  /** Type size, radius and padding, matching Input: sm (13px text), md (default), lg. */\n  size?: ControlSize;\n  /** Classes for the bordered shell. */\n  className?: string;\n  /** Classes for the `<textarea>` itself. */\n  controlClassName?: string;\n};\n\nconst PAD_Y: Record<ControlSize, string> = { sm: \"py-1.5\", md: \"py-2\", lg: \"py-2.5\" };\n\n/**\n * A textarea that grows with its text on a smooth spring instead of jumping a line at a time.\n * The `<textarea>` itself is sized at once (so the caret never scrolls out of view) and a clip\n * around it springs to the new height. Built on Base UI's Field.Control, so it labels,\n * describes and validates itself inside a `Field`.\n */\nexport function Textarea({\n  minRows = 3,\n  maxRows = 8,\n  onValueChange,\n  showCount,\n  invalid,\n  size = \"md\",\n  className = \"\",\n  controlClassName = \"\",\n  maxLength,\n  value,\n  defaultValue,\n  ...props\n}: TextareaProps) {\n  const reduced = useSafeReducedMotion();\n  const ref = useRef<HTMLTextAreaElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const spring = useRef<AnimationPlaybackControls | null>(null);\n  const [typed, setTyped] = useState(() => String(defaultValue ?? \"\").length);\n  const count = value !== undefined ? String(value).length : typed;\n  const counted = showCount ?? maxLength !== undefined;\n\n  const resize = useCallback(\n    (animated: boolean) => {\n      const el = ref.current;\n      if (!el) return;\n      const cs = getComputedStyle(el);\n      const line = parseFloat(cs.lineHeight) || 20;\n      const chrome = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n      const min = minRows * line + chrome;\n      const max = maxRows * line + chrome;\n      el.style.height = \"0px\";\n      const content = el.scrollHeight;\n      const target = Math.round(Math.min(max, Math.max(min, content)));\n      el.style.height = `${target}px`;\n      el.style.overflowY = content > max + 1 ? \"auto\" : \"hidden\";\n\n      const from = height.get();\n      if (from === target) return;\n      spring.current?.stop();\n      if (!animated || reduced || from === \"auto\") height.jump(target);\n      else spring.current = animate(height, target, smooth);\n    },\n    [minRows, maxRows, reduced, height],\n  );\n\n  // Size before first paint; re-measure without animating when the width changes (rewrap).\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    resize(false);\n    let width = el.offsetWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.offsetWidth === width) return;\n      width = el.offsetWidth;\n      resize(false);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [resize]);\n\n  // A controlled value can change without typing (a reset, a suggestion).\n  useLayoutEffect(() => {\n    if (value !== undefined) resize(true);\n  }, [value, resize]);\n\n  return (\n    <InputGroup multiline size={size} invalid={invalid} className={className}>\n      <motion.div style={{ height }} className=\"relative [overflow:clip]\">\n        <BaseField.Control\n          {...(props as ComponentProps<typeof BaseField.Control>)}\n          {...(invalid !== undefined ? { \"aria-invalid\": invalid } : null)}\n          ref={ref}\n          value={value as string | undefined}\n          defaultValue={defaultValue as string | undefined}\n          onValueChange={(v) => {\n            if (value === undefined) setTyped(v.length);\n            onValueChange?.(v);\n            resize(true);\n          }}\n          render={(p) => <textarea {...(p as ComponentProps<\"textarea\">)} rows={minRows} maxLength={maxLength} />}\n          className={`${controlClass} block w-full resize-none ${controlPadClass[size]} ${PAD_Y[size]} [scrollbar-width:thin] ${controlClassName}`}\n        />\n      </motion.div>\n      {counted && maxLength !== undefined && <Counter count={count} max={maxLength} pad={controlPadClass[size]} />}\n    </InputGroup>\n  );\n}\n\n/** `count / max`, digits rolling in the direction of change; warning colour from 90%. */\nfunction Counter({ count, max, pad }: { count: number; max: number; pad: string }) {\n  const near = count >= max * 0.9;\n  return (\n    <div className={`flex justify-end pb-2 select-none ${pad}`}>\n      <span\n        aria-hidden\n        className={`inline-flex items-center text-ui-xs tabular-nums transition-colors duration-150 ease-standard ${\n          near ? \"text-warning-text\" : \"text-text-muted\"\n        }`}\n      >\n        <NumberRoll value={count} format={{ useGrouping: false }} aria-hidden />\n        <span className=\"text-text-muted\">/{max}</span>\n      </span>\n      {/* Announce the thresholds, not every keystroke. */}\n      <span role=\"status\" className=\"sr-only\">\n        {count >= max ? \"Character limit reached\" : near ? `Approaching the ${max} character limit` : \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/textarea/textarea.tsx"
    }
  ],
  "docs": "Import Textarea from components/eased/textarea/textarea; wrap it in Field for a label and messages. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}