{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"textarea","type":"registry:component","title":"Textarea","description":"A text box that eases to the height of what you write, with a count that rolls and turns amber near the limit.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/textarea.tsx","type":"registry:component","content":"// Hairline UI · textarea · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Field, fieldSurface, useFieldIds, useShake } from \"./field\";\nimport { NumberRoll } from \"./number-roll\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * TEXTAREA: grows with what you write\n *\n *   grow     a hidden copy of the text is measured and the box\n *            eases to its height, between minRows and maxRows,\n *            then scrolls; it never snaps a line taller\n *   count    with maxLength, the count rolls as you type and turns\n *            amber in the last tenth, red at the limit\n *   error    same as every field: red line, hint gives way, shake\n * ───────────────────────────────────────────────────────── */\n\nexport interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  minRows?: number;\n  maxRows?: number;\n  /** Show \"12 / 280\" beside the label when maxLength is set. */\n  showCount?: boolean;\n  onValueChange?: (value: string) => void;\n  ref?: React.Ref<HTMLTextAreaElement>;\n  className?: string;\n  textareaClassName?: string;\n}\n\nconst LINE = 21; // 13.5px text at leading 1.55\nconst PAD = 18; // 9px top and bottom\n\nexport function Textarea({\n  label,\n  hint,\n  error,\n  minRows = 3,\n  maxRows = 10,\n  showCount = true,\n  onValueChange,\n  ref,\n  id,\n  className = \"\",\n  textareaClassName = \"\",\n  value,\n  defaultValue,\n  onChange,\n  maxLength,\n  disabled,\n  required,\n  ...props\n}: TextareaProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = useFieldIds(id);\n  const [text, setText] = React.useState(String(defaultValue ?? \"\"));\n  const current = value !== undefined ? String(value) : text;\n  const mirror = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState(minRows * LINE + PAD);\n  const shakeRef = useShake<HTMLDivElement>(!!error);\n\n  React.useLayoutEffect(() => {\n    const el = mirror.current;\n    if (!el) return;\n    const measure = () => {\n      const next = Math.min(maxRows * LINE + PAD, Math.max(minRows * LINE + PAD, el.offsetHeight));\n      setHeight((h) => (h === next ? h : next));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [current, minRows, maxRows]);\n\n  const count = current.length;\n  const near = maxLength ? count >= maxLength * 0.9 : false;\n  const full = maxLength ? count >= maxLength : false;\n  const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <Field\n      id={ids.id}\n      label={label}\n      hint={hint}\n      error={error}\n      required={required}\n      className={className}\n      aside={\n        maxLength && showCount ? (\n          <span className={full ? \"text-red-600 dark:text-red-400\" : near ? \"text-amber-600 dark:text-amber-400\" : \"\"} style={{ transition: \"color 200ms\" }}>\n            <NumberRoll value={count} /> / {maxLength}\n          </span>\n        ) : undefined\n      }\n    >\n      <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled, round: false })} items-stretch`}>\n        {/* The measuring copy: same type and padding, never seen. */}\n        <div\n          ref={mirror}\n          aria-hidden=\"true\"\n          className={`pointer-events-none invisible absolute inset-x-0 top-0 whitespace-pre-wrap break-words px-4 py-[9px] text-[13.5px] leading-[1.55] ${textareaClassName}`}\n        >\n          {current + \"​\"}\n        </div>\n        <textarea\n          ref={ref}\n          id={ids.id}\n          value={value}\n          defaultValue={defaultValue}\n          maxLength={maxLength}\n          disabled={disabled}\n          required={required}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={describedBy}\n          onChange={(e) => {\n            if (value === undefined) setText(e.target.value);\n            onChange?.(e);\n            onValueChange?.(e.target.value);\n          }}\n          style={{ height, transition: reduced ? \"none\" : `height 260ms ${EASE_OUT}` }}\n          className={`block w-full resize-none bg-transparent px-4 py-[9px] text-[13.5px] leading-[1.55] outline-none placeholder:text-muted-foreground/70 ${textareaClassName}`}\n          {...props}\n        />\n      </div>\n    </Field>\n  );\n}\n\nexport default Textarea;\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · textarea · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/textarea. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}