{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"textarea","type":"registry:ui","title":"Textarea","description":"Multi-line field on the sunken well that grows with a spring between min and max rows, with a ring-gauge character counter, a resize handle and a toolbar slot.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion"],"registryDependencies":["@geturui/field"],"files":[{"path":"registry/geturui/ui/textarea.tsx","type":"registry:ui","target":"components/ui/textarea.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport { Field, fieldWellClasses, type FieldSize } from \"@/components/ui/field\";\n\nconst pad: Record<FieldSize, string> = {\n  sm: \"px-2.5 py-1.5 text-[13px] leading-5\",\n  md: \"px-3 py-2 text-sm leading-[22px]\",\n  lg: \"px-3.5 py-2.5 text-[15px] leading-6\",\n};\n\nconst grow: Transition = { type: \"spring\", stiffness: 420, damping: 36, mass: 0.7 };\nconst SHAKE = { x: [0, -3, 3, -2, 2, 0] };\nconst STILL = { x: 0 };\n/** Tallest a manual drag may make the field, in px. */\nconst MANUAL_MAX = 640;\n\nexport interface TextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"size\" | \"rows\"> {\n  /** Text size and padding; matches Input and Button heights for one row. @default \"md\" */\n  size?: FieldSize;\n  label?: React.ReactNode;\n  /** Helper text under the field. Replaced by `error` when both are set. */\n  hint?: React.ReactNode;\n  /** Error message; any truthy value marks the field invalid. */\n  error?: React.ReactNode;\n  /** Right side of the label row. Shown next to the character counter when both exist. */\n  labelAside?: React.ReactNode;\n  /** Grow with the content between minRows and maxRows. @default true */\n  autoGrow?: boolean;\n  /** @default 3 */\n  minRows?: number;\n  /** Rows before the field starts scrolling. @default 8 */\n  maxRows?: number;\n  /** Show the counter in the label row. On by default when maxLength is set. */\n  showCount?: boolean;\n  /** Fraction of maxLength where the counter turns to a warning. @default 0.8 */\n  warnAt?: number;\n  /** Adds a drag (and arrow-key) handle in the corner to make the field taller. */\n  resizable?: boolean;\n  /** Row of controls inside the well, under the text: formatting buttons, a send button. */\n  toolbar?: React.ReactNode;\n  onValueChange?: (value: string) => void;\n  wrapperClassName?: string;\n  wellClassName?: string;\n}\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\n/** Multi-line field on the sunken well: grows with a spring, counts characters toward a limit and can carry a toolbar. */\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(\n  {\n    size = \"md\",\n    label,\n    hint,\n    error,\n    labelAside,\n    autoGrow = true,\n    minRows = 3,\n    maxRows = 8,\n    showCount,\n    warnAt = 0.8,\n    resizable,\n    toolbar,\n    onValueChange,\n    onChange,\n    value,\n    defaultValue,\n    maxLength,\n    disabled,\n    readOnly,\n    required,\n    id,\n    className,\n    wrapperClassName,\n    wellClassName,\n    placeholder,\n    ...props\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const controlId = id ?? `ta${uid}`;\n  const [own, setOwn] = React.useState(() => (defaultValue === undefined ? \"\" : String(defaultValue)));\n  const text = value === undefined ? own : String(value ?? \"\");\n\n  const box = React.useRef<HTMLDivElement>(null);\n  const mirror = React.useRef<HTMLTextAreaElement>(null);\n  const [metrics, setMetrics] = React.useState<{ content: number; min: number; max: number } | null>(null);\n  const [manual, setManual] = React.useState(0);\n  const [dragging, setDragging] = React.useState(false);\n  const drag = React.useRef<{ y: number; h: number } | null>(null);\n\n  const measure = React.useCallback(() => {\n    const m = mirror.current;\n    if (!m) return;\n    const cs = window.getComputedStyle(m);\n    const line = parseFloat(cs.lineHeight) || 20;\n    const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n    const min = Math.round(minRows * line + padY);\n    const max = Math.round(Math.max(minRows, maxRows) * line + padY);\n    const content = m.scrollHeight;\n    setMetrics((prev) => (prev && prev.content === content && prev.min === min && prev.max === max ? prev : { content, min, max }));\n  }, [minRows, maxRows]);\n\n  useIsoLayoutEffect(() => {\n    measure();\n  }, [text, measure, size]);\n\n  React.useEffect(() => {\n    const el = box.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [measure]);\n\n  const auto = metrics ? (autoGrow ? Math.min(Math.max(metrics.content, metrics.min), metrics.max) : metrics.min) : null;\n  const height = auto === null ? null : Math.min(MANUAL_MAX, Math.max(auto, manual));\n  const scrolls = metrics !== null && height !== null && metrics.content > height + 1;\n\n  const count = text.length;\n  const counting = maxLength !== undefined && maxLength > 0 && (showCount ?? true);\n  const ratio = counting ? Math.min(1, count / (maxLength as number)) : 0;\n  const tone: \"idle\" | \"warning\" | \"danger\" = !counting ? \"idle\" : count >= (maxLength as number) ? \"danger\" : ratio >= warnAt ? \"warning\" : \"idle\";\n\n  const onPointerDown = (e: React.PointerEvent<HTMLSpanElement>) => {\n    if (disabled || height === null) return;\n    e.preventDefault();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    drag.current = { y: e.clientY, h: height };\n    setDragging(true);\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLSpanElement>) => {\n    if (!drag.current || !metrics) return;\n    setManual(Math.min(MANUAL_MAX, Math.max(metrics.min, drag.current.h + e.clientY - drag.current.y)));\n  };\n  const endDrag = () => {\n    drag.current = null;\n    setDragging(false);\n  };\n  const onHandleKey = (e: React.KeyboardEvent<HTMLSpanElement>) => {\n    if (!metrics || height === null) return;\n    const step = Math.round((metrics.max - metrics.min) / Math.max(1, maxRows - minRows)) || 22;\n    if (e.key === \"ArrowDown\") setManual(Math.min(MANUAL_MAX, height + step));\n    else if (e.key === \"ArrowUp\") setManual(Math.max(metrics.min, height - step));\n    else if (e.key === \"Home\") setManual(0);\n    else return;\n    e.preventDefault();\n  };\n\n  return (\n    <Field\n      label={label}\n      hint={hint}\n      error={error}\n      required={required}\n      id={controlId}\n      disabled={disabled}\n      className={wrapperClassName}\n      labelAside={\n        counting || labelAside ? (\n          <span className=\"inline-flex items-center gap-3\">\n            {labelAside}\n            {counting && <Counter id={`${controlId}-count`} count={count} max={maxLength as number} ratio={ratio} tone={tone} reduce={Boolean(reduce)} />}\n          </span>\n        ) : undefined\n      }\n    >\n      {({ id: fieldId, describedBy, invalid }) => (\n        <div\n          className={fieldWellClasses({\n            size,\n            invalid,\n            disabled,\n            className: cn(\"relative min-h-0 flex-col items-stretch gap-0 overflow-hidden px-0\", readOnly && \"border-dashed bg-surface\", wellClassName),\n          })}\n        >\n          <motion.div\n            ref={box}\n            className=\"relative\"\n            initial={false}\n            animate={{ height: height ?? \"auto\" }}\n            transition={dragging || reduce ? { duration: 0 } : grow}\n          >\n            <textarea\n              ref={ref}\n              id={fieldId}\n              value={text}\n              rows={height === null ? minRows : undefined}\n              maxLength={maxLength}\n              disabled={disabled}\n              readOnly={readOnly}\n              required={required}\n              placeholder={placeholder}\n              aria-invalid={invalid || undefined}\n              aria-describedby={[describedBy, counting ? `${fieldId}-count` : null].filter(Boolean).join(\" \") || undefined}\n              onChange={(e) => {\n                if (value === undefined) setOwn(e.target.value);\n                onValueChange?.(e.target.value);\n                onChange?.(e);\n              }}\n              className={cn(\n                \"block w-full resize-none bg-transparent text-ink outline-none placeholder:text-ink-subtle disabled:cursor-not-allowed\",\n                height !== null && \"h-full\",\n                scrolls ? \"overflow-y-auto\" : \"overflow-y-hidden\",\n                resizable && \"pr-6\",\n                pad[size],\n                className,\n              )}\n              {...props}\n            />\n            <textarea\n              ref={mirror}\n              aria-hidden\n              tabIndex={-1}\n              readOnly\n              value={text}\n              rows={1}\n              className={cn(\n                \"pointer-events-none invisible absolute inset-x-0 top-0 block h-0 w-full resize-none overflow-hidden\",\n                resizable && \"pr-6\",\n                pad[size],\n                className,\n              )}\n            />\n            {resizable && (\n              <span\n                role=\"separator\"\n                tabIndex={disabled ? -1 : 0}\n                aria-label=\"Resize text area\"\n                aria-orientation=\"horizontal\"\n                aria-valuemin={metrics?.min}\n                aria-valuemax={MANUAL_MAX}\n                aria-valuenow={height === null ? undefined : Math.round(height)}\n                onPointerDown={onPointerDown}\n                onPointerMove={onPointerMove}\n                onPointerUp={endDrag}\n                onPointerCancel={endDrag}\n                onKeyDown={onHandleKey}\n                className={cn(\n                  \"absolute bottom-0.5 right-0.5 grid size-5 cursor-ns-resize touch-none place-items-center rounded-[5px] text-ink-subtle outline-none transition-colors\",\n                  \"hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)]\",\n                  dragging && \"bg-surface-hover text-volt-ink\",\n                )}\n              >\n                <svg aria-hidden viewBox=\"0 0 10 10\" className=\"size-2.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\">\n                  <path d=\"M9 3L3 9M9 6.5L6.5 9\" />\n                </svg>\n              </span>\n            )}\n          </motion.div>\n          {toolbar && <div className=\"flex items-center gap-1 border-t border-border px-1.5 py-1.5\">{toolbar}</div>}\n        </div>\n      )}\n    </Field>\n  );\n});\n\nfunction Counter({\n  id,\n  count,\n  max,\n  ratio,\n  tone,\n  reduce,\n}: {\n  id: string;\n  count: number;\n  max: number;\n  ratio: number;\n  tone: \"idle\" | \"warning\" | \"danger\";\n  reduce: boolean;\n}) {\n  const r = 6;\n  const circ = Math.round(2 * Math.PI * r * 100) / 100;\n  const color = tone === \"danger\" ? \"text-danger\" : tone === \"warning\" ? \"text-warning\" : \"text-ink-subtle\";\n  return (\n    <>\n      <motion.span\n        key={tone === \"danger\" ? \"cap\" : \"ok\"}\n        animate={tone === \"danger\" && !reduce ? SHAKE : STILL}\n        transition={{ duration: 0.36 }}\n        className={cn(\"inline-flex items-center gap-1.5 font-mono tabular-nums transition-colors duration-200\", color)}\n      >\n        <svg aria-hidden viewBox=\"0 0 16 16\" className={cn(\"size-3.5 -rotate-90\", tone === \"idle\" && \"text-volt-ink\")}>\n          <circle cx=\"8\" cy=\"8\" r={r} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" opacity=\"0.2\" />\n          <motion.circle\n            cx=\"8\"\n            cy=\"8\"\n            r={r}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeDasharray={circ}\n            initial={false}\n            animate={{ strokeDashoffset: Math.round(circ * (1 - ratio) * 100) / 100 }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 30 }}\n          />\n        </svg>\n        <span aria-hidden>\n          {count}/{max}\n        </span>\n      </motion.span>\n      <span id={id} className=\"sr-only\">\n        {count} of {max} characters used\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {tone === \"danger\" ? \"Character limit reached\" : tone === \"warning\" ? \"Approaching the character limit\" : \"\"}\n      </span>\n    </>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","textarea","form","auto-grow","character count","comment","multiline"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/textarea"}}