wui
组件

文本域 Textarea

用于录入多行长文本的多功能输入控件,支持动态字符计数、最大字数限制与尺寸缩放方向约束。

基础用法

最常见的多行文本录入用法。支持自定义行高与占位提示文案:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/textarea
安装基础依赖
pnpm add clsx tailwind-merge
复制组件源码到 components/ui/textarea.tsx
components/ui/textarea.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

const resizeClasses = {
  none: "resize-none",
  vertical: "resize-y",
  horizontal: "resize-x",
  both: "resize",
} as const

export interface TextareaAutoSize {
  /** 自适应高度时的最少行数。@default 2 */
  minRows?: number
  /** 自适应高度时的最多行数,超出后出现滚动条。 */
  maxRows?: number
}

export interface TextareaProps extends Omit<
  React.ComponentProps<"textarea">,
  "children"
> {
  /** 是否在右下角显示当前字符数。@default false */
  showCount?: boolean
  /** 文本域允许调整尺寸的方向;开启 `autoSize` 时固定为 `"none"`。@default "vertical" */
  resize?: keyof typeof resizeClasses
  /** 根据内容平滑地自适应高度;传入对象可限制最少与最多行数。@default false */
  autoSize?: boolean | TextareaAutoSize
  /** 应用于最外层容器的额外样式类。 */
  wrapperClassName?: string
}

/** A multiline text field with optional smooth auto-sizing, character count and resize control. */
function Textarea({
  className,
  wrapperClassName,
  showCount = false,
  resize = "vertical",
  autoSize = false,
  value,
  defaultValue,
  maxLength,
  onChange,
  style,
  "aria-describedby": ariaDescribedBy,
  ...props
}: TextareaProps) {
  const counterId = React.useId()
  const measureRef = React.useRef<HTMLTextAreaElement>(null)
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "")
  const [height, setHeight] = React.useState<number>()
  const [overflowing, setOverflowing] = React.useState(false)
  const currentValue = String(value ?? internalValue)
  const characterCount = currentValue.length
  const autoSizeEnabled = Boolean(autoSize)
  const autoSizeConfig = typeof autoSize === "object" ? autoSize : autoSize ? {} : null
  const minRows = autoSizeConfig?.minRows ?? 2
  const maxRows = autoSizeConfig?.maxRows
  const countRatio = typeof maxLength === "number" && maxLength > 0 ? characterCount / maxLength : 0

  React.useLayoutEffect(() => {
    const measure = measureRef.current
    if (!autoSizeEnabled || !measure) return

    function sync() {
      if (!measure) return
      const styles = window.getComputedStyle(measure)
      const lineHeight = Number.parseFloat(styles.lineHeight)
      const chrome =
        Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom)
      const minHeight = lineHeight * minRows + chrome
      const maxHeight = maxRows ? lineHeight * maxRows + chrome : Infinity
      const contentHeight = measure.scrollHeight
      setHeight(Math.min(Math.max(contentHeight, minHeight), maxHeight))
      setOverflowing(contentHeight > maxHeight)
    }

    sync()
    const observer = new ResizeObserver(sync)
    observer.observe(measure)
    return () => observer.disconnect()
  }, [autoSizeEnabled, currentValue, minRows, maxRows, showCount])

  const textareaClassName = cn(
    "placeholder:text-muted-foreground/75 block w-full rounded-[inherit] bg-transparent px-3 py-2.5 text-sm leading-6 outline-none disabled:cursor-not-allowed",
    showCount && "pb-7 pr-16",
    className
  )

  return (
    <div
      data-slot="textarea-shell"
      className={cn(
        "border-input bg-background shadow-xs focus-within:border-ring focus-within:ring-ring/30 has-[textarea[aria-invalid=true]]:border-destructive has-[textarea[aria-invalid=true]]:ring-destructive/20 relative w-full rounded-md border transition-[border-color,box-shadow] duration-200 ease-out focus-within:ring-[3px] has-[textarea:disabled]:cursor-not-allowed has-[textarea:disabled]:opacity-50 has-[textarea[aria-invalid=true]]:ring-[3px] motion-reduce:transition-none",
        wrapperClassName
      )}
    >
      <textarea
        data-slot="textarea"
        value={value}
        defaultValue={defaultValue}
        maxLength={maxLength}
        aria-describedby={
          showCount
            ? [ariaDescribedBy, counterId].filter(Boolean).join(" ")
            : ariaDescribedBy
        }
        onChange={(event) => {
          if (value === undefined) setInternalValue(event.target.value)
          onChange?.(event)
        }}
        style={autoSizeConfig ? { ...style, height, overflowY: overflowing ? "auto" : "hidden" } : style}
        className={cn(
          textareaClassName,
          autoSizeConfig
            ? "resize-none transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
            : cn("min-h-24", resizeClasses[resize])
        )}
        {...props}
      />
      {autoSizeConfig ? (
        <textarea
          ref={measureRef}
          aria-hidden="true"
          tabIndex={-1}
          readOnly
          rows={1}
          value={currentValue || " "}
          className={cn(
            textareaClassName,
            "pointer-events-none invisible absolute inset-x-0 top-0 h-0 resize-none overflow-hidden"
          )}
        />
      ) : null}
      {showCount ? (
        <span
          id={counterId}
          data-slot="textarea-count"
          data-state={countRatio >= 1 ? "full" : countRatio >= 0.9 ? "near" : undefined}
          className="text-muted-foreground data-[state=near]:text-warning data-[state=full]:text-destructive pointer-events-none absolute bottom-2 right-3 text-xs tabular-nums transition-colors duration-200"
        >
          {characterCount}
          {typeof maxLength === "number" ? ` / ${maxLength}` : null}
        </span>
      ) : null}
    </div>
  )
}

