组件
文本域 Textarea
用于录入多行长文本的多功能输入控件,支持动态字符计数、最大字数限制与尺寸缩放方向约束。
基础用法
最常见的多行文本录入用法。支持自定义行高与占位提示文案:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/textarea安装基础依赖
pnpm add clsx tailwind-merge复制组件源码到
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> 元素的全部属性,并额外支持以下功能配置:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| showCount | boolean | false | 是否在文本域右下角展示当前字符数统计(若同时指定了 maxLength,则呈现 "已输入数 / 最大限制数" 格式)。 |
| autoSize | boolean | { minRows?: number; maxRows?: number } | false | 根据内容自适应高度,高度变化带平滑过渡;传入对象可限制最少(默认 2 行)与最多行数,超过最多行数后出现滚动条。开启后 `resize` 固定为 none。 |
| resize | "none" | "vertical" | "horizontal" | "both" | "vertical" | 允许用户拖拽调整文本域尺寸的方向。推荐默认 "vertical" 仅允许垂直调整,避免破坏水平页面排版。 |
| rows | number | 4 | 初始呈现的文本行数高度。 |
| maxLength | number | — | 允许输入的最大字符数限制。达到上限后将自动拦截超出部分的输入。 |
| value | string | — | 受控模式下的文本内容。 |
| defaultValue | string | — | 非受控模式下的初始文本内容。 |
| placeholder | string | — | 文本域为空时显示的占位提示文字。 |
| disabled | boolean | false | 是否禁用文本域,禁用后无法获得焦点和输入。 |
| readOnly | boolean | false | 是否设为只读模式(可聚焦、可选中并复制内容,但不可编辑)。 |
| required | boolean | false | 在表单中是否为必填字段。 |
| aria-invalid | boolean | "true" | "false" | false | 标记当前输入是否校验失败。设为 true 时外边框将自动切换为高亮警示破坏色(destructive)。 |
| wrapperClassName | string | — | 应用于外层包裹容器 shell 的额外 CSS 类名。 |
| className | string | — | 应用于内部原生 `<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-ARIArole="textbox"与aria-multiline="true"语义。 - 支持
aria-invalid="true",校验未通过时外层容器自动呈现警示破坏色高亮焦点环。 - 字符计数不会逐字播报,避免每次按键都打断屏幕阅读器;它通过
aria-describedby关联,在聚焦时随说明一起朗读。 - 自动通过
aria-describedby将文本域与右下角的计数器及下方辅助说明关联。
- 原生
- 键盘导航与操作习惯:
- Tab:将焦点移入或移出文本域。
- Enter:在光标所在位置插入自然换行符。
- Ctrl + Enter(Mac 下为 Cmd + Enter):在表单中常用于触发表单快捷提交,无需用户离开键盘点击鼠标。
- 屏幕阅读器支持:
- 推荐始终使用
<label htmlFor="textareaId">显式关联文本域标题。
- 推荐始终使用