前置知识: HTML5

表单控件样式化(Form Control Styling)

8 min入门

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 节的成品页)有三处 典型痛点,也是几乎每个表单都会撞上的:

  1. 「记住我」checkbox 是浏览器默认样式,与品牌色无关;
  2. 密码错误提示靠 JS 监听 input 事件加 .error class——有网络 延迟与状态同步成本;
  3. 邮箱输入框 width: 200px 写死,换个语言(德语占位符长一倍) 文字被截。

三个痛点分别对应本篇三组技术:accent-color 一行染色(点 1)、 :user-invalid 无 JS 反馈(点 2)、field-sizing: content 自适应(点 3)——先解决 90% 的部分,再讲剩下 10% 的完全自绘。

1. 两条路线:染色原生控件 vs appearance:none 自绘

表单样式化的第一决策不是「怎么写 CSS」,是「用不用原生渲染」:

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 自绘的完整案例

设置页的开关组是自绘频率最高的控件:

css
.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 联动):

css
.switch input:focus-visible {
  outline: 2px solid #7c3aed;
  outline-offset: 2px;
}

3. 无 JS 校验反馈::user-invalid 与 :user-valid

css
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;
}
html
<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

css
/* 输入框随内容伸缩(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)的现实边界

css
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 同款布局):

html
<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 节的登录表单升级为「设置页表单组」,覆盖本篇 全部属性并做可访问性自检。

  1. 开关组:三个 appearance 自绘开关(通知/深色/自动更新), 带完整 :focus-visible;
  2. 评论框:field-sizing: content + min/max-height 的 lh 限制
    • 字数下限校验(:user-invalid);
  3. 范围滑杆:accent-color 染色 + 当前值联动显示(这个联动用 一行 JS 或纯 CSS 计数器,二选一);
  4. 自检:只用键盘走完整个表单(Tab 顺序、焦点环、错误触发时机);
  5. 把 --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% 别硬凑」。

参考实现(先自己写,再展开对照)
html
<!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 错误背景在深色页 刺眼,主题化时把错误色也做成变量成对替换。

参考与致谢