wui
组件

输入框 Input

用于录入单行文本、邮箱、密码及搜索词的基础表单控件,支持前后置内容、浮动标签、下划线风格与多重校验状态。

第三方依赖 · class-variance-authority

基础用法

最常用的单行搜索与文本输入框。开启 allowClear 后,输入内容时清空按钮会以缩放淡入的方式出现,清空后淡出:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/input
安装基础依赖
pnpm add class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/input.tsx
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)。
labelReact.ReactNode—浮动标签文本。传入后,标签在未聚焦且无内容时居中浮动,获得焦点或有内容时平滑缩小并移至顶部。
startContentReact.ReactNode—位于输入框左侧的前置内容(如搜索图标、国家区号、币种符号)。
endContentReact.ReactNode—位于输入框右侧的后置内容(如密码显隐开关、一键清空按钮、快捷键提示、复制按钮)。
allowClearbooleanfalse有内容时显示清空按钮,按钮带缩放淡入淡出过渡。清空会以空值触发 `onChange`,受控与非受控模式均可使用;禁用或只读时不显示。
onClear() => void—点击清空按钮并清空内容后触发。
typestring"text"原生 input 类型(例如 "text"、"password"、"email"、"number"、"search"、"tel"、"url" 等)。
valuestring | number | readonly string[]—受控模式下的输入值。
defaultValuestring | number | readonly string[]—非受控模式下的初始输入值。
placeholderstring—输入框为空时显示的占位提示文字。
disabledbooleanfalse是否禁用输入框,禁用后无法聚焦与编辑。
readOnlybooleanfalse是否设为只读模式(可聚焦、可复制文本,但不可修改内容)。
requiredbooleanfalse在表单中是否为必填字段。
maxLengthnumber—允许输入的最大字符数限制。
aria-invalidboolean | "true" | "false"false标记当前输入是否校验失败。设为 true 时输入框外边框与聚焦光圈将自动切换为高亮警示破坏色(destructive)。
wrapperClassNamestring—应用于最外层容器 shell 的额外 CSS 类名。
classNamestring—应用于内部原生 `<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。