表单样式与交互设计

张玥 2026年9月20日 阅读时间 50分钟
表单设计 CSS 交互体验 无障碍 移动端
前端开发技巧之表单样式与交互设计

表单是 Web 上唯一一种“用户必须主动付出”的界面。用户点一个按钮只需要一次点击,但填一张表单可能要花两分钟、输入二十个字段、被打断三次。也正因如此,表单是所有界面类型里容错率最低、收益也最高的一种:一个字段的标签写错,可能让注册转化率掉几个百分点;一次校验提示不及时,可能让用户彻底放弃支付。而浏览器给出的原生控件,二十年来几乎没怎么变过样子。这篇 50 分钟的长文,从语义结构讲到视觉状态,从原生控件的美化讲到自定义控件的取舍,从校验反馈讲到移动端键盘与无障碍,并配套了一整套可以直接上手操作的交互演示。读完你不仅能照着写,还能知道每一步为什么这么做。

一、表单为什么值得单独写一篇长文

在大多数项目里,表单是“最不起眼但最要命”的部分。它不像首页 Hero 那样需要炫技,也不像动画那样能拿来做演示,可一旦出问题,损失是直接和业务指标挂钩的:注册填不完、下单提交失败、支付密码输错三次被锁。

把表单做好,本质上是在解决三类矛盾:

矛盾一 用户想少填,业务想多收集 —— 字段能删就删,能自动填充就不要手输
矛盾二 用户想快,系统要准 —— 实时校验、智能格式化、自动补全
矛盾三 设计想好看,浏览器只给默认样式 —— 用 appearance 重塑,但不破坏原生行为
矛盾四 开发者想省事,无障碍要求严格 —— 语义标签是唯一同时满足两边的方案

原生控件的“丑”,其实是一种保护

很多人第一次尝试美化表单时会惊讶地发现:select 的下拉箭头改不掉、checkbox 的勾选框没法调圆角、range 的滑块在 Windows 和 macOS 上长得完全不一样。这不是浏览器偷懒,而是因为这些控件的渲染交给了操作系统——它们在不同平台上保持与系统一致,也让用户感到熟悉。

所以表单样式设计的第一原则是:先搞清楚哪些能改、哪些不能改、哪些“能改但不该改”。

控件 可定制程度 推荐做法
input[type=text] 完全可控 边框、圆角、阴影、内边距随意调整
textarea 完全可控 注意 resize 与最小高度
checkbox / radio 完全可控 appearance: none + 伪元素自绘
select 部分可控 外壳可改,下拉面板由系统绘制
range 部分可控 需要分别写 WebKit 与 Firefox 伪元素
file 部分可控 ::file-selector-button 可定制按钮
date / color 很难定制 接受原生外观,或改为自定义面板

这篇文章会带你走完的路径

接下来的内容按“从骨架到血肉”的顺序展开:先讲语义与结构,再讲视觉状态,然后是各类控件的具体做法、校验与反馈、布局与响应式、无障碍与移动端,最后落到一套可复用的设计系统与检查清单。中间穿插了六个可以直接在页面上操作的交互演示,建议边读边动手试。

二、结构先行:form、label、fieldset、legend

在写任何 CSS 之前,先把结构搭对。表单的结构语义不仅影响无障碍,也直接决定了你能不能用 :focus-within、:placeholder-shown 这类选择器做出漂亮的交互。

label 是表单的灵魂

一个没有 label 的输入框,对屏幕阅读器用户来说就是“编辑框,空白”。而 label 的价值远不止无障碍:

  • 点击标签即可聚焦:把 for 指向 id,用户点“邮箱地址”这四个字就能把光标送进输入框,触屏上尤其重要。
  • 扩大点击热区:对 checkbox 和 radio 这种小目标来说,这是决定性的体验差异。
  • 屏幕阅读器朗读:聚焦时自动读出标签文本,用户不必回头找。
  • 浏览器自动填充:Chrome 和 Safari 会结合 label 文本与 autocomplete 判断字段含义。

两种合法写法,各有适用场景:

<!-- 写法一:for / id 显式关联,最通用 -->
<label for="email">邮箱地址</label>
<input type="email" id="email" name="email">

<!-- 写法二:包裹式,无需 id,适合自定义控件 -->
<label class="check">
  <input type="checkbox" name="agree">
  <span>我已阅读并同意用户协议</span>
</label>

fieldset 与 legend:把相关控件分组

当一组控件共享同一个主题时——比如“收货地址”里的省市区,“支付方式”里的几个单选项——用 fieldset 把它们包起来,用 legend 提供组标题。屏幕阅读器在进入每个控件时都会先朗读一次组名,用户不会迷失在“男 / 女 / 其他”这三个孤零零的选项里。

