☰
Material Design文本输入字段:浮动标签、状态机与无障碍实践
2026/11/4 21:34:24 网站建设 项目流程

表单里最难伺候的控件,不是下拉框,也不是日期选择器,而是那个看起来最普通的文本输入字段。我做过不少表单密集型的项目,从一开始觉得“输入框不就是个框嘛”,到后来被浮动标签错位、边框缺口偏移、暗色模式对比度不够、中文输入法字符计数跳变这些细节轮番教育,才慢慢意识到:Material Design文本输入字段这套东西,表面上是视觉规范,底层其实是一整套关于状态、动效、可访问性和输入行为的工程约定。

这篇内容想把这件事说透。它适合三类人看:刚接手设计规范落地、想搞清楚“为什么规范要这么定”的前端或客户端开发者;正在做组件库封装、需要统一全站输入框行为的中高级工程师;以及做设计走查、老觉得实现出来“差一点意思”的界面设计同学。我会从最基础的形态选择讲到像素级的尺寸推导,再到图标、计数、多行自适应、动效节奏、无障碍播报和跨端参数映射,中间穿插我自己踩过的坑和排查思路。看完你至少能做到两件事:一是能独立写出一个不依赖任何组件库、行为和视觉都站得住的输入字段;二是能看懂现成组件库那些参数名背后的真实含义,而不是照着文档抄一遍。

1. 先想明白:输入字段在Material体系里到底在解决什么问题

1.1 一个控件,八种状态,别只画两种

很多人做输入框只考虑“普通”和“聚焦”两种样子,上线之后立刻被现实打脸。完整的输入字段至少要在八种组合下都成立:空值未聚焦、空值聚焦、有值未聚焦、有值聚焦、错误未聚焦、错误聚焦、禁用、只读。如果再加上“带前缀”“带后缀”“多行”,组合数量会翻倍。

这套规范里最核心的发明是浮动标签(floating label)。它同时承担了两个角色:在空值时它是占位说明,告诉用户这里该填什么;在聚焦或有值时它缩小上浮,变成字段的“名字”,把位置让给真实输入内容。这件事的价值在于,用户填到第五个字段时,回头看一眼仍然知道每个框里填的是什么业务含义,而不是对着一排已经消失的占位文字发呆。

提示:不要用 placeholder 冒充标签。placeholder 在输入后消失,用户失去上下文;而且很多读屏软件对 placeholder 的播报优先级低于 label,体验会断层。placeholder 的正确用途是给格式示例,比如“name@example.com”。

状态之间怎么过渡,才是真正考验实现的地方。标签的位置、字号、颜色,边框的粗细和颜色,辅助文字的出现和替换,这些东西必须由同一套状态机驱动,否则你会在某个边缘状态下看到“标签浮起来了但边框还是灰的”这种半吊子效果。我在项目里通常会把状态收敛成一个枚举:idle | focused | filled | error | disabled | readonly,再由它派生出视觉变量,而不是在样式表里到处写:focus和.has-error的各种交叉组合。

1.2 Filled 还是 Outlined,这不是审美问题

这套体系里输入字段有两个主要变体:Filled(填充式)和Outlined(描边式)。很多团队选型靠“哪个好看”,结果整站风格打架。

我的判断标准很实际。填充式字段的容器有底色,视觉重量更重,它适合放在信息密度高、字段成组出现的场景,比如设置页、筛选面板、后台表格的行内编辑。因为底色本身就是一种分组信号,连续几个字段排下来,用户能看出“这一坨是一组”。描边式字段留白多、边界轻,适合表单是页面主角的场景,比如注册、结算、问卷,一屏只有四五个字段时,描边的克制感更好。

关键在于不要混用。我见过一个页面里上半部分是填充式、下半部分是描边式,原因是两拨人分别开发的。用户不会说清哪里别扭,但会觉得“这页面不太专业”。真要在同一产品里区分场景,可以按模块边界划分,比如设置模块统一填充式、表单流程统一描边式,并且写进组件库文档,而不是靠开发者临场发挥。