export { Textarea }

属性 Props

Textarea 继承原生 HTML <textarea> 元素的全部属性,并额外支持以下功能配置:

属性类型默认值说明
showCountbooleanfalse是否在文本域右下角展示当前字符数统计(若同时指定了 maxLength,则呈现 "已输入数 / 最大限制数" 格式)。
autoSizeboolean | { minRows?: number; maxRows?: number }false根据内容自适应高度,高度变化带平滑过渡;传入对象可限制最少(默认 2 行)与最多行数,超过最多行数后出现滚动条。开启后 `resize` 固定为 none。
resize"none" | "vertical" | "horizontal" | "both""vertical"允许用户拖拽调整文本域尺寸的方向。推荐默认 "vertical" 仅允许垂直调整,避免破坏水平页面排版。
rowsnumber4初始呈现的文本行数高度。
maxLengthnumber—允许输入的最大字符数限制。达到上限后将自动拦截超出部分的输入。
valuestring—受控模式下的文本内容。
defaultValuestring—非受控模式下的初始文本内容。
placeholderstring—文本域为空时显示的占位提示文字。
disabledbooleanfalse是否禁用文本域,禁用后无法获得焦点和输入。
readOnlybooleanfalse是否设为只读模式(可聚焦、可选中并复制内容,但不可编辑)。
requiredbooleanfalse在表单中是否为必填字段。
aria-invalidboolean | "true" | "false"false标记当前输入是否校验失败。设为 true 时外边框将自动切换为高亮警示破坏色(destructive)。
wrapperClassNamestring—应用于外层包裹容器 shell 的额外 CSS 类名。
classNamestring—应用于内部原生 `<textarea>` 元素的额外 CSS 类名。

事件 Events

属性类型默认值说明
onChange(event: React.ChangeEvent<HTMLTextAreaElement>) => void—文本域内容发生改变时触发的回调函数,同时驱动字符计数的实时更新。
onFocus(event: React.FocusEvent<HTMLTextAreaElement>) => void—文本域获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLTextAreaElement>) => void—文本域失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) => void—在文本域获得焦点时按下键盘按键触发(如监听 Ctrl/Cmd + Enter 快捷提交)。
onScroll(event: React.UIEvent<HTMLTextAreaElement>) => void—在文本域内部滚动时触发。