要注意的是,legend 的样式在浏览器里出了名的难搞:它默认有自己的内边距、无法直接使用 display: flex、在某些浏览器里会“咬掉”边框。常见的处理方式是:

/* 让 legend 变成普通块级元素,便于排版 */
fieldset {
  border: none;
  padding: 0;
  margin: 0;
}

legend {
  float: left;  /* 关键:消除浏览器默认渲染差异 */
  width: 100%;
  font-size: 0.85rem;
  font-weight: 700;
  margin-bottom: 12px;
}

一个完整的注册表单骨架

<form action="/register" method="post" novalidate>

  <fieldset>
    <legend>账号信息</legend>

    <div class="field">
      <label for="username">用户名</label>
      <input type="text" id="username" name="username"
        autocomplete="username" required>
    </div>

    <div class="field">
      <label for="pwd">密码</label>
      <input type="password" id="pwd" name="password"
        autocomplete="new-password"
        minlength="8" required
        aria-describedby="pwd-hint">
      <p id="pwd-hint" class="hint">至少 8 位,包含字母与数字</p>
    </div>
  </fieldset>

  <fieldset>
    <legend>接收通知的方式</legend>
    <label class="radio">
      <input type="radio" name="notify" value="email" checked>
      <span>邮件</span>
    </label>
    <label class="radio">
      <input type="radio" name="notify" value="sms">
      <span>短信</span>
    </label>
  </fieldset>

  <button type="submit">创建账号</button>
</form>
不要这样
<input placeholder="请输入邮箱">
用户开始输入后提示就消失了,屏幕阅读器也读不到字段名。
应该这样
<label for="e">邮箱</label>
<input id="e" type="email" placeholder="name@example.com">
标签始终可见,placeholder 只做格式示例。

三、动手实验:一张可交互的表单工作台

下面这张表单把本文后续要讲的大部分控件都放进来了。你可以直接在上面输入、勾选、拖动、上传,观察每一种控件的实际行为与视觉反馈。表单不会真正提交,点击提交只会展示一次成功提示。

交互演示 · 账号注册
浮动标签:聚焦或有内容时上移
请输入常用邮箱,我们会发送验证邮件
建议混合使用字母、数字与符号
移动端会自动弹出数字键盘
原生日期控件,移动端体验最佳
8k 25k 60k
支持 PDF / DOC / DOCX,单个文件不超过 5MB
只读状态:可选中复制,不可编辑
0 / 200
CSS Grid 无障碍
这类复合控件需要用键盘事件补齐原生控件的体验
表单已通过前端校验
真实项目中,此时应发送请求并在等待期间禁用提交按钮、展示加载状态。

建议你动手做几件事:把焦点依次移到每个控件上,观察边框与阴影的变化;在手机上打开这个页面,点一下手机号输入框看弹出的键盘类型;用 Tab 键走一遍,确认每一步都有清晰的焦点指示。

四、输入框的七种状态与视觉语言

一个输入框从来不是“静态的一根线”。它在不同时刻有不同状态,每种状态都需要用户能一眼分辨。把状态设计清楚,是表单可用性的地基。

default
默认状态
边框用中性灰(#cbd5e1),不抢注意力
:hover
鼠标悬停
边框加深一档,暗示“这里可交互”
:focus
获得焦点
主色边框 + 3px 外发光,绝不能用 outline: none 了事
filled
已填写内容
与默认态视觉一致,靠内容本身区分
error
格式不正确
红色边框 + 浅红底 + 图标,三重信号
success
校验通过
绿色边框,仅在真正需要确认时使用
:disabled
不可编辑
灰底灰字,光标改为 not-allowed

焦点样式:最不能省的那一行 CSS

很多项目在全局样式里写了这样一句:

/* 千万不要这样写 */
*:focus {
  outline: none;
}

这行代码把所有键盘用户的定位能力一次性抹掉了。正确的做法是只移除默认 outline,同时补上自定义的可见焦点样式:

/* 方案一:现代浏览器,只在键盘导航时出现 */
.f-input:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* 方案二:用边框 + 阴影模拟,视觉更柔和 */
.f-input:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}

:focus-visible 与 :focus 的区别在于:前者只在浏览器判断“用户需要看到焦点”时才匹配——键盘 Tab 会触发,鼠标点击通常不会。这意味着鼠标用户不会被一圈难看的描边打扰,而键盘用户依然能清楚看到自己在哪里。

错误状态:三重信号,不依赖颜色

