组件
输入框 Input
用于录入单行文本、邮箱、密码及搜索词的基础表单控件,支持前后置内容、浮动标签、下划线风格与多重校验状态。
基础用法
最常用的单行搜索与文本输入框。开启 allowClear 后,输入内容时清空按钮会以缩放淡入的方式出现,清空后淡出:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/input安装基础依赖
pnpm add class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/input.tsx"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const inputShellVariants = cva(
"group relative flex w-full items-center transition-[border-color,box-shadow,background-color] duration-200 ease-out has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 has-[input[aria-invalid=true]]:border-destructive motion-reduce:transition-none",
{
variants: {
variant: {
default:
"rounded-md border border-input bg-background shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30 has-[input[aria-invalid=true]]:ring-[3px] has-[input[aria-invalid=true]]:ring-destructive/20",
underline:
"border-b border-input bg-transparent focus-within:border-foreground has-[input[aria-invalid=true]]:border-destructive",
},
size: {
sm: "h-8",
default: "h-10",
lg: "h-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface InputProps extends Omit<
React.ComponentProps<"input">,
"size"
> {
/** Surface treatment of the input. @default "default" */
variant?: "default" | "underline"
/** Visual height preset. @default "default" */
size?: "sm" | "default" | "lg"
/** Floating label shown inside the field until focus or input. */
label?: React.ReactNode
/** Decorative or actionable content before the input. */
startContent?: React.ReactNode
/** Decorative or actionable content after the input. */
endContent?: React.ReactNode
/** Extra classes applied to the outer surface. */
wrapperClassName?: string
/** Shows an animated clear button while the field has a value; clearing fires `onChange` with an empty value. @default false */
allowClear?: boolean
/** Called after the clear button empties the field. */
onClear?: () => void
}
function setNativeValue(input: HTMLInputElement, next: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
setter?.call(input, next)
input.dispatchEvent(new Event("input", { bubbles: true }))
}
/** A refined text field with optional floating label, inline content and a clear action. */
function Input({
className,
wrapperClassName,
variant = "default",
size = "default",
label,
startContent,
endContent,
allowClear = false,
onClear,
id,
placeholder,
ref,
value,
defaultValue,
onChange,
disabled,
readOnly,
...props
}: InputProps) {
const generatedId = React.useId()
const inputId = id ?? generatedId
const inputRef = React.useRef<HTMLInputElement | null>(null)
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "")
const hasValue = String(value ?? internalValue).length > 0
const showClear = allowClear && hasValue && !disabled && !readOnly
function assignRef(node: HTMLInputElement | null) {
inputRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
}
function clear() {
const input = inputRef.current
if (!input) return
setNativeValue(input, "")
input.focus()
onClear?.()
}
return (
<div
data-slot="input-shell"
data-size={size}
data-variant={variant}
className={cn(inputShellVariants({ variant, size }), wrapperClassName)}
>
{startContent ? (
<span
data-slot="input-start"
className={cn(
"text-muted-foreground group-focus-within:text-foreground flex shrink-0 items-center justify-center transition-colors [&_svg]:size-4",
variant === "underline" ? "ml-0" : "ml-3"
)}
>
{startContent}
</span>
) : null}
<input
ref={assignRef}
id={inputId}
data-slot="input"
value={value}
defaultValue={defaultValue}
disabled={disabled}
readOnly={readOnly}
placeholder={label ? " " : placeholder}
onChange={(event) => {
if (value === undefined) setInternalValue(event.target.value)
onChange?.(event)
}}
className={cn(
"placeholder:text-muted-foreground/75 peer h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none disabled:cursor-not-allowed",
size === "lg" && "px-4 text-base",
startContent && "pl-2.5",
(endContent || allowClear) && "pr-2.5",
label && "pb-1 pt-4",
variant === "underline" && "px-0",
variant === "underline" && startContent && "pl-2.5",
className
)}
{...props}
/>
{label ? (
<label
data-slot="input-label"
htmlFor={inputId}
className={cn(
"text-muted-foreground peer-focus:text-foreground pointer-events-none absolute top-1.5 origin-left text-[10px] font-medium leading-none transition-[top,translate,font-size,color] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:text-sm peer-focus:top-1.5 peer-focus:translate-y-0 peer-focus:text-[10px] motion-reduce:transition-none",
variant === "underline" ? "left-0" : "left-3",
startContent && (variant === "underline" ? "left-6.5" : "left-9.5")
)}
>
{label}
</label>
) : null}
{allowClear ? (
<button
type="button"
tabIndex={-1}
data-slot="input-clear"
data-state={showClear ? "visible" : "hidden"}
aria-label="清空内容"
aria-hidden={!showClear}
className={cn(
"text-muted-foreground hover:bg-muted hover:text-foreground flex size-5 shrink-0 items-center justify-center rounded-full outline-none transition-[opacity,scale,background-color,color] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"data-[state=hidden]:pointer-events-none data-[state=hidden]:scale-50 data-[state=hidden]:opacity-0",
endContent ? "mr-1.5" : variant === "underline" ? "mr-0" : "mr-2.5"
)}
onMouseDown={(event) => event.preventDefault()}
onClick={clear}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
</button>
) : null}
{endContent ? (
<span
data-slot="input-end"
className={cn(
"text-muted-foreground group-focus-within:text-foreground flex shrink-0 items-center justify-center transition-colors [&_button]:-mr-1 [&_svg]:size-4",
variant === "underline" ? "mr-0" : "mr-3"
)}
>
{endContent}
</span>
) : null}
</div>
)
}
export { Input, inputShellVariants }
属性 Props
Input 继承原生 HTML <input> 元素的全部属性,并额外支持以下功能扩展:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "default" | "underline" | "default" | 输入框的外观变体风格。"default" 为四周带圆角边框的标准卡片风格,"underline" 为极简底部下划线风格。 |
| size | "sm" | "default" | "lg" | "default" | 输入框的垂直高度与字号尺寸预设(sm: 32px, default: 40px, lg: 48px)。 |
| label | React.ReactNode | — | 浮动标签文本。传入后,标签在未聚焦且无内容时居中浮动,获得焦点或有内容时平滑缩小并移至顶部。 |
| startContent | React.ReactNode | — | 位于输入框左侧的前置内容(如搜索图标、国家区号、币种符号)。 |
| endContent | React.ReactNode | — | 位于输入框右侧的后置内容(如密码显隐开关、一键清空按钮、快捷键提示、复制按钮)。 |
| allowClear | boolean | false | 有内容时显示清空按钮,按钮带缩放淡入淡出过渡。清空会以空值触发 `onChange`,受控与非受控模式均可使用;禁用或只读时不显示。 |
| onClear | () => void | — | 点击清空按钮并清空内容后触发。 |
| type | string | "text" | 原生 input 类型(例如 "text"、"password"、"email"、"number"、"search"、"tel"、"url" 等)。 |
| value | string | number | readonly string[] | — | 受控模式下的输入值。 |
| defaultValue | string | number | readonly string[] | — | 非受控模式下的初始输入值。 |
| placeholder | string | — | 输入框为空时显示的占位提示文字。 |
| disabled | boolean | false | 是否禁用输入框,禁用后无法聚焦与编辑。 |
| readOnly | boolean | false | 是否设为只读模式(可聚焦、可复制文本,但不可修改内容)。 |
| required | boolean | false | 在表单中是否为必填字段。 |
| maxLength | number | — | 允许输入的最大字符数限制。 |
| aria-invalid | boolean | "true" | "false" | false | 标记当前输入是否校验失败。设为 true 时输入框外边框与聚焦光圈将自动切换为高亮警示破坏色(destructive)。 |
| wrapperClassName | string | — | 应用于最外层容器 shell 的额外 CSS 类名。 |
| className | string | — | 应用于内部原生 `<input>` 元素的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onChange | (event: React.ChangeEvent<HTMLInputElement>) => void | — | 输入框内容发生改变时触发的回调函数。 |
| onFocus | (event: React.FocusEvent<HTMLInputElement>) => void | — | 输入框获得焦点时触发。 |
| onBlur | (event: React.FocusEvent<HTMLInputElement>) => void | — | 输入框失去焦点时触发。 |
| onKeyDown | (event: React.KeyboardEvent<HTMLInputElement>) => void | — | 在输入框获得焦点时按下键盘按键触发(如监听 Enter 提交或 Esc 清空)。 |
| onInput | (event: React.FormEvent<HTMLInputElement>) => void | — | 原生 input 事件,在用户输入内容时立即触发。 |
使用场景与设计规范
Input 是最基础且高频的文本录入控件,用于捕获用户的单行文本、邮箱、密码、手机号、搜索关键词等数据。
输入类组件选型对比
| 控件类型 | 适用行数 | 输入特性 | 典型业务场景 |
|---|---|---|---|
| Input | 单行 | 基础文本、邮箱、密码、搜索框 | 登录/注册表单、搜索栏、配置名称 |
| Textarea | 多行(可换行) | 备注、长篇描述、反馈意见、评论 | 工单描述、个人简介、文章摘要 |
| InputOTP | 单字符分段 | 验证码、双因子认证 PIN 码 | 短信验证码、Google Authenticator 6 位验证码 |
| InputNumber | 数值专用 | 步进加减、上下限区间约束、高精度格式化 | 商品购买数量、金额充值、库存配置 |
设计最佳实践
- 占位符 (Placeholder) 不能替代标签 (Label):占位符文本在用户开始输入后会立即消失,容易导致用户遗忘当前输入项的含义。必须始终配合独立的
<label>或内置的label浮动标签使用。 - 前后置内容的合理语义:
startContent:通常用于放置传达字段类型的视觉图标(如邮箱图标、锁图标、放大镜图标)或固定协议头(如https://)。endContent:通常用于交互型操作(如密码显隐切换按钮、一键清空按钮、复制按钮、单位后缀如px或MB)。
- 错误态的综合呈现:不能仅依靠边框变红来表达错误;必须在输入框下方提供明确的错误说明文本,并通过
aria-invalid="true"与aria-describedby关联,保障色弱与视障用户的无障碍体验。 - 合理选用输入类型 (type):对于移动端设备,设置正确的
type="email"、type="tel"或type="number"可以自动唤起针对性的虚拟软键盘,极大提升移动端输入效率。
场景示例
受控模式与字符统计
通过 value 与 onChange 进行受控管理,并实时呈现字数统计与一键清空操作:
Loading…
const [value, setValue] = React.useState("")
return (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="请输入文本..."
/>
)尺寸规格
提供 sm (32px)、default (40px) 和 lg (48px) 三种尺寸以适应不同的界面密度:
Loading…
sm:适合紧凑工具栏、数据表格内嵌编辑、过滤器条。default:通用页面表单、弹窗配置项。lg:登录/注册页核心 Hero 区域、醒目的搜索条。
前后置图标与操作 (Start & End Content)
利用 startContent 和 endContent 轻松组合图标、域名后缀或一键复制等实用操作:
Loading…
密码输入与强度检测
结合状态切换实现密码明文/密文查看,并实时显示密码安全强度条:
Loading…
浮动标签 (Floating Label)
传入 label 属性即可启用 Material 风格的动效浮动标签,既节省垂直空间又保留持久的字段提示:
Loading…
下划线风格 (Underline Variant)
设置 variant="underline" 呈现极简下划线风格,适合排版极简的移动端界面或已有明确卡片边界的表单区域:
Loading…
状态展示与表单校验
输入框提供标准、校验错误 (aria-invalid="true")、只读 (readOnly) 与禁用 (disabled) 等全套状态反馈:
Loading…
完整业务表单
在多字段提交的业务场景中,结合统一的校验逻辑、错误提示文案与提交反馈:
Loading…
无障碍与交互 Accessibility
- ARIA 标准规范:
- 支持
aria-invalid="true",当输入无效时自动关联错误状态并应用破坏色高亮边框。 - 通过
aria-describedby关联下方的辅助说明或错误文本 ID。 - 必填项支持声明
aria-required="true"或原生required属性。
- 支持
- 键盘交互与焦点管理:
- Tab:按 DOM 顺序移动焦点,输入框获得焦点时显示清晰的高对比度焦点光圈(Focus Ring)。
allowClear的清空按钮带有aria-label="清空内容",但不进入 Tab 序列,避免打断表单间的焦点移动;键盘用户可直接全选删除。点击后焦点会回到输入框。- 通过
endContent放入的操作按钮(如密码显隐切换)应使用type="button"与明确的aria-label。
- 屏幕阅读器支持:
- 始终推荐使用
<label htmlFor="inputId">关联输入框。若使用无文字包裹的独立搜索框,务必添加aria-label。
- 始终推荐使用