表单控件样式化(Form Control Styling)
appearance:none 自绘 checkbox/开关/下拉、accent-color 一行染色原生控件、:focus-visible 与 :user-invalid 无 JS 校验反馈、field-sizing 自适应输入——表单样式化专篇
知识点地图
- 知识类别:表单控件样式化(Styling forms)。表单控件是 CSS 里 最特殊的元素族:它们由浏览器内置渲染(影子组件),普通 CSS 管不到内部结构,需要专门的一组属性与选择器。
- 解决什么问题:checkbox/radio/select 的默认外观与设计系统 格格不入;校验反馈要靠 JS 加 class;输入框宽度写死导致 「占位符被截断」。
- 什么时候用到:任何带表单的页面。现代基线四件套——
appearance(自绘)、accent-color(染色)、:user-invalid(无 JS 校验反馈)、field-sizing(自适应宽度)——覆盖 90% 的表单样式需求。
真实场景:登录表单的三个样式痛点
demo-pages 的登录表单演示页(扫描素材 3.6 节的成品页)有三处 典型痛点,也是几乎每个表单都会撞上的:
- 「记住我」checkbox 是浏览器默认样式,与品牌色无关;
- 密码错误提示靠 JS 监听 input 事件加
.errorclass——有网络 延迟与状态同步成本; - 邮箱输入框
width: 200px写死,换个语言(德语占位符长一倍) 文字被截。
三个痛点分别对应本篇三组技术:accent-color 一行染色(点 1)、
:user-invalid 无 JS 反馈(点 2)、field-sizing: content
自适应(点 3)——先解决 90% 的部分,再讲剩下 10% 的完全自绘。
1. 两条路线:染色原生控件 vs appearance:none 自绘
表单样式化的第一决策不是「怎么写 CSS」,是「用不用原生渲染」:
/* 路线 A:保留原生控件,只染色(一行,成本最低) */
:root {
accent-color: #7c3aed; /* checkbox/radio/range/progress 的选中色 */
}
/* 路线 B:appearance:none 完全自绘(全控制,成本高) */
.checkbox input {
appearance: none; /* 卸掉浏览器内置渲染 */
width: 1.25rem;
aspect-ratio: 1;
border: 2px solid #cbd5e1;
border-radius: .25rem;
display: grid;
place-content: center;
transition: background .15s, border-color .15s;
}
.checkbox input:checked {
background: #7c3aed;
border-color: #7c3aed;
}
.checkbox input:checked::before {
content: "";
width: .9rem;
height: .5rem;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg) translateY(-15%);
}逐行讲解:appearance: none 之后 input 变成一块白布——内置的
勾、圆点全部消失,必须自己画(::before 勾出对勾的两条边);
place-content: center 让内部网格居中(勾的位置稳定性靠它);
aspect-ratio: 1 保证方形随宽度缩放。换成别的写法会发生什么:
不用 appearance 直接改 background/border,Chromium 会画出一个
「半原生半自定义」的缝合怪(内置勾还在上面)——appearance:none
是自绘的前提不是可选项。路线选择:整页只用 checkbox/radio/range
的选中态染色,路线 A 成本为零;控件要改形状(圆角开关、胶囊 radio)
才上路线 B。
2. 开关(switch):appearance 自绘的完整案例
设置页的开关组是自绘频率最高的控件:
.switch input {
appearance: none;
width: 2.75rem;
height: 1.5rem;
border-radius: 999px;
background: #d1d5db;
position: relative;
transition: background .2s;
cursor: pointer;
}
.switch input::after {
content: "";
position: absolute;
inset: .1875rem auto .1875rem .1875rem; /* 拇指停靠左侧 */
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: #fff;
transition: translate .2s;
}
.switch input:checked {
background: #16a34a;
}
.switch input:checked::after {
translate: 1.25rem 0; /* 拇指滑到右侧 */
}逐行讲解:拇指(::after)用绝对定位 + inset 锚定左侧,选中态
只改 translate——动画只有 transform 合成属性,60fps 无忧(540
性能篇的合成层原则);inset 的四值写法 上 右 下 左 中右值设
auto 让 thumb 宽度由自身决定。易错点:真机测试键盘焦点——
appearance:none 后部分浏览器焦点环变淡,必须配 :focus-visible
补一个明显的焦点样式(与 500-AccessibleStyling 联动):
.switch input:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 2px;
}3. 无 JS 校验反馈::user-invalid 与 :user-valid
input:invalid { /* 坑:页面刚加载就红 */
}
input:user-invalid { /* 正解:仅在用户交互后判定 */
border-color: #dc2626;
background: #fef2f2;
}
input:user-invalid + .hint { visibility: visible; }
.form-row {
display: grid;
gap: .25rem;
}
.hint {
visibility: hidden; /* 默认隐藏提示 */
color: #dc2626;
font-size: .8125rem;
}<div class="form-row">
<label for="email">邮箱</label>
<input id="email" type="email" required autocomplete="email">
<p class="hint">请输入有效邮箱地址</p>
</div>逐行讲解::invalid 的判定是纯状态——required 输入框在用户
敲第一个字之前就「无效」,页面加载即满屏红是 :invalid 的经典事故;
:user-invalid 加了「用户已交互」(输入过并失焦,或已输入内容)的
前置条件,错误样式只在真正犯错后出现——校验反馈的时机问题
用选择器解决,不需要一行 JS。兄弟选择器 + .hint 让提示跟随
自己的输入框(对照 JS 方案要维护 DOM 查询与事件监听)。
注意::user-invalid 的触发时机各浏览器略有差异(有的在
blur、有的在输入改变后),关键表单在真实浏览器矩阵里过一遍。
:focus-visible 补全可访问性:鼠标点击不出焦点环、键盘 Tab
出焦点环,浏览器自动判断——永远给自定义样式的控件补
:focus-visible,否则键盘用户找不到焦点(500 篇的硬规则)。
4. field-sizing 与 caret-color
/* 输入框随内容伸缩(Chrome 123+) */
input[type="text"], textarea {
field-sizing: content;
min-width: 12ch; /* 下限:空值时不至于只剩光标 */
max-width: 100%;
}
textarea {
field-sizing: content;
min-height: 3lh; /* 至少三行高,lh 单位随行高缩放 */
max-height: 12lh;
}
/* 光标颜色:品牌色贯穿输入体验 */
input { caret-color: #7c3aed; }逐行讲解:field-sizing: content 让输入框宽度/高度跟随内容
而不是 size 属性——占位符多长框就多长(登录表单的痛点 3 就此
消失);评论框配 min-height/max-height 的 lh 单位(一行文本
的高度)实现「随内容长高、封顶后内滚」,替代几十行的 JS 自动
伸缩。降级:不支持的浏览器退回普通定宽框,属于渐进增强,
无破坏性。caret-color 顺带讲:光标是「文字输入的品牌触点」,
默认黑色在深色输入框里看不见——深色主题必查项。
5. 下拉框(select)的现实边界
select {
appearance: none;
padding: .5rem 2.25rem .5rem .75rem;
border: 1px solid #cbd5e1;
border-radius: .375rem;
background: url("data:image/svg+xml,...chevron-down...") no-repeat right .75rem center / 1rem;
}
select:invalid { color: #9ca3af; } /* 未选时的占位色 */逐行讲解:appearance: none 后 select 只剩一个矩形,右侧箭头
自己画(内联 SVG data URI 免请求);但展开后的选项面板
(option list)仍由操作系统渲染——这是 select 自绘的硬边界,
要完全控制选项外观只有两条路:接受原生面板(推荐,移动端体验
反而更好)或上列表组件(headless UI,带完整的键盘与 ARIA 责任,
联动 500 篇)。工程判断:能用 select 就不用自造下拉——
原生面板在手机上是 OS 级控件,可及性白送。
6. 完整示例:无 JS 校验的登录表单
把三个痛点全解掉的最小登录表单(对齐 demo-pages 同款布局):
<style>
:root { --brand: #7c3aed; }
form { display: grid; gap: 1rem; max-width: 320px; }
.row { display: grid; gap: .25rem; }
input:not([type="checkbox"]) {
field-sizing: content; min-width: 100%;
padding: .5rem .75rem;
border: 1px solid #cbd5e1; border-radius: .375rem;
caret-color: var(--brand);
transition: border-color .15s;
}
input:user-invalid {
border-color: #dc2626;
background: #fef2f2;
}
input:focus-visible {
outline: 2px solid var(--brand); outline-offset: 1px;
}
.row:has(input:user-invalid) .hint { visibility: visible; }
.hint { visibility: hidden; color: #dc2626; font-size: .8125rem; }
label.remember { display: flex; gap: .5rem; align-items: center; }
input[type="checkbox"] { accent-color: var(--brand); width: 1rem; }
button {
background: var(--brand); color: #fff;
padding: .625rem; border: 0; border-radius: .375rem;
cursor: pointer;
}
</style>
<form>
<div class="row">
<label for="email">邮箱</label>
<input id="email" name="email" type="email" required autocomplete="email">
<p class="hint">请输入有效邮箱</p>
</div>
<div class="row">
<label for="pwd">密码</label>
<input id="pwd" name="pwd" type="password" required minlength="8" autocomplete="current-password">
<p class="hint">密码至少 8 位</p>
</div>
<label class="remember"><input type="checkbox" checked> 记住我</label>
<button type="submit">登录</button>
</form>逐段讲解::has(input:user-invalid) 把提示的显隐绑在「本行
输入框的状态」上——:has 父选择器让「提示跟随输入框」不需要
兄弟相邻(label 在 input 前面的结构也能工作);checkbox 走路线 A
(accent-color 一行),输入框走无 JS 校验,整个表单的交互反馈
零 JS。提交前校验仍建议保留 JS 兜底(服务端校验不可省,500 篇
的可访问性与 090 的错误链路一并 review)。
常见陷阱与调试
- 坑 1:
:invalid上来就红。 用:user-invalid(或老的:not(:placeholder-shown):invalid技巧)把判定推迟到交互后。 - 坑 2:appearance:none 只写了 input 忘了控件族差异。 checkbox
与 radio 的 appearance 属性各写各的(
input[type=radio]单独 一条),共用选择器会把 radio 也画成方块。 - 坑 3:自绘后丢了状态可视化。
:disabled、:checked:disabled的置灰也要自绘一份——设计系统组件把这些状态列成 checklist。 - 坑 4:number input 的 spinner。
appearance: textfield(或 none)隐藏上下箭头;只用 CSS 藏视觉,键盘上下键仍生效—— 这是特性不是 bug。 - 坑 5:field-sizing 与 width 冲突。 content 模式下 width 是 「最大值语义」,想定宽就别开 content——两者选一。
动手实践
任务:把第 6 节的登录表单升级为「设置页表单组」,覆盖本篇 全部属性并做可访问性自检。
- 开关组:三个 appearance 自绘开关(通知/深色/自动更新),
带完整
:focus-visible; - 评论框:
field-sizing: content+min/max-height的 lh 限制- 字数下限校验(
:user-invalid);
- 字数下限校验(
- 范围滑杆:
accent-color染色 + 当前值联动显示(这个联动用 一行 JS 或纯 CSS 计数器,二选一); - 自检:只用键盘走完整个表单(Tab 顺序、焦点环、错误触发时机);
- 把
--brand换成深色主题色值,检查 caret-color 与 user-invalid 配色在深色下的可读性。
提示:开关是第 2 节代码的复用;评论框 minlength=10 配
:user-invalid;滑杆联动纯 CSS 方案用 @counter-style 不现实,
老实写一行 input.addEventListener("input", e => out.value = e.target.value),
并体会「表单样式化 90% 无 JS,剩下 10% 别硬凑」。
参考实现(先自己写,再展开对照)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
:root { --brand: #7c3aed; --danger: #dc2626; }
body { font-family: system-ui; max-width: 420px; margin: 2rem auto; }
.setting { display: flex; justify-content: space-between; align-items: center;
padding: .75rem 0; border-bottom: 1px solid #eee; }
/* 开关:appearance 自绘 */
.switch input {
appearance: none; width: 2.75rem; height: 1.5rem;
border-radius: 999px; background: #d1d5db;
position: relative; cursor: pointer; transition: background .2s;
}
.switch input::after {
content: ""; position: absolute;
inset: .1875rem auto .1875rem .1875rem;
width: 1.125rem; height: 1.125rem; border-radius: 50%;
background: #fff; transition: translate .2s;
}
.switch input:checked { background: #16a34a; }
.switch input:checked::after { translate: 1.25rem 0; }
.switch input:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
/* 评论框:自适应 + 校验 */
.row { display: grid; gap: .25rem; margin-top: 1.5rem; }
textarea {
field-sizing: content; min-height: 3lh; max-height: 12lh;
padding: .5rem .75rem; border: 1px solid #cbd5e1; border-radius: .375rem;
caret-color: var(--brand); resize: vertical;
}
textarea:user-invalid { border-color: var(--danger); background: #fef2f2; }
.hint { visibility: hidden; color: var(--danger); font-size: .8125rem; }
.row:has(textarea:user-invalid) .hint { visibility: visible; }
/* 滑杆:染色 + 联动 */
.range-row { display: flex; gap: .75rem; align-items: center; margin-top: 1.5rem; }
input[type="range"] { accent-color: var(--brand); flex: 1; }
</style>
</head>
<body>
<h2>设置</h2>
<div class="setting"><span>桌面通知</span>
<label class="switch"><input type="checkbox" checked></label></div>
<div class="setting"><span>深色模式</span>
<label class="switch"><input type="checkbox"></label></div>
<div class="setting"><span>自动更新</span>
<label class="switch"><input type="checkbox" checked></label></div>
<div class="row">
<label for="comment">反馈(至少 10 字)</label>
<textarea id="comment" minlength="10" required></textarea>
<p class="hint">请至少输入 10 个字符</p>
</div>
<div class="range-row">
<label for="vol">音量</label>
<input id="vol" type="range" min="0" max="100" value="60">
<output id="volOut">60</output>
</div>
<script>
const vol = document.getElementById("vol");
vol.addEventListener("input", () =>
document.getElementById("volOut").textContent = vol.value);
</script>
</body>
</html>逐段讲解:三个开关是第 2 节的逐字复用——复用即验证「设计
系统组件一次写对、处处粘贴」的价值;评论框的 min-height: 3lh
配 field-sizing: content 实现「空框三行、写多少长多少、12 行
封顶内滚」;键盘自检路径:Tab 依次落在三个开关 -> 空格切换
(原生 checkbox 行为保留,appearance 不影响语义)-> 评论框 -> 滑杆
方向键——第 4 步做下来顺畅说明状态与焦点没丢,卡壳处就是
自绘漏掉的状态;深色主题测试要点:#fef2f2 错误背景在深色页
刺眼,主题化时把错误色也做成变量成对替换。
参考与致谢
- MDN:Styling forms 系列与 appearance / accent-color / :user-invalid / field-sizing / caret-color 属性页 (https://developer.mozilla.org/docs/Learn_web_development/Extensions/Forms/Styling_web_forms ,CC-BY-SA 2.5)
- CSS UI(css-ui-4)规范(https://drafts.csswg.org/css-ui-4/ ,W3C 文档许可)
- 场景素材:本仓库扫描素材 demo-pages 登录表单演示页(3.6 节)