使用场景与设计规范

Textarea 用于捕获用户录入的多行纯文本内容(例如问题反馈、工单详情、用户简介、评论发布、更新日志等)。

文本录入组件选型对比

控件类型典型行数格式支持适用场景
Input单行纯文本姓名、邮箱、密码、手机号、搜索框
Textarea多行(可自然换行)纯文本备注、描述、工单正文、多行地址
Rich Text Editor多行(结构化文档)富文本(加粗、列表、图片、表格)文章创作、知识库编辑、邮件正文撰写

设计最佳实践

  • 约束缩放方向 (Resize):默认推荐使用 resize="vertical",防止用户横向拉伸破坏页面的栅格布局与容器宽度;在紧凑弹窗或固定卡片中,推荐使用 resize="none"。
  • 配置合理的初始高度与极值:通过 rows 或 Tailwind 的 min-h-* / max-h-* 为文本域设定舒适的初始高度与拉伸上下限,避免拉得过小导致无法阅读或拉得过大超出视口。
  • 字数统计与防遮挡:当开启 showCount 时,组件内部会自动在右下角增加安全内边距(Padding),确保长文本滚动到底部时不会被计数字符遮挡。
  • 明确关联 Label:使用 <label htmlFor="..."> 关联文本域 ID,确保辅助技术与屏幕阅读器能够正确朗读字段名称。

场景示例

受控模式

通过 value 与 onChange 实现受控管理,方便随时重置或与上层业务状态进行同步:

Loading…
const [value, setValue] = React.useState("")

return (
  <Textarea
    value={value}
    onChange={(e) => setValue(e.target.value)}
    showCount
    maxLength={200}
  />
)

字符计数与最大字数限制

同时开启 showCount 与 maxLength,右下角将实时呈现当前已输入字符数与最大允许字数;接近上限(90%)时计数变为警告色,达到上限时变为危险色:

Loading…

自适应高度 (AutoSize)

设置 autoSize 后,文本域会随内容增减平滑地调整高度,适合评论框、聊天输入等场景。通过 minRows 与 maxRows 约束高度范围:

Loading…
<Textarea autoSize={{ minRows: 1, maxRows: 6 }} />

缩放方向控制 (Resize)

根据界面布局需求灵活配置缩放策略(固定不可调 vs 仅垂直可调):

Loading…
  • resize="none":完全禁止用户手动调整尺寸,保持界面排版高度确定性。
  • resize="vertical":仅允许上下拖拽调整高度,同时建议配合 min-h-* 与 max-h-* 约束极值范围。

状态展示与校验错误

支持标准状态、校验错误状态 (aria-invalid="true")、只读模式 (readOnly) 及禁用状态 (disabled):

Loading…

完整反馈提交表单

在真实业务工单与建议反馈流程中,结合必填校验、字数限制、动态错误提示与快捷键提交体验:

Loading…

无障碍与交互 Accessibility

  • ARIA 规范与无障碍属性:
    • 原生 <textarea> 具备 WAI-ARIA role="textbox" 与 aria-multiline="true" 语义。
    • 支持 aria-invalid="true",校验未通过时外层容器自动呈现警示破坏色高亮焦点环。
    • 字符计数不会逐字播报,避免每次按键都打断屏幕阅读器;它通过 aria-describedby 关联,在聚焦时随说明一起朗读。
    • 自动通过 aria-describedby 将文本域与右下角的计数器及下方辅助说明关联。
  • 键盘导航与操作习惯:
    • Tab:将焦点移入或移出文本域。
    • Enter:在光标所在位置插入自然换行符。
    • Ctrl + Enter(Mac 下为 Cmd + Enter):在表单中常用于触发表单快捷提交,无需用户离开键盘点击鼠标。
  • 屏幕阅读器支持:
    • 推荐始终使用 <label htmlFor="textareaId"> 显式关联文本域标题。