尺寸上还有一套密度分级。常规高度是 56dp,紧凑版是 40dp。紧凑版只适合鼠标操作的桌面端表格或工具栏,触摸场景下不要用,因为 40dp 已经低于舒适触摸目标,手指点上去容易误触到相邻字段。

变体常规高度紧凑高度适用场景主要风险
Filled56dp40dp设置页、筛选、行内编辑底色多时页面显脏
Outlined56dp40dp注册、结算、问卷边框多时页面显碎
混用不建议不建议无视觉语言不统一

1.3 三条技术路线的成本账

落地的时候有三条路:直接调现成组件、在现成组件上二次封装、从零手写。

直接调用的成本最低,但如果你的产品有多端(Web、移动端原生、跨平台框架),各端组件库对同一套规范的实现细节并不一致,比如标签上浮的动画时长、错误态的边框宽度、辅助文字的行高,最后会出现“同一个设计稿在三个端长得不一样”。这个坑我在跨端项目里踩得很深。

二次封装是我最推荐的路线:把标签、辅助文字、错误文案、字符计数、图标位收敛成一个统一 API,内部再对接各端原生实现。这样业务侧只写<TextField label="手机号" error={err} />,换端或者升级组件库时改动集中在一处。

从零手写的唯一合理理由是:设计规范被深度定制过,或者你需要极致的包体积控制。手写的代价是你要自己处理无障碍、输入法事件、自动填充、RTL 布局,这些隐形成本远超写样式本身。我一般会告诉团队:手写可以,但要先准备好一份覆盖二十个场景的验收清单,否则一定会在上线后收到奇怪的反馈。

2. 基础形态的像素级还原

2.1 56dp 到底是怎么算出来的

这个数字不是拍脑袋。正文文本用 16sp 字号、24sp 行高,容器上下各留 16dp 的内边距,16 + 24 + 16 = 56。上下 16dp 不只是为了好看,它保证了输入区域的可点击高度足够,同时在标签上浮后,顶部那 16dp 刚好容纳 12sp 的浮动标签。

填充式字段的结构可以拆成三层:容器(负责底色、圆角、状态色)、文本输入区(负责光标、选区、滚动)、标签层(绝对定位,负责位移和缩放)。描边式多一层麻烦:标签浮起后要压在边框线上,把边框“挖”出一个缺口,这个缺口叫 notch。

紧凑版 40dp 的算法是:上下内边距各 8dp,加上 24sp 行高,8 + 24 + 8 = 40。这时候浮动标签的位置要相应上移,字号也更小,实际项目中紧凑版往往干脆不显示浮动标签,改用外部标签,避免挤成一团。

注意:如果你在 Web 端用box-sizing: border-box,56px 高度是含边框的。描边式聚焦时边框从 1px 变 2px,如果不做补偿,内容会整体位移 1px,视觉上就是“输入的时候字抖了一下”。解决办法是聚焦态把 padding 减 1px,或者干脆用box-shadow模拟第二层边框,不改变盒模型。

2.2 浮动标签用 transform,不要动 font-size

标签上浮的实现方式,我见过最多的错误写法是直接改font-size和top。这么写动画会卡,因为字号变化触发的是布局重排,浏览器要重新计算文本排版,几十个字段同时动画时帧率会掉得很难看。

正确做法是用 transform 做位移和缩放。标签初始位置在输入区垂直居中,上浮时执行translateY(-28px) scale(0.75),并且把transform-origin设成左侧中心(left center),这样缩放时标签是往左上收,不会左右乱跑。

缩放比例 0.75 是有依据的:16sp 乘 0.75 等于 12sp,正好是浮动标签的字号,而且 0.75 这个比例在大多数渲染引擎里缩放后的文字边缘还算清晰。位移量则取决于变体:填充式大约上移 24 到 28px 到达容器顶部区域;描边式要让标签中心正好压在边框线上,位移量等于“输入区中心到边框的距离”,通常是输入区高度的一半再减去标签自身高度的一半。

.field__label { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); transform-origin: left center; font-size: 16px; line-height: 24px; pointer-events: none; padding: 0 4px; background: var(--surface); transition: transform 200ms cubic-bezier(0.2, 0, 0, 1), color 200ms cubic-bezier(0.2, 0, 0, 1); } .field__input:focus + .field__label, .field__input:not(:placeholder-shown) + .field__label { transform: translateY(-28px) scale(0.75); color: var(--primary); }

