表单是 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判断字段含义。
两种合法写法,各有适用场景:
<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、在某些浏览器里会“咬掉”边框。常见的处理方式是:
fieldset {
border: none;
padding: 0;
margin: 0;
}
legend {
float: left; /* 关键:消除浏览器默认渲染差异 */
width: 100%;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 12px;
}
一个完整的注册表单骨架
<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 只做格式示例。
三、动手实验:一张可交互的表单工作台
下面这张表单把本文后续要讲的大部分控件都放进来了。你可以直接在上面输入、勾选、拖动、上传,观察每一种控件的实际行为与视觉反馈。表单不会真正提交,点击提交只会展示一次成功提示。
建议你动手做几件事:把焦点依次移到每个控件上,观察边框与阴影的变化;在手机上打开这个页面,点一下手机号输入框看弹出的键盘类型;用 Tab 键走一遍,确认每一步都有清晰的焦点指示。
四、输入框的七种状态与视觉语言
一个输入框从来不是“静态的一根线”。它在不同时刻有不同状态,每种状态都需要用户能一眼分辨。把状态设计清楚,是表单可用性的地基。
焦点样式:最不能省的那一行 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 会触发,鼠标点击通常不会。这意味着鼠标用户不会被一圈难看的描边打扰,而键盘用户依然能清楚看到自己在哪里。
错误状态:三重信号,不依赖颜色
红边框是最常见的错误提示,但只靠颜色是不够的——红绿色盲用户几乎分辨不出。一个合格的错误状态应该同时具备:
- 边框变色:让错误字段在整片表单里一眼可见。
- 图标辅助:在输入框右侧或提示文字前放一个警告图标。
- 文字说明:明确告诉用户“哪里错了、怎么改”,而不是只说“输入有误”。
用户不知道是少了 @,还是多了空格,还是域名写错。
给出格式示例,用户一次就能改对。
只读与禁用的区别
这两个状态经常被混用,但它们的语义完全不同:
readonly:值仍然会被提交,用户可以选中和复制,只是不能编辑。适合展示“已生成但需要让用户看到的”内容,比如订单号、邀请码。disabled:值不会被提交,无法聚焦,也无法选中。适合展示“当前条件下不可用”的选项。
在视觉上,readonly 可以保持正常的文字颜色,只把边框改成虚线;而 disabled 应该明确地灰化,让用户一眼看出它不可用。不要用 disabled 来“代替校验”——用户看到灰色按钮却不知道为什么不能点,是非常糟糕的体验。更好的做法是保持可点击,点击后给出原因。
五、进阶交互:浮动标签、显隐切换与字数统计
浮动标签:只用 CSS 就能实现
浮动标签(Floating Label)的经典实现依赖 :placeholder-shown 这个伪类。它的逻辑是:给 input 一个内容为空的 placeholder=" ",当输入框为空时 :placeholder-shown 匹配,此时 label 停在中间;一旦有内容或获得焦点,label 就上移变小。
<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 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 事件并更新一个计数元素:
const len = bio.value.length;
counter.textContent = len + ' / 200';
counter.classList.toggle('warn', len > 180);
});
注意 maxlength 是按 UTF-16 码元计数的,一个 emoji 可能算两个字符。如果业务对字数有严格限制(比如短信、推特),需要自己按码点计算并提示。
输入格式化:在用户按下键盘时帮忙
手机号、银行卡号、身份证号这类固定格式的输入,可以在用户输入时自动插入分隔符。核心思路是:先去掉所有非数字字符,再按位数重新分组。
// 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="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”形:
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 设置:
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。 - 滑块的值应该同步显示在旁边的文本里,因为用户拖动时看不到精确数值。
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 与输入框关联,这样屏幕阅读器聚焦时能一并读出。表单整体提交失败时,再在顶部放一个汇总区,列出所有出错字段的锚点链接,方便用户逐个跳转。
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 是最省事的:
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 加分组标题能明显降低压迫感。分组之间用留白或浅色背景区分,比加一堆分割线更清爽。
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 显式建立关联:
<input type="password" id="pwd"
aria-describedby="pwd-hint">
<p id="pwd-hint">至少 8 位,需包含字母和数字</p>
聚焦输入框时,屏幕阅读器会先读标签名,再读描述文本。这个属性的值可以是多个 id,用空格分隔。
视觉隐藏但可被朗读的文本
有些场景需要给屏幕阅读器提供额外的上下文,但又不想在视觉上显示出来。比如一个只有图标的按钮,或者一组没有可见标题的选项。这时候用“视觉隐藏”类:
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:
input,
select,
textarea {
font-size: 16px;
}
}
不要用 user-scalable=no 来禁用缩放——那会同时剥夺视障用户放大的权利,属于典型的“为了省事牺牲无障碍”。
触摸目标尺寸
移动端的点击目标至少要 44 × 44 CSS 像素。这在复选框和单选按钮上尤其重要——原生控件本身只有 13px 左右,必须靠 label 扩大热区。这就是为什么本文演示中所有 checkbox 和 radio 都包在 label 里。
自动填充与密码管理器
正确使用 autocomplete 能让用户少输入大量内容,还能让密码管理器正确识别字段:
<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:
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 变量。
/* 尺寸 */
--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);
}
把这些变量应用到一个统一的字段类上,整个站点的表单就都跟着变了:
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);
}
深色模式的低成本方案
如果颜色全部走变量,深色模式只需要覆盖这一组值:
:root {
--field-bg: #1e293b;
--field-border: #334155;
--field-border-hover: #475569;
--field-text: #e2e8f0;
--field-placeholder: #64748b;
--field-bg-disabled: #0f172a;
}
}
注意:深色模式下,原本的浅红背景(#fef2f2)会显得刺眼,错误状态也应该单独覆盖一套深色适配值。深色模式不是把颜色反过来就行,饱和度、对比度都需要重新调整。
按钮的统一规格
按钮是表单里最容易被忽略一致性的一环。一个站点至少应该定义这几种:
十二、十个最常见的表单设计陷阱
:invalid 而不是 :user-invalid
disabled 按钮代替校验说明,用户不知道为什么点不了
outline: none,键盘用户彻底迷路
div + onclick 冒充按钮,键盘无法触发
type="number",导致前导零丢失
关于“保存草稿”
长表单最容易被忽略的一点是:用户填了十分钟,一个误触返回,全部白填。解决方式并不复杂——在输入时把内容存进 localStorage,页面加载时读回来填充。虽然实现简单,但能挽回大量流失。
// 输入时保存(节流)
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 状态,可能只需要三行代码。这些微小的改动累加起来,就是一条从“能填”到“愿意填”的曲线。
所以,下一次当你面对一张表单的时候,不妨先问自己三个问题:用户第一眼知道该填什么吗?填错了知道怎么改吗?用键盘能走完吗?——这三个问题的答案,基本就决定了这张表单的上限。