红边框是最常见的错误提示,但只靠颜色是不够的——红绿色盲用户几乎分辨不出。一个合格的错误状态应该同时具备:

  • 边框变色:让错误字段在整片表单里一眼可见。
  • 图标辅助:在输入框右侧或提示文字前放一个警告图标。
  • 文字说明:明确告诉用户“哪里错了、怎么改”,而不是只说“输入有误”。
模糊的提示
“邮箱格式不正确”
用户不知道是少了 @,还是多了空格,还是域名写错。
具体的提示
“邮箱地址需要包含 @ 符号,例如 name@example.com”
给出格式示例,用户一次就能改对。

只读与禁用的区别

这两个状态经常被混用,但它们的语义完全不同:

  • readonly:值仍然会被提交,用户可以选中和复制,只是不能编辑。适合展示“已生成但需要让用户看到的”内容,比如订单号、邀请码。
  • disabled:值不会被提交,无法聚焦,也无法选中。适合展示“当前条件下不可用”的选项。

在视觉上,readonly 可以保持正常的文字颜色,只把边框改成虚线;而 disabled 应该明确地灰化,让用户一眼看出它不可用。不要用 disabled 来“代替校验”——用户看到灰色按钮却不知道为什么不能点,是非常糟糕的体验。更好的做法是保持可点击,点击后给出原因。

五、进阶交互:浮动标签、显隐切换与字数统计

浮动标签:只用 CSS 就能实现

浮动标签(Floating Label)的经典实现依赖 :placeholder-shown 这个伪类。它的逻辑是:给 input 一个内容为空的 placeholder=" ",当输入框为空时 :placeholder-shown 匹配,此时 label 停在中间;一旦有内容或获得焦点,label 就上移变小。

<div class="float-field">
  <input type="text" id="name" placeholder=" ">
  <label for="name">姓名</label>
</div>

/* 注意:DOM 顺序必须是 input 在前、label 在后 */
.float-field { position: relative; }

.float-field label {
  position: absolute;
  left: 13px;
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;  /* 关键:不挡住输入框点击 */
  transition: all 0.18s ease;
}

.float-field input:focus + label,
.float-field input:not(:placeholder-shown) + label {
  top: 15px;
  font-size: 0.68rem;
  font-weight: 700;
  color: var(--primary);
}

这种方案有三个容易踩的坑:第一,placeholder 不能写真实文本,只能写一个空格,否则会和浮动标签重叠;第二,label 必须关闭 pointer-events,不然点击标签区域时光标进不了输入框;第三,autocomplete 自动填充时,部分浏览器不会触发 :placeholder-shown 的变化,导致标签位置错乱——这是浮动标签方案至今难以完全消除的缺陷。

密码显隐:一个按钮的可用性细节

密码显隐切换看起来很简单,但要做好有几个讲究:

  • 按钮应该用 <button type="button">,否则在表单里会默认触发提交。
  • 图标要在“眼睛”和“带斜线的眼睛”之间切换,给用户明确的当前状态。
  • aria-label 要跟着状态更新,屏幕阅读器才知道现在点下去是显示还是隐藏。
  • 切换后要保持焦点在按钮上,不能因为 type 变化导致焦点丢失。
const btn = document.getElementById('togglePwd');
const pwd = document.getElementById('demoPwd');

btn.addEventListener('click', () => {
  const isPwd = pwd.type === 'password';
  pwd.type = isPwd ? 'text' : 'password';
  btn.setAttribute('aria-label', isPwd ? '隐藏密码' : '显示密码');
  btn.querySelector('i').className = isPwd ? 'far fa-eye-slash' : 'far fa-eye';
});

字数统计:让用户心里有数

对于有 maxlength 限制的文本域,实时显示剩余字数能显著降低用户“写完了才发现超了”的挫败感。实现上只需监听 input 事件并更新一个计数元素:

bio.addEventListener('input', () => {
  const len = bio.value.length;
  counter.textContent = len + ' / 200';
  counter.classList.toggle('warn', len > 180);
});

注意 maxlength 是按 UTF-16 码元计数的,一个 emoji 可能算两个字符。如果业务对字数有严格限制(比如短信、推特),需要自己按码点计算并提示。

输入格式化:在用户按下键盘时帮忙

手机号、银行卡号、身份证号这类固定格式的输入,可以在用户输入时自动插入分隔符。核心思路是:先去掉所有非数字字符,再按位数重新分组。