这段 CSS 有个小技巧值得说明:用:placeholder-shown判断“是否为空”,前提是 input 上要写placeholder=" "(一个空格)。空格占位符不可见,但能让选择器正常工作,这是纯 CSS 实现浮动标签的常用手法。代价是读屏软件可能读到一个空 placeholder,所以更严谨的做法是同时加aria-label或者用真实 label 元素关联。

2.3 边框缺口的三四种做法,各有各的坑

描边式的 notch 是这类组件最容易翻车的地方。常见做法有三种。

第一种是标签背景遮盖:给浮动标签加一层和页面背景同色的底色,左右各留 4px padding,标签浮到边框上时,底色把边框线盖住,视觉上就是缺口。这是最简单、兼容性最好的方案,我八成项目都用它。坑在于背景不能是纯色的时候会露馅,比如页面有渐变背景、图片背景,或者字段放在卡片里而卡片底色和页面底色不同。

第二种是用原生 fieldset 和 legend。HTML 的 fieldset 天生就有“边框被 legend 打断”的行为,把 label 塞进 legend 里,浏览器会自己帮你算缺口。这个方案语义和视觉都对齐,缺点是样式控制权弱,各浏览器对 legend 的内边距处理有差异,浮动动画几乎没法做。它更适合不需要动画的后台系统。

第三种是mask 或者 border-image,用 CSS 遮罩把边框中间挖掉一段。精度最高,能应付任意背景,但实现复杂度高,动画过程中的遮罩宽度计算容易出错。

方案实现难度背景适配动画友好度推荐场景
标签背景遮盖低仅纯色好绝大多数常规页面
fieldset + legend中全部差后台系统、无动画需求
mask 遮罩高全部中渐变或图片背景
SVG path 绘制高全部好设计规范深度定制

还有个细节:缺口宽度要跟标签实际宽度联动。标签文案变长(比如多语言切换、字号放大),固定宽度的缺口会被文字撑破,露出半截边框,很难看。稳妥做法是让缺口宽度由标签内容自然撑开,标签用display: inline-block加左右 padding,遮盖层的宽度自然等于标签宽度。

2.4 颜色、对比度和暗色模式的正确姿势

输入字段涉及的颜色变量比想象中多:容器底色、边框色、边框聚焦色、标签色、输入文字色、占位符色、辅助文字色、错误色、禁用底色、禁用文字色。这十个颜色变量在亮色和暗色两套主题下都要成立。

对比度上有个容易被忽略的点:占位符和辅助文字不能太淡。正文文字要求 4.5:1 的对比度,占位符虽然可以放宽一点,但也不能低到看不见。我在暗色模式走查时经常发现占位符用了 30% 透明度的白,放在深灰底上几乎不可辨,用户会误以为字段已经填了内容。

错误色还有一个常见误区:只靠红色传达“出错了”。色觉障碍用户分辨不出。规范的做法是颜色 + 图标 + 文案三重信号,错误态在字段下方显示带图标的错误说明,边框变红只是辅助。

暗色模式也不要简单反色。亮色模式下填充式字段的底色比页面底色略深,暗色模式下应该比页面底色略浅,这样才有“凹进去”或“浮起来”的一致感知。简单把颜色取反,字段会看起来像被挖空的洞。

3. 高级定制:图标、计数、多行与动效

3.1 前后缀和图标的空间分配

带图标的输入字段,麻烦在于“文本区从哪里开始”。开头图标(leading icon)占 48dp 宽度,其中图标本体 24dp,左右各 12dp 留白。文本区的左内边距要加上这个宽度,否则文字会压到图标上。结尾图标同理,右内边距要预留。

前缀文本(prefix)和后缀文本(suffix)跟图标不是一回事。前缀文本一般是货币符号、国家区号、协议头这类固定内容,它跟输入文字在同一基线上,字号一致。后缀文本常用于单位,比如“kg”“cm”,或者固定域名。前缀后缀的麻烦在于它们要跟着输入内容滚动,某些实现里前缀是绝对定位的,长文本输入后会把内容推到看不见的地方。

