Day 24 | 用 placeholder 当 label,用户一输入就不知道在填什么
本文是《30天精通CSS》专栏第 24 篇,订阅专栏不迷路。
摘要:本文基于 Selectors Level 4 与 CSS Scrolling/Forms 现行规范,用诊断式的方法排查表单动效里最常见的三个坑:placeholder 被误当 label 导致一输入就"失忆"、输入框缺少"正在被填"的聚焦信号、校验反馈来得太早。逐个排除后给出修复方案::focus-within 让整块高亮、:user-invalid 把报错时机推迟到用户离开字段之后、错误抖动只做一次且只给真错误。文中还标明了 :user-invalid 的兼容边界与 @supports 渐进增强兜底写法。读完你能拿到一套能直接复用的登录表单动效。适合正在做表单、却总被"填起来怪怪的"困扰的读者。
本文目录
-
先看一张会让人"填到一半发呆"的表单
-
可疑点一:placeholder 不是用来当 label 的
-
可疑点二:输入框没告诉你"我正在被填"
-
可疑点三:校验红得太早,像在被骂
-
逐个修掉:三件套补齐
-
举一反三:抖动只给真错误
-
回头看:表单动效的三条边界
-
手熟靠这三下

先看一张会让人"填到一半发呆"的表单
诊断式文章,规矩是把崩掉的现场先甩你脸上。下面这张图,是很多人真实上线的登录框:用户名框里灰灰地写着"请输入用户名",看起来挺好。可你一旦开始打字,那行灰字"啪"就没了——填到一半你抬头,框里只剩你敲的字,那个"到底该填用户名还是手机号"的提示早就消失了。上一天 Day 23 我们刚给按钮配齐悬停、按下、键盘焦点那四态,今天把这套讲究搬进表单,翻车却更隐蔽——不报错,就是让你填到一半发起呆来,挺别扭呢。我自己有次做登录页,图省事把字段名全塞进 placeholder,上线没几天就被用户追着问"这框到底填啥",这才老老实实把 label 立起来。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>placeholder 当 label 的事故现场</title>
<style>
* { box-sizing: border-box; }
body { max-width: 460px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
.bar { background: #fde8e8; border: 1px solid #f3b6b6; border-radius: 6px;
padding: 8px 14px; font-size: 14px; color: #8a2828; margin-bottom: 22px; }
/* 错: 没有 label, 把"该填什么"这件事全押在 placeholder 上 */
.field input { width: 100%; padding: 14px 16px; border: 1px solid #d8d3c7;
border-radius: 10px; font-size: 15px; background: #fff; }
/* placeholder 一有内容就消失, 用户回头就忘了字段含义; 而且灰字对比度常常不够 */
.field input::placeholder { color: #c9c4b8; }
.field { margin-bottom: 16px; }
</style>
</head>
<body>
<div class="bar">输入一半,提示就没了 —— 下面第二个框我已经打了字,"密码"这个含义彻底消失</div>
<div class="field"><input type="text" placeholder="请输入用户名"></div>
<div class="field"><input type="password" placeholder="请输入密码" value="123456"></div>
</body>
</html>

现象很明确:第二个框有内容了,可你完全看不出它是密码框。这不是 transition 的问题,是语义放错了地方。我们把三个可疑点逐个拆开。
可疑点一:placeholder 不是用来当 label 的
先给结论:placeholder 是"提示",不是"名字"。它的宿命就是一输入就消失,所以它天生不能承载"这个字段是什么"这种必须长期可见的信息。这个信息是 label 的活。

placeholder 的可访问性边界
还有两个容易被忽略的边界:placeholder 默认是浅灰,对比度经常不达标(注意 WCAG 对文本对比度有硬性要求);而且它在窄框里会被截断。所以"格式示例"可以交给 placeholder,“必填 / 字段名 / 校验规则"这三样必须交给 label 或旁边的说明文字,不能只写在 placeholder 里。这是适用边界,不是"placeholder 不能用”,而是"不能只靠它"。
把这条边界落成代码,就是下面这版"最小正确表单":label 承载字段名和必填星号,旁边一行说明承载规则,placeholder 只留一个格式示例。这份代码双击就能验证:输入进去后,字段名和规则都还在。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>label 与 placeholder 各司其职</title>
<style>
* { box-sizing: border-box; }
body { max-width: 420px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
/* 字段名交给 label, 必填用星号 + aria-label 语义; placeholder 只放示例 */
label { display: block; font-size: 14px; font-weight: bold; margin-bottom: 4px; }
.req { color: #c21818; }
.hint { font-size: 12px; color: #6b6b66; margin: 4px 0 0; }
.field { margin-bottom: 18px; }
input { width: 100%; padding: 12px 14px; font-size: 15px; border: 1.5px solid #d8d3c7; border-radius: 9px; }
/* 提升 placeholder 对比度, 别用太浅的灰 */
input::placeholder { color: #8a8a86; }
</style>
</head>
<body>
<div class="field">
<label for="phone">手机号 <span class="req">*</span></label>
<input id="phone" type="tel" placeholder="13800001234">
<p class="hint">11 位数字, 用于登录和找回</p>
</div>
</body>
</html>

这版和开头那张崩掉的图的差别就一句话:名字(label)一直在,示例(placeholder)只是锦上添花。
可疑点二:输入框没告诉你"我正在被填"
聚焦态(:focus)是有的,但它只作用在 <input> 那一行。如果你想让"整个输入组"(label + input + 图标)一起亮起来,单个 :focus 力不从心。:focus-within 解决的就是这个:只要一个容器内部任何后代拿到焦点,容器自己就命中。机制上,:focus-within 会把焦点状态"冒泡"到祖先,这正是整块高亮的关键。没有它的时候,焦点孤零零待在 input 那一行,外面的 label 和图标纹丝不动,像卡住了半拍,你说憋屈不憋屈呀。哎,这就是单个 :focus 力不从心的地方。

修复的第一步,给每个输入组一个能整块响应聚焦的容器,并把 label 立起来。下面这段代码你可以直接双击打开:点进输入框,整个白框会一起描蓝、阴影浮起。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>label + focus-within 整块高亮</title>
<style>
* { box-sizing: border-box; }
body { max-width: 460px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
h3 { font-size: 18px; }
/* label 单独占一行, 永远可见; placeholder 只放格式示例 */
.field label { display: block; font-size: 13px; color: #6b6b66; margin: 0 0 6px 4px; }
.field { margin-bottom: 18px; }
.field .box { display: flex; align-items: center; background: #fff;
border: 1.5px solid #d8d3c7; border-radius: 10px; padding: 0 12px;
transition: border-color .18s ease-out, box-shadow .18s ease-out; }
.field input { flex: 1; border: 0; outline: none; padding: 13px 6px; font-size: 15px; background: transparent; }
.field input::placeholder { color: #b9b4a7; }
/* 关键: 内部任一元素聚焦, 整块 .box 描蓝 + 浮起阴影 */
.field .box:focus-within { border-color: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,.15); }
.field .ico { color: #9a958a; font-size: 15px; }
</style>
</head>
<body>
<h3>登录</h3>
<div class="field">
<label for="u">用户名</label>
<div class="box"><span class="ico">@</span><input id="u" type="text" placeholder="手机 / 邮箱"></div>
</div>
<div class="field">
<label for="p">密码</label>
<div class="box"><span class="ico">🔒</span><input id="p" type="password" placeholder="8-20 位"></div>
</div>
</body>
</html>

可疑点三:校验红得太早,像在被骂
表单报错要即时,但不能"打字打到一半就红"。很多人用 input:invalid 做校验样式,结果用户刚敲第一个字符,框就红了——因为一个空的 required 或格式没填完的 email 在浏览器眼里当场就是 invalid 状态。这种"边填边骂"的体验很差。真正的坑在这::invalid 反映的是"此刻合不合法",而不是"用户填完没有"。这就好比你话还没说完,对面"叮"一下就把红线甩过来,急人了嘛。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>:invalid 打字即报错</title>
<style>
* { box-sizing: border-box; }
body { max-width: 460px; margin: 40px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
.bar { background: #fde8e8; border: 1px solid #f3b6b6; border-radius: 6px;
padding: 8px 14px; font-size: 14px; color: #8a2828; margin-bottom: 22px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; color: #6b6b66; margin-bottom: 6px; }
.field input { width: 100%; padding: 14px 16px; border: 1.5px solid #d8d3c7;
border-radius: 10px; font-size: 15px; }
/* 错: 只要不合法就红, 用户还在打第一个邮箱字符就已经被标红 */
.field input:invalid { border-color: #c21818; background: #fff5f5; }
.field input:valid { border-color: #2f8f4e; }
</style>
</head>
<body>
<div class="bar">邮箱框: 我还在打 "z", 它已经红着骂我了 —— 因为 :invalid 只看"当下合不合法"</div>
<div class="field"><label>邮箱</label><input type="email" required value="z"></div>
<div class="field"><label>手机号</label><input type="tel" required pattern="1[0-9]{10}" value="13"></div>
</body>
</html>

z 不是合法邮箱,:invalid 立刻命中,框当场变红。注意这里的前提:字段带了 required 且格式尚未填完。修复动作很简洁:把校验反馈从 :invalid 换成 :user-invalid(它只在用户"编辑过并离开"之后才命中)。

逐个修掉:三件套补齐
把三个可疑点的修复合成一份定稿。先看多状态并排的运行结果——我让脚本给整个表单容器分别强制加上聚焦态和报错态(等价 class,与真实伪类命中路径一致),这样"默认 / 聚焦 / 报错"三态能同框对比,你可以逐格验证颜色、边框、错误文案是不是各自正确。
# @target .form
# @states 默认 | 聚焦:focus | 报错:invalid
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>表单 默认/聚焦/报错 三态</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 26px; background: #f6f4ee;
font-family: "Microsoft YaHei", sans-serif; color: #24292f; }
.form { max-width: 340px; background: #fff; border-radius: 14px; padding: 20px;
box-shadow: 0 6px 20px rgba(31,45,61,.10); }
/* 默认态: 中性边框, 无多余装饰 */
.title { font-size: 16px; margin: 0 0 14px; }
.fld { margin-bottom: 12px; }
.fld label { display: block; font-size: 12px; color: #6b6b66; margin-bottom: 5px; }
.fld input { width: 100%; padding: 11px 12px; border: 1.5px solid #d8d3c7;
border-radius: 9px; font-size: 14px; transition: border-color .18s, box-shadow .18s; }
.err { display: none; font-size: 12px; color: #c21818; margin-top: 4px; }
/* 聚焦态: 当前字段描蓝 + 焦点环, 卡片外圈也轻微变亮 */
.st-focus .fld.on input { border-color: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,.15); }
.st-focus .form, .form { outline: none; }
.st-focus .form { box-shadow: 0 10px 26px rgba(37,99,235,.16); }
/* 报错态: 字段变红 + 露出错误文案, 卡片描红 */
.st-invalid .fld.on input { border-color: #c21818; background: #fff5f5; }
.st-invalid .form { box-shadow: 0 6px 20px rgba(194,24,24,.16); }
.st-invalid .err { display: block; }
</style>
</head>
<body>
<div class="form">
<p class="title">登录</p>
<div class="fld on"><label>邮箱</label><input type="text" value="zhang@bad"></div>
<div class="fld"><label>密码</label><input type="password" placeholder="8-20 位"></div>
<p class="err">邮箱格式不正确,请检查</p>
</div>
</body>
</html>

对比数据很清楚:默认三件套都是灰边框;聚焦格第一个字段描蓝、卡片投出蓝阴影;报错格第一个字段变红、底下"格式不正确"文案出现。三态确实各不相同。
把三态摆在一起看还有一层好处:你能一眼验证“聚焦”和“报错”是两种完全不同的信号——聚焦是冷静的蓝,表达“我在等你填”;报错是刺眼的红,表达“你填错了”。这两种颜色千万别混用,否则用户分不清是该继续还是该改。这里的设计前置条件是:字段默认态必须是“安静”的中性灰,把颜色和位移都留给状态去表达,平时不喧宾夺主。
再把"错误抖动"定格成胶片。抖动是一次性的 translateX 往复,我用一个等价的时间轴动画把它的过程切成四格,方便你看它怎么从"中性"抖到"标红静止"。真实项目里这段只跑一次(animation-iteration-count: 1),这里用 animation-fill-mode: both 让定格脚本能稳定取到 0 / 33 / 67 / 满 四个进度点。
# @frames 4
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>错误抖动定格</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 30px; background: #f6f4ee;
font-family: "Microsoft YaHei", sans-serif; color: #24292f; }
.form { max-width: 320px; }
.err-lab { font-size: 12px; color: #c21818; margin: 0 0 6px; }
/* 抖动: 水平往复, 同时把边框从灰过渡到红; both 让定格能稳定取到首尾进度 */
.fld { animation: shake 1.6s ease-in-out both; }
.fld input { width: 100%; padding: 12px 14px; font-size: 15px;
border: 2px solid #d8d3c7; border-radius: 10px; }
/* 错误文案随进度淡入, 让首尾两帧也明显不同 */
.err { animation: fadein 1.6s linear both; color: #c21818; font-size: 13px; margin-top: 6px; }
@keyframes shake {
0% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(4px); }
100% { transform: translateX(0); }
}
@keyframes fadein { 0% { opacity: 0; } 100% { opacity: 1; } }
</style>
</head>
<body>
<div class="form">
<p class="err-lab">邮箱格式不正确</p>
<div class="fld"><input type="text" value="zhang@bad"></div>
<p class="err">请检查后重新输入</p>
</div>
</body>
</html>

四格里输入框的水平偏移和"请检查后重新输入"这行的深浅都在变,合起来就是抖动 + 标红的完整过程。要抄的参数:shake 位移峰值 ±8px、总时长 .4s、ease-in-out、只播一次。
举一反三:抖动只给真错误
抖动能吸引注意,但它是"高音"。如果你给每一个空必填框都抖动,页面一提交就全体乱晃,反而让人找不到重点。我改完第一版时就手滑给每个空框都挂了抖动,一提交满屏乱颤,活像地震,赶紧删到只留真错误那一条吧。原则很简单:一次交互里最多抖一个元素,且只对"用户确实填错"的字段抖。服务端返回的、提交后才确认的错误,配 :invalid 足够,不必抖。
修复后的定稿把时机分层::user-invalid 管即时反馈,:focus-within 管聚焦高亮,抖动只在提交那一刻由一个 class 触发一次。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>表单动效定稿</title>
<style>
* { box-sizing: border-box; }
body { max-width: 400px; margin: 36px auto; padding: 0 16px;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
.form { background: #fff; border-radius: 14px; padding: 20px; box-shadow: 0 6px 20px rgba(31,45,61,.10); }
h3 { margin: 0 0 14px; font-size: 17px; }
.fld { margin-bottom: 14px; }
.fld label { display: block; font-size: 12px; color: #6b6b66; margin-bottom: 5px; }
.fld input { width: 100%; padding: 12px 14px; font-size: 15px; border: 1.5px solid #d8d3c7;
border-radius: 9px; transition: border-color .18s, box-shadow .18s; }
/* 聚焦高亮: 用 focus-within 让整块表态 */
.fld:focus-within input { border-color: #2563eb; box-shadow: 0 0 0 4px rgba(37,99,235,.15); }
/* 校验时机: 用 user-invalid 避免打字中途就骂人; 老浏览器用 :invalid 兜底 */
.fld input:user-invalid { border-color: #c21818; background: #fff5f5; }
@supports not selector(:user-invalid) { .fld input:invalid { border-color: #c21818; } }
.fld input:user-valid { border-color: #2f8f4e; }
/* 提交时才抖: 一个 class, 只播一次 */
.form.wrong .fld.on { animation: shake .4s ease-in-out 1; }
@keyframes shake { 0%,100% { transform: translateX(0);} 25% { transform: translateX(-8px);} 75% { transform: translateX(8px);} }
.btn { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: #2563eb;
color: #fff; font-size: 15px; cursor: pointer; transition: transform .18s, background .18s; }
.btn:hover { background: #1d4ed8; transform: translateY(-2px); }
.btn:active { transform: translateY(1px) scale(.98); transition-duration: .06s; }
</style>
</head>
<body>
<form class="form wrong" onsubmit="return false">
<h3>登录</h3>
<div class="fld on"><label for="e">邮箱</label><input id="e" type="email" required value="zhang@bad"></div>
<div class="fld"><label for="p">密码</label><input id="p" type="password" required placeholder="8-20 位"></div>
<button class="btn">登录</button>
</form>
</body>
</html>

运行结果里,第一个邮箱框因为带上了 wrong 且自身 on,进来就抖一次标红;第二个还没填的框只等 :user-invalid——不会在你打字中途乱红。
表单里的按钮本身也别忘了昨天那套四态:提交按钮同样要有 :hover / :active / :focus-visible,否则键盘用户走到"登录"上又是一片黑。举一反三的意思就是——同一套状态规则,表单里的每个可交互元素都适用。
<!– 《30天精通CSS》Day 24 · 平头哥 · 技术改变世界,勤奋改变人生 –>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>提交按钮:表单里也要四态齐全</title>
<style>
* { box-sizing: border-box; }
body { max-width: 380px; margin: 40px auto; padding: 0 16px; text-align: center;
font-family: "Microsoft YaHei", sans-serif; background: #f6f4ee; color: #24292f; }
/* 提交按钮复用 Day 23 的时长梯度: 悬停慢, 按下快 */
.btn { width: 100%; padding: 13px; border: 0; border-radius: 10px; background: #2563eb;
color: #fff; font-size: 15px; cursor: pointer; box-shadow: 0 4px 12px rgba(37,99,235,.3);
transition: transform .18s ease-out, box-shadow .18s ease-out, background .18s; }
.btn:hover { background: #1d4ed8; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(37,99,235,.42); }
.btn:active { transform: translateY(1px) scale(.98); transition-duration: .06s; }
/* 键盘到达: 用 outline 画环, 不撑大按钮也不挤布局 */
.btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 3px; }
.tip { font-size: 13px; color: #8a8a86; margin-top: 16px; text-align: left; }
</style>
</head>
<body>
<button class="btn">登录</button>
<p class="tip">点它是浮起, 按住它回落, 用 Tab 走到它身上会亮蓝环 —— 表单里的可交互元素, 一个都不能少。</p>
</body>
</html>

回头看:表单动效的三条边界
抖动参数怎么定,抖给谁看
抖动不是特效,是一句“看这里”。参数上我一般定在位移峰值 ±8px、总时长 .4s、ease-in-out、只播一次;位移再大就显得夸张,时长再长就拖沓。时机上,只给“用户确实填错”的字段抖,而且一次交互里最多抖一个——否则整屏乱晃反而找不到重点。至于那些还没碰过的空 required 字段,老实说不该报红:一个空的手机号框在你还没输入时并不是“非法”,用 :user-invalid 而不是 :invalid 正是为了把这条边界划清。user-invalid 是新一代选择器对“边打边骂”这种误用的替代方案,旧的即时校验写法可以逐步废弃;而 :focus-within 把焦点冒泡到容器这个机制是经典选择器能力,已经稳定多年,属于学一次长期有效的东西。 说到底,表单动效的目标只有一句话:让用户在任何一刻都知道「现在这个框处于什么状态、系统对我做了什么判断」。动效越克制,信息越清晰——好的表单反馈是「读」出来的,不是「看热闹」看出来的啊。这也是为什么今天全篇都在讲时机和边界,而不是堆动画效果。
把今天诊断出的三条收口,这就是表单动效的边界清单:
label 与 placeholder 分工:名字、必填、规则交给 label;格式示例才交给 placeholder。注意 placeholder 的对比度边界,别让它承载长期信息。
聚焦反馈用 ****:focus-within:整块高亮才看得见"我落到哪个字段组了",单靠 :focus 只亮一根线。
校验时机分层::user-invalid 管即时、:invalid 兜底老浏览器、抖动只给提交后的真错误、且一次只抖一个。

这套"聚焦冒泡 + 校验时机"的选择器机制是表单可访问性的地基,值得长期沿用。
手熟靠这三下
表单这块光看没用,动手三下才算数:一,把定稿里 .form.wrong 那个 on 去掉,看第二个还没填的框会不会跟着乱抖——答案是不该抖,你亲眼验一遍;二,把 :user-invalid 换回 :invalid,盯着自己打字中途那个提前变红的刺眼感,再把时机找回来;三,把 placeholder 里塞的字段名抽出来做成真 label,填到一半回头看,名字还在不在。这三下改顺了,登录框那份"该红就红、该安静就安静"的克制劲儿就出来了。
参考资料
-
MDN: :focus-within
-
MDN: :user-invalid
-
W3C: Selectors Level 4
网硕互联帮助中心


评论前必须登录!
注册