phone.addEventListener('input', (e) => {
  // 1. 只保留数字
  let v = e.target.value.replace(/\D/g, '').slice(0, 11);

  // 2. 按 3-4-4 分组
  let out = v;
  if (v.length > 7) {
    out = v.slice(0, 3) + ' ' + v.slice(3, 7) + ' ' + v.slice(7);
  } else if (v.length > 3) {
    out = v.slice(0, 3) + ' ' + v.slice(3);
  }

  e.target.value = out;
});

这里有一个必须注意的副作用:直接修改 value 会让光标跳到末尾。如果用户是在中间插入字符,体验会非常糟糕。生产环境需要记录 selectionStart,在格式化后重新计算光标位置。这也是为什么“格式化”看起来简单,实际要做好并不容易。

六、重塑选择类控件:checkbox、radio、switch、select、range

appearance: none 是起点

要让浏览器交出控件的绘制权,第一步永远是关掉原生外观:

input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;  /* 兼容 Safari 旧版本 */
  width: 20px;
  height: 20px;
  border: 2px solid #cbd5e1;
  border-radius: 5px;
  background: white;
  cursor: pointer;
  position: relative;
  flex-shrink: 0;  /* 防止在 flex 容器里被压扁 */
  transition: all 0.18s ease;
}

然后用 ::after 伪元素画勾或者画圆点。复选框的勾是一个旋转 45 度的“L”形:

input[type="checkbox"]:checked {
  background: var(--primary);
  border-color: var(--primary);
}