结尾图标如果有交互(清除、切换密码可见性),点击区域必须扩到 48dp 见方,不能只让 24dp 的图标可点。做法是给图标按钮加 padding 或用一个透明的点击层。这个细节在小屏手机上体验差异极大,24dp 的点击区手指基本点不准。

密码可见性切换按钮必须有明确的可访问名称,而且要随状态变化。切换前的名称是“显示密码”,切换后变成“隐藏密码”,同时aria-pressed跟着变。我见过太多实现只改图标不改文案,读屏用户点下去完全不知道发生了什么。

3.2 字符计数和多行自适应

带字数上限的字段,计数文案通常放在辅助文字区域的右下方,和左侧的辅助说明同一行。计数文案的数据来源是当前值的长度,但中文输入法会让这个数字跳变:拼音输入过程中,输入框里的内容包含未上屏的字母,长度会临时增加,用户看到计数从 0 跳到 5 再跳回 2,很出戏。

处理办法是监听输入法组合事件,在组合期间挂起计数更新,组合结束再刷新。

let composing = false; const input = document.querySelector('#bio'); const counter = document.querySelector('#bio-count'); function refresh() { counter.textContent = `${input.value.length}/200`; } input.addEventListener('compositionstart', () => { composing = true; }); input.addEventListener('compositionend', () => { composing = false; refresh(); }); input.addEventListener('input', () => { if (!composing) refresh(); });

还有一个更根本的取舍:不要静默截断。超过上限时直接砍掉多出来的字符,用户粘贴一段文字后发现少了半截,会很恼火。更好的做法是允许超出一部分,同时把计数和边框切到错误态,提示“已超出 X 个字符”。如果业务上必须硬限制,至少给一个短暂的提示,说明为什么输入不进去。

多行字段的自适应高度,思路是监听内容变化,把高度重置为最小高度,再设为scrollHeight,并夹在最小行数和最大行数之间。设置overflow-y: auto只在超过最大行数时才出现滚动条。

