{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-area","type":"registry:ui","title":"Text Area","description":"The multi-line twin of the Text Field – grows with the text, a character count, a button for more room and ⌘ Enter to send","dependencies":[],"files":[{"path":"registry/text-area/text-area.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type ReactNode } from \"react\";\n\n/*\n * Text Area – a single self-contained file.\n * The multi-line twin of the Text Field: a label, a hint the error takes the place of, a character count, a box that\n * grows with the text between minRows and maxRows (then scrolls), an optional button that gives it more room and\n * ⌘/Ctrl+Enter to send. A white field with a soft gray edge; focus only darkens the edge – no ring.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type TextAreaProps = Omit<ComponentPropsWithRef<\"textarea\">, \"rows\"> & {\n  /** Shown above the field and tied to it */\n  label?: ReactNode;\n  /** A quiet line under the field – the error takes its place while there is one */\n  hint?: ReactNode;\n  /** Marks the field invalid and shows this under it, in place of the hint */\n  error?: ReactNode;\n  /** \"Optional\" next to the label */\n  optional?: boolean;\n  /** Shows \"120/2000\" under the field; needs maxLength */\n  counter?: boolean;\n  /** Height when empty, in lines */\n  minRows?: number;\n  /** It grows with the text up to this many lines, then scrolls */\n  maxRows?: number;\n  /** A small button in the corner that gives the field more room (expandedRows) and back */\n  expandable?: boolean;\n  /** Lines while expanded */\n  expandedRows?: number;\n  /** Controls the expanded state from outside */\n  expanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** ⌘/Ctrl+Enter – e.g. save or send without leaving the keyboard */\n  onSubmitShortcut?: (value: string) => void;\n  /** Extra class on the textarea itself; className goes on the wrapper */\n  inputClassName?: string;\n};\n\nexport function TextArea({\n  label,\n  hint,\n  error,\n  optional = false,\n  counter = false,\n  minRows = 3,\n  maxRows = 10,\n  expandable = false,\n  expandedRows = 16,\n  expanded: expandedProp,\n  onExpandedChange,\n  onSubmitShortcut,\n  className,\n  inputClassName,\n  id,\n  value,\n  defaultValue,\n  onChange,\n  onKeyDown,\n  disabled,\n  readOnly,\n  maxLength,\n  ref,\n  \"aria-describedby\": describedBy,\n  ...rest\n}: TextAreaProps) {\n  const autoId = useId();\n  const inputId = id ?? `${autoId}-input`;\n  const messageId = `${autoId}-message`;\n  const counterId = `${autoId}-count`;\n  const area = useRef<HTMLTextAreaElement | null>(null);\n\n  // The current text, for the count and the height – follows value when controlled, the field itself otherwise\n  const [own, setOwn] = useState(String(defaultValue ?? \"\"));\n  const text = value !== undefined && value !== null ? String(value) : own;\n\n  const [ownExpanded, setOwnExpanded] = useState(false);\n  const expanded = expandable && (expandedProp ?? ownExpanded);\n  // The height glides only when the room changes on purpose – never while typing\n  const [gliding, setGliding] = useState(false);\n  const glideTimer = useRef(0);\n  useEffect(() => () => window.clearTimeout(glideTimer.current), []);\n\n  const invalid = Boolean(error) || rest[\"aria-invalid\"] === true || rest[\"aria-invalid\"] === \"true\";\n  const message = shows(error) ? error : shows(hint) ? hint : null;\n  const showCount = counter && typeof maxLength === \"number\";\n  const fallbackName =\n    label == null && !rest[\"aria-label\"] && !rest[\"aria-labelledby\"] && typeof rest.placeholder === \"string\" ? rest.placeholder : undefined;\n\n  // Fits the height to the text: at least minRows (expandedRows while expanded), at most maxRows, then it scrolls\n  const fit = () => {\n    const el = area.current;\n    if (!el) return;\n    const cs = getComputedStyle(el);\n    const line = parseFloat(cs.lineHeight) || 20;\n    const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);\n    const min = (expanded ? Math.max(expandedRows, minRows) : minRows) * line + pad;\n    const max = (expanded ? Math.max(expandedRows, maxRows) : Math.max(maxRows, minRows)) * line + pad;\n    const before = el.style.height;\n    el.style.height = \"auto\";\n    const needed = el.scrollHeight;\n    const next = Math.min(Math.max(needed, min), max);\n    // Back to the old height and a read, so a glide starts from there and not from \"auto\"\n    el.style.height = before;\n    void el.offsetHeight;\n    el.style.height = `${next}px`;\n    el.style.overflowY = needed > max + 1 ? \"auto\" : \"hidden\";\n  };\n\n  useLayoutEffect(fit, [text, expanded, minRows, maxRows, expandedRows]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // A narrower or wider field wraps differently – measure again\n  useEffect(() => {\n    const el = area.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    let width = el.clientWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.clientWidth === width) return;\n      width = el.clientWidth;\n      fit();\n    });\n    // A new text size (a touch screen, a zoom) changes the line height too\n    const mq = window.matchMedia(\"(pointer: coarse)\");\n    mq.addEventListener(\"change\", fit);\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      mq.removeEventListener(\"change\", fit);\n    };\n  }); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // An uncontrolled field follows form.reset(), so the count and the height don't keep the old text\n  useEffect(() => {\n    const form = area.current?.form;\n    if (!form || value !== undefined) return;\n    const onReset = () => window.setTimeout(() => setOwn(area.current?.value ?? \"\"));\n    form.addEventListener(\"reset\", onReset);\n    return () => form.removeEventListener(\"reset\", onReset);\n  }, [value]);\n\n  const toggle = () => {\n    const next = !expanded;\n    setGliding(true);\n    window.clearTimeout(glideTimer.current);\n    glideTimer.current = window.setTimeout(() => setGliding(false), 320);\n    if (expandedProp === undefined) setOwnExpanded(next);\n    onExpandedChange?.(next);\n  };\n\n  return (\n    <div\n      className={[\"ui-ta\", className].filter(Boolean).join(\" \")}\n      data-invalid={invalid || undefined}\n      data-disabled={disabled || undefined}\n      data-readonly={readOnly || undefined}\n      data-expandable={expandable || undefined}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many fields render */}\n      <style href=\"ui-text-area\" precedence=\"default\">\n        {css}\n      </style>\n      {label != null && (\n        <div className=\"ui-ta__head\">\n          <label className=\"ui-ta__label\" htmlFor={inputId}>\n            {label}\n          </label>\n          {optional && <span className=\"ui-ta__optional\">Optional</span>}\n        </div>\n      )}\n      <div className=\"ui-ta__box\">\n        <textarea\n          {...rest}\n          ref={(el) => {\n            area.current = el;\n            if (typeof ref === \"function\") ref(el);\n            else if (ref) ref.current = el;\n          }}\n          id={inputId}\n          className={[\"ui-ta__input\", inputClassName].filter(Boolean).join(\" \")}\n          rows={minRows}\n          value={value}\n          defaultValue={defaultValue}\n          disabled={disabled}\n          readOnly={readOnly}\n          maxLength={maxLength}\n          dir={rest.dir ?? \"auto\"}\n          aria-label={rest[\"aria-label\"] ?? fallbackName}\n          aria-invalid={invalid || undefined}\n          aria-describedby={[describedBy, message ? messageId : null, showCount ? counterId : null].filter(Boolean).join(\" \") || undefined}\n          aria-keyshortcuts={onSubmitShortcut ? \"Meta+Enter Control+Enter\" : rest[\"aria-keyshortcuts\"]}\n          data-gliding={gliding || undefined}\n          onChange={(e) => {\n            if (value === undefined) setOwn(e.target.value);\n            onChange?.(e);\n          }}\n          onKeyDown={(e) => {\n            if (onSubmitShortcut && e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {\n              e.preventDefault();\n              onSubmitShortcut(e.currentTarget.value);\n            }\n            onKeyDown?.(e);\n          }}\n        />\n        {expandable && !disabled && (\n          <button\n            type=\"button\"\n            className=\"ui-ta__expand\"\n            aria-label={expanded ? \"Less room\" : \"More room\"}\n            aria-pressed={expanded}\n            aria-controls={inputId}\n            onClick={toggle}\n          >\n            <span className=\"ui-ta__swap\" key={expanded ? \"less\" : \"more\"}>\n              {expanded ? <CollapseIcon /> : <ExpandIcon />}\n            </span>\n          </button>\n        )}\n      </div>\n      {(message || showCount) && (\n        <div className=\"ui-ta__foot\">\n          {/* The error swaps in where the hint was, so the form below doesn't move */}\n          <p className=\"ui-ta__message\" id={messageId} key={shows(error) ? \"error\" : \"hint\"} dir=\"auto\" aria-live={shows(error) ? \"polite\" : undefined}>\n            {message}\n          </p>\n          {showCount && (\n            <span\n              className=\"ui-ta__count\"\n              id={counterId}\n              data-full={text.length === maxLength || undefined}\n              data-over={text.length > maxLength || undefined}\n            >\n              {text.length.toLocaleString()}/{maxLength.toLocaleString()}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** Text or an element – not true, false, null or an empty string */\nconst shows = (v: ReactNode) => v != null && typeof v !== \"boolean\" && v !== \"\";\n\n/* Icons: Hugeicons Free (MIT), drawn at 16px */\nfunction ExpandIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M16.5 3h4.5v4.5M21 3l-6 6M7.5 21H3v-4.5M3 21l6-6\" />\n    </svg>\n  );\n}\nfunction CollapseIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M14.5 5v4.5H19M14.5 9.5 20 4M9.5 19v-4.5H5M9.5 14.5 4 20\" />\n    </svg>\n  );\n}\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-ta (see Theming in the docs) */\n.ui-ta {\n  --ui-ta-title: var(--aui-ink, #171717);\n  --ui-ta-ink: var(--aui-ink, #171717);\n  --ui-ta-muted: var(--aui-muted, #8f8f8f);\n  --ui-ta-subtle: var(--aui-muted, #8f8f8f);\n  --ui-ta-faint: var(--aui-placeholder, #b5b5b5);\n  --ui-ta-icon: var(--aui-muted, #8f8f8f);\n  --ui-ta-text: #404040;\n  --ui-ta-surface: var(--aui-surface, #ffffff);\n  --ui-ta-line: var(--aui-border, #e6e6e8);\n  --ui-ta-edge: var(--aui-border-strong, #d4d4d8);\n  --ui-ta-edge-focus: var(--aui-border-strong, #b4b4bb);\n  --ui-ta-wash: #f4f4f5;\n  --ui-ta-readonly: #fafafa;\n  --ui-ta-danger: var(--aui-danger, #c4320a);\n  --ui-ta-danger-edge: #ff8a7a;\n  --ui-ta-danger-edge-focus: #f04438;\n  --ui-ta-danger-wash: color-mix(in srgb, var(--aui-red, #f04438) 4%, transparent);\n  --ui-ta-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-ta-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  --fs: 13.5px;\n  --lh: 21px;\n\n  display: flex;\n  flex-direction: column;\n  gap: 7px;\n  min-width: 0;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 20px;\n  color: var(--ui-ta-ink);\n  -webkit-font-smoothing: antialiased;\n}\n/* Touch screens: 16px text, so iOS doesn't zoom in on focus */\n@media (pointer: coarse) {\n  .ui-ta { --fs: 16px; --lh: 24px; }\n}\n\n.ui-ta__head { display: flex; align-items: baseline; gap: 6px; min-width: 0; padding: 0 2px; }\n.ui-ta__label { min-width: 0; font-size: 13px; font-weight: 500; color: var(--ui-ta-title); overflow-wrap: anywhere; cursor: default; }\n.ui-ta__optional { flex: none; font-size: 12px; line-height: 16px; color: var(--ui-ta-subtle); }\n\n/* The box: white with a 1px gray edge; hover and focus only darken that edge */\n.ui-ta__box {\n  position: relative;\n  display: flex;\n  min-width: 0;\n  border-radius: var(--aui-radius, 10px);\n  background: var(--ui-ta-surface);\n  box-shadow: 0 0 0 1px var(--ui-ta-line), 0 1px 2px rgba(0, 0, 0, 0.04);\n  cursor: text;\n  transition: box-shadow 140ms ease, background-color 140ms ease;\n}\n.ui-ta__box:hover { box-shadow: 0 0 0 1px var(--ui-ta-edge), 0 1px 2px rgba(0, 0, 0, 0.04); }\n.ui-ta__box:focus-within { box-shadow: 0 0 0 1px var(--ui-ta-edge-focus), 0 1px 2px rgba(0, 0, 0, 0.04); }\n.ui-ta[data-invalid] .ui-ta__box { background: var(--ui-ta-danger-wash); box-shadow: 0 0 0 1px var(--ui-ta-danger-edge), 0 1px 2px rgba(0, 0, 0, 0.04); animation: ui-ta-nudge 320ms var(--ui-ta-ease); }\n.ui-ta[data-invalid] .ui-ta__box:focus-within { box-shadow: 0 0 0 1px var(--ui-ta-danger-edge-focus), 0 1px 2px rgba(0, 0, 0, 0.04); }\n.ui-ta[data-readonly] .ui-ta__box { background: var(--ui-ta-readonly); box-shadow: 0 0 0 1px var(--ui-ta-line); cursor: default; }\n.ui-ta[data-disabled] .ui-ta__box { background: var(--ui-ta-readonly); box-shadow: 0 0 0 1px var(--ui-ta-line); cursor: not-allowed; }\n.ui-ta[data-disabled] { opacity: 0.55; }\n\n.ui-ta__input {\n  display: block;\n  flex: 1;\n  box-sizing: border-box;\n  width: 100%;\n  min-width: 0;\n  margin: 0;\n  padding: 8px 12px;\n  border: 0;\n  border-radius: inherit;\n  outline: 0;\n  background: transparent;\n  font: inherit;\n  font-size: var(--fs);\n  line-height: var(--lh);\n  color: var(--ui-ta-ink);\n  overflow-wrap: anywhere;\n  resize: none;\n  overflow-y: hidden;\n  scrollbar-width: thin;\n  -webkit-appearance: none;\n  appearance: none;\n}\n/* The corner button keeps clear of the text */\n.ui-ta[data-expandable] .ui-ta__input { padding-inline-end: 36px; }\n.ui-ta__input::placeholder { color: var(--ui-ta-faint); opacity: 1; }\n.ui-ta__input:disabled { cursor: not-allowed; }\n.ui-ta__input[data-gliding] { transition: height 300ms var(--ui-ta-ease); }\n\n/* More room: a small ghost button in the top corner */\n.ui-ta__expand {\n  position: absolute;\n  top: 4px;\n  inset-inline-end: 4px;\n  display: grid;\n  place-items: center;\n  width: 26px;\n  height: 26px;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: 7px;\n  background: transparent;\n  color: var(--ui-ta-icon);\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color 140ms ease, color 140ms ease;\n}\n.ui-ta__expand:hover { background: var(--ui-ta-wash); color: var(--ui-ta-ink); }\n.ui-ta__expand:focus-visible { outline: 2px solid var(--ui-ta-focus); outline-offset: -2px; }\n.ui-ta__expand svg { display: block; }\n.ui-ta__swap { display: grid; animation: ui-ta-swap 280ms var(--ui-ta-ease); }\n@media (pointer: coarse) {\n  .ui-ta__expand { width: 32px; height: 32px; }\n  .ui-ta__expand::before { content: \"\"; position: absolute; inset: -4px; }\n  .ui-ta[data-expandable] .ui-ta__input { padding-inline-end: 42px; }\n}\n\n/* Under the field: the hint, or the error in its place, and the count */\n.ui-ta__foot { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding: 0 2px; }\n.ui-ta__message {\n  flex: 1;\n  min-width: 0;\n  margin: 0;\n  font-size: 12.5px;\n  line-height: 17px;\n  color: var(--ui-ta-subtle);\n  overflow-wrap: anywhere;\n  animation: ui-ta-message 260ms var(--ui-ta-ease);\n}\n.ui-ta[data-invalid] .ui-ta__message { color: var(--ui-ta-danger); }\n.ui-ta__count { flex: none; margin-left: auto; font-size: 12px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--ui-ta-faint); transition: color 140ms ease; }\n.ui-ta__count[data-full] { color: var(--ui-ta-text); }\n.ui-ta__count[data-over] { color: var(--ui-ta-danger); }\n\n@keyframes ui-ta-nudge {\n  30% { transform: translateX(-3px); }\n  65% { transform: translateX(2px); }\n}\n@keyframes ui-ta-message {\n  from { opacity: 0; transform: translateY(-3px); filter: blur(2px); }\n}\n@keyframes ui-ta-swap {\n  from { opacity: 0; transform: scale(0.6); filter: blur(2px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-ta *, .ui-ta[data-invalid] .ui-ta__box { animation-duration: 1ms !important; }\n  .ui-ta__box, .ui-ta__input[data-gliding] { transition-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-ta {\n  --ui-ta-title: var(--aui-ink, #ededed);\n  --ui-ta-ink: var(--aui-ink, #ededed);\n  --ui-ta-text: #c8c8cc;\n  --ui-ta-muted: var(--aui-muted, #8b8b92);\n  --ui-ta-subtle: var(--aui-muted, #8b8b92);\n  --ui-ta-faint: var(--aui-placeholder, #5c5c63);\n  --ui-ta-icon: var(--aui-muted, #8b8b92);\n  --ui-ta-surface: var(--aui-surface, #19191b);\n  --ui-ta-line: var(--aui-border, #2a2a2e);\n  --ui-ta-edge: var(--aui-border-strong, #3a3a40);\n  --ui-ta-edge-focus: var(--aui-border-strong, #4e4e55);\n  --ui-ta-wash: #26262a;\n  --ui-ta-readonly: #151517;\n  --ui-ta-danger: var(--aui-danger, #ff8a7a);\n  --ui-ta-danger-edge: color-mix(in srgb, var(--aui-red, #ff6b5f) 45%, transparent);\n  --ui-ta-danger-edge-focus: rgba(255, 99, 105, 0.75);\n  --ui-ta-danger-wash: color-mix(in srgb, var(--aui-red, #ff6b5f) 6%, transparent);\n  --ui-ta-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}