input[type="checkbox"]:checked::after {
  content: '';
  position: absolute;
  left: 5px;
  top: 1px;
  width: 5px;
  height: 10px;
  border: solid white;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

这里有个精妙的细节:勾的两条边用了 border-width: 0 2px 2px 0,只保留右边和下边,正好构成一个直角,旋转 45 度后就是标准的对勾形状。这是纯 CSS 打勾最通用的写法,比用 SVG 或字体图标更轻。

复选框的第三种状态:indeterminate

在“全选”场景下,当子项只选中了一部分,父级复选框应该显示一个横杠而不是勾。这个状态只能用 JavaScript 设置:

// 注意:这是属性不是特性,不能写在 HTML 里
checkbox.indeterminate = true;

/* CSS 用 :indeterminate 匹配 */
input[type="checkbox"]:indeterminate::after {
  content: '';
  position: absolute;
  left: 3px;
  top: 7px;
  width: 10px;
  height: 2px;
  background: white;
}

开关:用 checkbox 实现,不要用 div

开关(Switch / Toggle)本质上就是一个二值控件,语义上等同于复选框。用 <input type="checkbox"> 改造的好处是:键盘可操作、屏幕阅读器会朗读“已选中 / 未选中”、表单能正常提交。

做法是把 input 拉宽成胶囊形,用 ::after 画圆形滑块,选中时用 transform: translateX() 把滑块推到右边。动画曲线推荐用带一点回弹的 cubic-bezier(0.34, 1.4, 0.64, 1),手感会比线性过渡好很多。

select 的定制边界

select 是表单里最“顽固”的控件。你可以改它的边框、背景、内边距、字体,但下拉展开后的选项面板由操作系统绘制,CSS 完全无法触及。所以常见的定制方案是:

  • 轻量方案:appearance: none 去掉原生箭头,用绝对定位的图标或 SVG 背景图自己画一个。这也是本文演示中采用的方式。
  • 完全自定义方案:用 div + ul + JavaScript 从头实现,可以完全控制选项样式、支持搜索、支持多选。代价是需要自己处理键盘导航、焦点管理、屏幕阅读器朗读——工作量远大于想象。
  • 折中方案:用原生 select 保证可用性,用 <datalist> 提供输入建议,只在确有必要时才上完全自定义。

如果选择自定义,请务必参考 WAI-ARIA 的 Combobox 模式:使用 role="combobox"、aria-expanded、aria-activedescendant,并支持上下方向键、Home / End、Esc 关闭、输入过滤等一整套键盘行为。这不是一个下午能做完的活儿。

range:需要写两套伪元素

滑块是少数必须分别针对 WebKit 和 Firefox 写样式的控件。除了前面演示中的 ::-webkit-slider-thumb 和 ::-moz-range-thumb,还有两个容易忽略的点:

  • ::-webkit-slider-runnable-track 允许你单独定制轨道,从而做出“已选区域变色”的效果。
  • 拖动时如果不希望页面跟着滚动,需要在触摸设备上加 touch-action: none。
  • 滑块的值应该同步显示在旁边的文本里,因为用户拖动时看不到精确数值。
/* 轨道分段变色:用 linear-gradient 按当前值计算 */
const pct = (value - min) / (max - min) * 100;
range.style.background =
  `linear-gradient(90deg, #2563eb ${pct}%, #e2e8f0 ${pct}%)`;

七、校验与反馈:让错误在正确的时间出现

原生校验能力比你想的强

在写任何 JavaScript 之前,先看看 HTML 已经提供了什么:

属性 / 类型 作用 示例
required 必填,空值提交时浏览器拦截 <input required>
type="email" 校验邮箱格式,移动端弹邮箱键盘 <input type="email">
type="url" 校验 URL 格式 <input type="url">
minlength / maxlength 限制字符长度 <input minlength="8">
min / max / step 数值、日期、范围的范围限制 <input type="number" min="1" max="10">
pattern 正则校验,注意隐式锚定 pattern="[0-9]{6}"
autocomplete 自动填充,也是重要的语义提示 autocomplete="one-time-code"
inputmode 指定移动端键盘类型,不改校验行为 inputmode="numeric"

:user-invalid 与 :invalid 的关键区别

CSS 早就有了 :invalid,但直接用它做样式会有一个致命问题:页面刚加载、用户还没开始填的时候,所有必填项就已经是红的了。这会让用户一进门就被满屏错误吓到。

:user-invalid 解决了这个问题:它只在用户已经与字段交互过(输入后失焦,或者尝试提交)且值仍然非法时才匹配。

/* 不推荐:一进页面就全红 */
input:invalid { border-color: #ef4444; }

/* 推荐:只在用户操作后反馈 */
input:user-invalid {
  border-color: #ef4444;
  background: #fef2f2;
}

/* 搭配 :valid 做正向确认 */
input:user-valid {
  border-color: #10b981;
}

如果需要兼容还不支持 :user-invalid 的浏览器,可以在 blur 事件里手动打标记类名,效果等价。

校验时机:三个时间点的取舍

输入时校验 用户还在打字就报“格式错误”,容易打断输入。仅在“实时反馈有明显帮助”时使用,比如密码强度、用户名可用性
失焦时校验 最常用的时机。用户填完一个字段准备去下一个,此时给反馈不会打断思路
提交时校验 最后一道防线。应把所有出错字段一次性标出,并把焦点移到第一个错误处

无论哪个时机,有一条原则不能破:报错后不要清空用户已经填好的内容。这是表单设计里最不可原谅的错误之一。

错误提示放在哪里

推荐把提示文字紧贴在对应字段下方,用 aria-describedby 与输入框关联,这样屏幕阅读器聚焦时能一并读出。表单整体提交失败时,再在顶部放一个汇总区,列出所有出错字段的锚点链接,方便用户逐个跳转。

<input
  type="email"
  id="email"
  aria-invalid="true"
  aria-describedby="email-error">

<p id="email-error" class="msg error" role="alert">
  邮箱地址需要包含 @ 符号
</p>

注意 role="alert":它会让屏幕阅读器在提示出现的瞬间主动朗读,而不需要用户移动焦点。但要注意别滥用——如果每个字符变化都触发一次 alert,用户会被吵到崩溃。它只适合用在真正需要即时告知的场景。

提交按钮的加载状态

点击提交后到服务器返回之间,往往有几百毫秒到几秒的空白。如果没有反馈,用户会以为没点上,于是再点一次——重复提交就是这么产生的。

正确做法是:提交瞬间把按钮置为禁用、把文案换成“提交中…”并配一个旋转指示器,等请求返回再恢复。同时还要防止用户通过回车键重复提交——disabled 属性天然能拦住这一点。

八、布局:栅格、标签位置与响应式

标签放上面还是放左边

这是一个被反复争论的问题,答案取决于你的表单形态:

标签在上方
推荐
标签在左侧
特定场景
  • 标签在上方:填写路径是“从上到下”的一条直线,视线不需要左右跳跃。移动端几乎是唯一正确选择。缺点是纵向占用更多空间。
  • 标签在左侧:适合字段很多、且用户需要快速扫描标签名的场景(比如后台设置页)。但对移动端不友好,且当标签长度不一时需要固定宽度。
  • 浮动标签:兼顾两者,视觉紧凑且标签始终可见。但字号偏小,对老年用户和视力障碍用户不友好,不适合信息密集的复杂表单。

用 Grid 做双列表单

桌面端的表单经常需要两列排布,用 CSS Grid 是最省事的:

.form-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
}

/* 需要整行的字段跨两列 */
.field.full {
  grid-column: 1 / -1;
}

/* 窄屏退回单列 */
@media (max-width: 700px) {
  .form-grid { grid-template-columns: 1fr; }
}

1fr 这个单位的关键作用是:即使某一列的内容很长,也不会撑破容器。如果改用 auto,长内容会把两列推得不均等,看起来非常别扭。

字段顺序与心理负担

表单的字段顺序会显著影响完成率。几条实践经验:

  • 把容易填的放前面:姓名、邮箱这类不需要思考的字段先填,用户一旦开始填写就更可能坚持到最后。
  • 把敏感的放后面:手机号、身份证、支付信息放在用户已经投入了时间成本之后。
  • 相关字段放在一起:省、市、区应该在同一行或相邻位置。
  • 选项数量控制在 7 个以内:超过之后用户开始犹豫,可以考虑分组或提供搜索。

用 fieldset 做视觉分组

当字段超过 10 个时,用 fieldset 加分组标题能明显降低压迫感。分组之间用留白或浅色背景区分,比加一堆分割线更清爽。

fieldset {
  border: none;
  padding: 20px;
  border-radius: 10px;
  background: #f8fafc;
  margin-bottom: 20px;
}

legend {
  float: left;
  width: 100%;
  margin-bottom: 14px;
  font-weight: 700;
  font-size: 0.9rem;
  color: var(--primary);
}

九、无障碍:让所有人都能填完这张表

表单是无障碍问题最集中的地方。一个视觉上完美的表单,如果用键盘根本走不通,那它有一半用户是用不了的。

键盘可操作性检查表

  • Tab 顺序合理:焦点应该按视觉顺序移动。不要在 DOM 里打乱顺序再用 CSS 的 order 调整视觉位置——这会导致 Tab 跳来跳去。
  • 所有交互元素可聚焦:链接、按钮、输入框天然可聚焦;自己用 div 做的控件必须加 tabindex="0" 并手动实现键盘事件。
  • 焦点指示可见:前面已经说过,绝不写裸的 outline: none。
  • 分组可用方向键切换:原生 radio 组内用方向键切换,这是浏览器免费提供的,自定义控件时需要手动实现。
  • Esc 可关闭浮层:日期选择器、下拉面板这类浮层必须支持 Esc。

aria-describedby:把提示关联到字段

表单里那些说明文字(“至少 8 位”“我们不会分享你的邮箱”)如果只是视觉上挨着输入框,屏幕阅读器是完全感知不到的。必须用 aria-describedby 显式建立关联:

<label for="pwd">密码</label>
<input type="password" id="pwd"
       aria-describedby="pwd-hint">
<p id="pwd-hint">至少 8 位,需包含字母和数字</p>

聚焦输入框时,屏幕阅读器会先读标签名,再读描述文本。这个属性的值可以是多个 id,用空格分隔。

视觉隐藏但可被朗读的文本

有些场景需要给屏幕阅读器提供额外的上下文,但又不想在视觉上显示出来。比如一个只有图标的按钮,或者一组没有可见标题的选项。这时候用“视觉隐藏”类:

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

不要用 display: none 或 visibility: hidden——它们会把内容从无障碍树里一并移除。

颜色对比度

WCAG 2.1 AA 标准要求正文文字与背景的对比度至少达到 4.5:1,大字号文字(18pt 以上或 14pt 加粗)可以放宽到 3:1。输入框的边框属于“非文本内容”,要求 3:1。

这意味着 #cbd5e1 这种浅灰边框在白底上其实是不达标的(对比度约 1.6:1)。实际项目中出于美观考虑常常妥协,但至少要保证聚焦和错误状态下的对比度足够——那才是真正影响可用性的关键时刻。

错误提示的可感知性

除了颜色,错误提示还应该:

  • 在字段下方以文本形式出现,而不是仅仅在输入框里飘一个红框。
  • 带上图标,帮助色觉障碍用户识别。
  • 在表单顶部提供一个汇总区域,用 role="alert" 或 aria-live="polite" 让屏幕阅读器主动播报。
  • 提交失败后把焦点移到第一个错误字段或错误汇总区。

十、移动端表单:键盘、缩放与触摸

用 type 和 inputmode 换到正确的键盘

在手机上,弹出什么键盘直接决定了输入效率。数字键盘比全键盘快得多,也少误触:

输入内容 推荐写法 弹出的键盘
邮箱 type="email" 带 @ 和 . 的键盘
网址 type="url" 带 / 和 .com 的键盘
电话号码 type="tel" + inputmode="numeric" 数字键盘
金额、数量 type="text" + inputmode="decimal" 带小数点的数字键盘
验证码 inputmode="numeric" + autocomplete="one-time-code" 数字键盘 + 短信自动填充
搜索 type="search" 回车键显示“搜索”

为什么手机号不直接用 type="number"?因为 number 会去掉前导零、支持科学计数法、在小键盘上有些浏览器还带上下调节箭头,对手机号这种“数字组成的字符串”并不合适。type="tel" 才是语义正确的选择。

防止 iOS 自动缩放

iOS Safari 有个老毛病:当输入框的字号小于 16px 时,聚焦会自动把整个页面放大,导致布局错乱,用户还很难缩放回去。解决办法是把移动端的输入框字号设置为至少 16px:

@media (max-width: 768px) {
  input,
  select,
  textarea {
    font-size: 16px;
  }
}

不要用 user-scalable=no 来禁用缩放——那会同时剥夺视障用户放大的权利,属于典型的“为了省事牺牲无障碍”。

触摸目标尺寸

移动端的点击目标至少要 44 × 44 CSS 像素。这在复选框和单选按钮上尤其重要——原生控件本身只有 13px 左右,必须靠 label 扩大热区。这就是为什么本文演示中所有 checkbox 和 radio 都包在 label 里。

自动填充与密码管理器

正确使用 autocomplete 能让用户少输入大量内容,还能让密码管理器正确识别字段:

<input name="name" autocomplete="name">
<input name="email" autocomplete="email">
<input name="tel" autocomplete="tel">
<input name="addr" autocomplete="street-address">
<input name="zip" autocomplete="postal-code">
<input type="password" autocomplete="new-password">
<input type="password" autocomplete="current-password">

注册表单用 new-password,登录表单用 current-password——这两个值决定了密码管理器是“建议生成一个新密码”还是“填入已保存的密码”,写错会直接影响用户能否顺利登录。

输入法的组合输入事件

中文、日文用户使用输入法时,会经历“拼音输入 → 候选 → 确认上屏”的过程。在这个过程中 input 事件会频繁触发,此时如果做实时格式化或实时搜索,会产生大量无意义请求,甚至破坏用户正在输入的内容。

正确的处理方式是监听 compositionstart 与 compositionend:

let composing = false;

input.addEventListener('compositionstart', () => {
  composing = true;
});

input.addEventListener('compositionend', () => {
  composing = false;
  handleSearch(input.value);  // 输入法确认后才触发
});

input.addEventListener('input', (e) => {
  if (composing) return;  // 组合输入期间忽略
  handleSearch(e.target.value);
});

这是中文开发者特别需要注意的一点:在英文环境下测试完全正常的实时搜索,到了中文用户手里就可能变成“每敲一个字母发一次请求”。

十一、设计系统:用 CSS 变量统一表单风格

一个项目里往往有十几张表单。如果每个页面的输入框都是各写各的样式,很快就会失控。解决办法是把表单的视觉规则抽成一组 CSS 变量。

:root {
  /* 尺寸 */
  --field-height: 44px;
  --field-radius: 8px;
  --field-padding-x: 13px;
  --field-gap: 18px;

  /* 颜色 */
  --field-border: #cbd5e1;
  --field-border-hover: #94a3b8;
  --field-border-focus: #2563eb;
  --field-bg: #ffffff;
  --field-bg-disabled: #f1f5f9;
  --field-text: #0f172a;
  --field-placeholder: #b6c2d1;
  --field-error: #ef4444;
  --field-success: #10b981;

  /* 焦点环 */
  --field-ring: 0 0 0 3px rgba(37, 99, 235, 0.15);
  --field-ring-error: 0 0 0 3px rgba(239, 68, 68, 0.15);
}

把这些变量应用到一个统一的字段类上,整个站点的表单就都跟着变了:

.field-input {
  width: 100%;
  min-height: var(--field-height);
  padding: 0 var(--field-padding-x);
  border: 1.5px solid var(--field-border);
  border-radius: var(--field-radius);
  background: var(--field-bg);
  color: var(--field-text);
  transition: border-color 0.2s, box-shadow 0.2s;
}

.field-input:hover {
  border-color: var(--field-border-hover);
}

.field-input:focus {
  outline: none;
  border-color: var(--field-border-focus);
  box-shadow: var(--field-ring);
}

.field-input[aria-invalid="true"] {
  border-color: var(--field-error);
  box-shadow: var(--field-ring-error);
}

深色模式的低成本方案

如果颜色全部走变量,深色模式只需要覆盖这一组值:

@media (prefers-color-scheme: dark) {
  :root {
    --field-bg: #1e293b;
    --field-border: #334155;
    --field-border-hover: #475569;
    --field-text: #e2e8f0;
    --field-placeholder: #64748b;
    --field-bg-disabled: #0f172a;
  }
}

注意:深色模式下,原本的浅红背景(#fef2f2)会显得刺眼,错误状态也应该单独覆盖一套深色适配值。深色模式不是把颜色反过来就行,饱和度、对比度都需要重新调整。

按钮的统一规格

按钮是表单里最容易被忽略一致性的一环。一个站点至少应该定义这几种:

primary 主操作,实心填充,每屏通常只有一个
secondary 次要操作,描边样式
ghost 最轻量的操作,无边框,仅文字
danger 破坏性操作,红色,通常需要二次确认
loading 提交中状态,禁用点击并显示指示器

十二、十个最常见的表单设计陷阱

陷阱 1 用 placeholder 代替 label,用户输入后完全不知道这个字段是什么
陷阱 2 一进页面就满屏红色错误提示,因为用了 :invalid 而不是 :user-invalid
陷阱 3 提交失败后清空用户填写的所有内容
陷阱 4 用 disabled 按钮代替校验说明,用户不知道为什么点不了
陷阱 5 全局写 outline: none,键盘用户彻底迷路
陷阱 6 错误提示只说“输入有误”,不告诉用户怎么改
陷阱 7 用 div + onclick 冒充按钮,键盘无法触发
陷阱 8 手机号用 type="number",导致前导零丢失
陷阱 9 移动端输入框字号小于 16px,聚焦时页面被强制放大
陷阱 10 提交按钮不加禁用逻辑,用户连点三次产生三条订单

关于“保存草稿”

长表单最容易被忽略的一点是:用户填了十分钟,一个误触返回,全部白填。解决方式并不复杂——在输入时把内容存进 localStorage,页面加载时读回来填充。虽然实现简单,但能挽回大量流失。

const KEY = 'form-draft';

// 输入时保存(节流)
form.addEventListener('input', debounce(() => {
  const data = Object.fromEntries(new FormData(form));
  localStorage.setItem(KEY, JSON.stringify(data));
}, 500));

// 页面加载时恢复
const saved = localStorage.getItem(KEY);
if (saved) {
  const data = JSON.parse(saved);
  for (const [k, v] of Object.entries(data)) {
    const el = form.elements[k];
    if (el) el.value = v;
  }
}

// 提交成功后清除
form.addEventListener('submit', () => localStorage.removeItem(KEY));

需要注意的是,不要缓存密码、验证码、支付信息这类敏感字段。缓存本身也应该有一个过期时间,比如 24 小时后自动清除。

十三、检查清单与可复用基线

把全文的结论压缩成一份可以贴在工位上的清单。每次提交表单页面之前扫一眼,能挡掉绝大多数问题。

  • 每个控件都有 label,for 与 id 一一对应。
  • placeholder 只做格式示例,不承担标签职责。
  • 相关控件用 fieldset + legend 分组。
  • 输入框有清晰的 hover / focus / error 状态,焦点环绝不移除。
  • 错误提示具体到“怎么改”,并带图标而非只用颜色。
  • 使用 :user-invalid,避免页面加载就报错。
  • 提交按钮有 loading 状态,并在请求期间禁用。
  • 手机号用 type="tel",数字用 inputmode 指定键盘。
  • 移动端输入框字号不小于 16px。
  • 触摸目标不小于 44×44px,复选框靠 label 扩大热区。
  • autocomplete 正确填写,注册用 new-password。
  • 监听 compositionstart,避免输入法组合期间触发搜索。
  • 只按 Tab 键走一遍,确认所有交互都能完成。
  • 用屏幕阅读器读一遍,确认标签与错误提示都能被朗读。
  • 颜色全部走 CSS 变量,深色模式只需要覆盖一组值。
  • 长表单考虑草稿保存,但不要缓存敏感字段。
<!-- 一份可直接复用的表单字段基线 -->

<div class="field">
  <label for="email">
    邮箱地址 <span class="req">*</span>
  </label>

  <input
    type="email"
    id="email"
    name="email"
    class="field-input"
    placeholder="name@example.com"
    autocomplete="email"
    required
    aria-describedby="email-hint">

  <p id="email-hint" class="hint">
    我们会向这个邮箱发送验证链接
  </p>
</div>

表单设计的迷人之处在于:它的每一分改进都能被用户直接感知,也几乎都能被数据量化。把标签从 placeholder 改成真实的 label,可能只需要十分钟;把错误提示从“输入有误”改成一句具体的修改建议,可能只需要五分钟;给提交按钮加一个 loading 状态,可能只需要三行代码。这些微小的改动累加起来,就是一条从“能填”到“愿意填”的曲线。

所以,下一次当你面对一张表单的时候,不妨先问自己三个问题:用户第一眼知道该填什么吗?填错了知道怎么改吗?用键盘能走完吗?——这三个问题的答案,基本就决定了这张表单的上限。