function autoGrow(el, minRows = 3, maxRows = 8) { const line = 24; const pad = 32; el.style.height = 'auto'; const max = maxRows * line + pad; const next = Math.min(el.scrollHeight, max); el.style.height = Math.max(minRows * line + pad, next) + 'px'; el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden'; }

这里用固定行高是个前提:多行输入框必须显式设置line-height,否则scrollHeight的计算会受字体度量影响,出现半行高度导致抖动。

3.3 清除按钮、密码切换与键盘动作

清除按钮的出现时机有两种流派:一种是有内容就显示,一种是有内容且有焦点才显示。我倾向后者,因为失去焦点后还挂着一个叉号,视觉噪音大,而且用户很容易误触把内容清空。

清除行为的细节也要考虑:点击清除后焦点应该留在输入框里,方便继续输入,而不是焦点丢失。如果字段有联动校验,清除后应该重置为初始未校验状态,而不是立刻显示“不能为空”的错误,那等于骂用户。

键盘动作(IME action)在移动端表单里非常关键。单行字段可以设成“下一项”“完成”“搜索”“发送”,用户填完一项直接跳到下一项,不用每次都去点下一个框。设置的时候要注意最后一项用“完成”并收起键盘,中间项用“下一项”并自动聚焦到下一个字段。这个顺序错了,用户按“下一项”发现键盘收起了,体验很割裂。

输入类型也要匹配内容:邮箱用邮箱键盘,电话用数字键盘,金额用带小数点的数字键盘,网址用网址键盘。类型选错,用户在小键盘上找字母,这就是纯粹的返工。验证码字段记得加一次性验证码的自动填充标记,让系统能把短信里的码直接填进来。

3.4 动效节奏:别让输入框“抖”

动效是最容易做出廉价感的地方。常见问题是:标签上浮、边框变色、辅助文字展开、图标旋转,四个动画各用各的时长和曲线,同时触发时画面很乱。

我的做法是统一时长和曲线,标签位移和颜色用同一条缓动曲线、同一个时长,一般是 200 毫秒,曲线用先快后慢的标准缓动。边框颜色变化可以短一点,150 毫秒左右,因为它只是颜色,不需要位移的惯性感。辅助文字的展开(从 0 高度到实际高度)最容易抖,建议直接用透明度过渡,不要做高度动画,或者固定住辅助文字区域的高度,让它一直占位,只切换内容。

注意:系统开启“减少动态效果”时,所有位移和缩放动画都应该降级为直接切换。这不是可选项,是基本要求。判断方式是媒体查询里的prefers-reduced-motion。

还有一个细节:错误态出现时,如果同时伴随整块表单的高度变化(因为错误文案把下面的元素顶下去了),用户刚要点的按钮会突然跑位。解决办法是预留错误文案的位置,或者用绝对定位让错误文案浮在下方不影响布局。前者更稳妥,代价是表单看起来空隙大一点。

4. 表单联动、无障碍与键盘处理

4.1 标签关联和错误播报的正确写法

无障碍这块,输入字段最常见的三个错误:标签没关联、错误没播报、必填没标记。

标签关联靠for和id配对,这是最基本的一条。如果视觉上不需要标签(比如用图标代替),也要保留一个视觉隐藏的 label 元素,让读屏能读到。

错误播报的关键是aria-invalid和aria-describedby的组合使用:字段出错时把aria-invalid设为真,把错误文案的 id 挂到aria-describedby上,错误文案容器加role="alert",这样错误出现时读屏会主动念出来。

<div class="field"> <label for="phone">手机号</label> <input id="phone" type="tel" aria-invalid="true" aria-describedby="phone-error" aria-required="true" autocomplete="tel" /> <p id="phone-error" role="alert">请输入 11 位手机号</p> </div>

有两个坑要避开。第一,role="alert"只在内容插入时播报一次,如果你把错误文案从“格式错误”改成“长度不对”,某些读屏不会重复播报,因为容器没变。稳妥做法是清空再写入,或者用aria-live="polite"的独立区域。第二,aria-describedby可以挂多个 id,用空格分隔,辅助说明和错误文案可以同时挂上,读屏会依次念出。

4.2 焦点顺序和自定义控件的坑

有一类实现我建议直接否决:用div加contenteditable或者纯div模拟输入框。看起来样式好控,实际上要自己实现光标定位、选区、撤销、输入法事件、剪贴板、移动端键盘唤起,每一项都是深坑。除非你在做富文本编辑器,否则老老实实用原生input和textarea,样式受限的部分用外层容器解决。

焦点顺序上,视觉顺序和 DOM 顺序必须一致。用tabindex正数强行调整焦点顺序是典型反模式,维护起来极其痛苦,加一个字段就要重排所有数字。正确的做法是调整 DOM 结构。

焦点样式也不能省。去掉默认 outline 之后必须自己画一个,而且要用:focus-visible而不是:focus,这样鼠标点击时不出现焦点环,键盘 Tab 时才出现。用:focus的话,鼠标点一下输入框就出现一圈环,很多人因此干脆全局删掉 outline,结果键盘用户彻底迷失。

4.3 自动填充和浏览器的“染色”行为

autocomplete属性值得单独说。它不只是方便,还直接影响转化率:用户能一键填入姓名、电话、地址,表单完成率会明显提升。常用的值包括姓名、邮箱、电话、街道地址、邮编、当前密码、新密码、一次性验证码。密码类字段一定要区分“当前密码”和“新密码”,标错了浏览器会填错,用户还得手动改。

浏览器自动填充还有个视觉副作用:某些浏览器给自动填充的输入框加一层淡黄或淡蓝底色,把设计稿的底色盖掉,非常突兀。这个底色是浏览器内部绘制的高优先级样式,background-color改不动。可行的处理是用超大内阴影盖住它:

input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus { -webkit-box-shadow: 0 0 0 1000px var(--surface) inset; -webkit-text-fill-color: var(--on-surface); transition: background-color 9999s ease-out 0s; }

最后那行超长 transition 是老技巧,目的是把底色变化无限延后。这个方案在各浏览器表现不完全一致,属于“能用但别当规范”的手段,写的时候要加注释说明意图,免得后人以为是无用代码删掉。

4.4 移动端键盘遮挡和视口变化

移动端表单最烦的问题:聚焦靠底部的字段,软键盘弹起来把字段盖住,用户看不见自己在输什么。

Android 端通常有视口自动调整策略,iOS 端则常常需要手动处理。可行的做法是监听视口尺寸变化,在键盘弹出时把聚焦的字段滚到可视区域中间:

if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { const el = document.activeElement; if (el && el.matches('input, textarea')) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }); }

底部固定的提交按钮也要处理:键盘弹起时按钮可能被顶上去盖住字段,或者被键盘完全遮住。用安全区域变量配合视口高度做适配,别写死100vh,移动端浏览器的100vh包含地址栏,实际可视高度更小。

还有一条经验:字号不要小于 16px。某些移动端浏览器在输入框字号小于 16px 时,聚焦会自动放大页面,用户输完还得手动缩回去,体验极差。设计稿上 14px 的输入文字,在移动端要么改成 16px,要么在聚焦时临时放大,但后者会带来布局跳动,不如一开始就定 16px。

5. 常见问题排查实录

5.1 布局和渲染类问题

浮动标签位置偏移。最常见的成因是标签用了top百分比定位,而父容器高度包含了边框,导致计算基准不对。改成绝对定位到具体像素值,或者用 flex 布局加transform组合,能规避大部分问题。另一个成因是字体加载时机:自定义字体加载完成后行高变了,标签位置却没重算。用font-display: swap配合固定的行高能缓解。

描边缺口错位。标签浮起后盖住边框,但盖的位置偏了半个像素,露出一点边框残影。这类问题多半是缩放后的标签尺寸取整导致的,浏览器对小数像素的渲染策略不一致。处理办法是别在缩放后的元素上算遮盖宽度,而是让遮盖层独立于缩放,宽度由文案实际宽度决定。

输入时高度跳动。单行输入框在某些实现里会因为内容为空和内容有值时的行高不同而抖一下。显式设置line-height和固定高度就能解决。多行输入框的抖动则是scrollHeight在内容刚好占满时来回切换导致,给个几像素的容差。

5.2 输入行为和状态类问题

受控组件光标跳到末尾。这个坑几乎每个做 React 表单的人都遇到过:输入框的值受外部状态控制,用户把光标移到中间插入字符,结果光标跳到末尾。根因是异步更新导致的值回写覆盖了选区。处理思路是不要在输入过程中做同步格式化(比如自动加空格、自动转大写),如果必须格式化,就在更新后手动恢复选区位置,或者改用非受控模式加引用读取。

中文输入法下校验提前触发。拼音还没上屏就触发了“格式不正确”,用户被红框吓一跳。判断方式是用组合事件标记,组合期间跳过校验。

错误态一直不消失。用户改完了内容,红框还挂着。原因是校验只在提交时跑一次,输入时不重跑。改成失焦时校验、输入时清除错误,体验会顺很多。

禁用态看起来像可编辑。禁用的输入框如果只是把文字调灰、底色不变,用户会以为能点。禁用态通常需要降低整体透明度、去掉边框、改光标样式,三件事一起做。

5.3 一张排查速查表

症状高概率原因处理方向
标签浮起后盖住输入文字位移量按容器高度算,未扣除内边距改用固定像素位移
聚焦时内容右移 1px边框 1px 变 2px 未补偿聚焦态 padding 减 1
缺口处有边框残影遮盖层宽度取整误差遮盖层加 1px 冗余
暗色模式占位符看不见透明度太低提到可辨对比度
移动端聚焦自动放大字号小于 16px提到 16px
自动填充后底色异常浏览器内建高亮内阴影覆盖
粘贴超长文本被截断硬性长度限制改为错误提示
读屏不播报错误缺 aria-invalid 或 alert补属性与角色
键盘弹出遮挡字段视口未适配监听视口尺寸滚动定位
多行框高度抖动行高未固定显式设置行高

6. 跨端实现对照:同一套规范的三张脸

6.1 Web 端最小可靠实现

Web 端我建议的骨架是:外层容器负责定位和状态类名,input负责真实输入,label绝对定位做浮动,辅助文字区域固定高度。不要用div套div再套div,层级越深,定位和层级问题越多。

结构大致是:

<div class="field">

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询