给 <input type=range> 上妆——track / thumb 伪元素
range slider 是少数「能用 CSS 改样子、却不能用 JS 操作内部结构」的原生控件:它在浏览器内部由 track(滑轨)与 thumb(滑块)两部分组成,两者各藏在 shadow DOM 里,只能通过厂商私有伪元素选中样式,无法 querySelector 到、也挂不上事件。本页从最省事的
accent-color 一路走到完全重绘,把跨浏览器的几个固定坑(伪元素不能合并写、进度填充各家不同、input 与 change 的分工)逐一实测,最后叠两个 input 拼一个原生没有的双向区间(min–max)slider。
1 · 解剖:一根 track + 一颗 thumb
下面是一个未加任何样式的原生 slider。它渲染出的两个部件——横贯的 track 与可拖动的 thumb——在 DOM 树里都不可见:slider.children 是空的,你拿不到它们的节点。要改外观,只能用各浏览器的伪元素选择器:WebKit / Blink 用 ::-webkit-slider-runnable-track 与
::-webkit-slider-thumb,Firefox 用 ::-moz-range-track 与 ::-moz-range-thumb。
track 与 thumb 不在主 DOM 里。它们由浏览器以原生绘制 / shadow DOM 实现,因此既选不中节点、也挂不上事件;想做 tooltip、想读 thumb 的坐标,都得绕路(监听 slider 自身的 input,再按 value 百分比自己算位置)。能直接拿到的只有 slider
这一个元素和它的 value / min / max / step。
2 · accent-color:只想换个颜色时的一行写法
如果需求只是「把 slider 染成品牌色」,根本不必碰伪元素。一条 accent-color 就能同时给 thumb 和已填充的 track 上色,且跨浏览器一致、保留原生的尺寸与可访问性。拖下面的色板换色:
能用 accent-color 就别重绘。它是「换色」这一档需求的正解:不破坏原生交互、不需要处理跨浏览器伪元素、自动适配 high-contrast / 暗色模式。只有当需要改尺寸、形状、轨道粗细、加阴影等结构性外观时,才升级到下面的完全重绘。
3 · 完全重绘:appearance: none + 厂商伪元素
要彻底换形,第一步是给 slider 自身设 appearance: none(WebKit 还需 -webkit-appearance: none),去掉原生外观;然后分别给各厂商的 track / thumb 伪元素写样式。下面这个 slider 的 thumb 直径、轨道高度可现场调:
致命陷阱:厂商伪元素不能合并进一个选择器列表。许多人会为省事写 input::-webkit-slider-thumb, input::-moz-range-thumb { … }——这会让整条规则失效。CSS 的规矩是:选择器列表里只要有一个 token
不被当前浏览器认识,整条规则连同后面认识的部分一起被丢弃。所以 thumb / track 的样式必须按厂商各写一遍,哪怕声明体一模一样,也只能复制粘贴(或用构建期的预处理器展开)。
4 · 进度填充:把已选区间染色
原生 slider 默认整条 track 同色。想让 thumb 左侧(已选部分)显示填充色,各家做法不同:Firefox 提供专门的 ::-moz-range-progress 伪元素,直接上色即可;WebKit / Blink 没有对应伪元素,只能在 track 背景上叠一层 linear-gradient,并用 JS 把当前百分比写进一个 CSS
变量来驱动断点。拖动下面这个 slider,填充会实时跟随:
WebKit 用 gradient,Firefox 用原生 progress:
.rng::-webkit-slider-runnable-track {
background: linear-gradient(var(--fill), var(--fill))
no-repeat, #f3c6e6;
background-size: var(--p, 50%) 100%;
}
.rng::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: var(--fill);
}
JS:把百分比写进 CSS 变量:
function sync() {
const pct = (el.value - el.min) / (el.max - el.min) * 100;
el.style.setProperty('--p', pct + '%');
}
el.addEventListener('input', sync);
sync();
5 · input vs change:拖动时到底谁在响
slider 拖动会接连派发两类事件,分工固定:input 在 value 每一次变化时都触发(拖动过程中连续来),适合实时预览;change 只在松手 / 提交那一刻触发一次,适合落库、发请求等较重的操作。拖一下下面的 slider,看右侧日志里 input 连发、change
收尾:
重活要节流,且别只听 change。拖动一次会瞬间打出几十个 input,若每个都触发昂贵计算(重排、网络请求)会卡顿——给 input 处理器套个 debounce / throttle 即可。早年有「同时监听 input 与 change 兜底」的写法,那是为了应对老 IE 不发 input 的缺陷;现代浏览器
input 已可靠触发,按语义选事件即可。
6 · 双向 range:原生没有,得叠两个 input
原生 <input type=range> 只有一颗 thumb,只能选单值。要做「价格区间 / min–max 筛选」这种双向区间,标准做法是把两个 range 叠在同一条轨道上:背后垫一条公共 rail 与一段表示选中区间的 fill,前面盖两个透明轨道的
input(一个管下界、一个管上界)。两处关键技巧——其一,给 input 设 pointer-events: none、只给 thumb 伪元素设 pointer-events: auto,让点击穿过透明轨道、精准落到某颗 thumb;其二,在 JS 里做夹取(下界 thumb 不得越过上界,反之亦然)并按两值算出 fill 的
left / width。拖两颗 thumb 试试:
两处关键:thumb 单独放行点击 + JS 夹取防交叉:
.dual-input { pointer-events: none; }
.dual-input::-webkit-slider-thumb { pointer-events: auto; }
.dual-input::-moz-range-thumb { pointer-events: auto; }
function render() {
let lo = +rMin.value, hi = +rMax.value;
if (lo > hi) {
if (document.activeElement === rMin) rMin.value = hi;
else rMax.value = lo;
}
fill.style.left = pct(lo) + '%';
fill.style.width = (pct(hi) - pct(lo)) + '%';
}
dual.addEventListener('pointerdown', (e) => {
if (e.target === rMin || e.target === rMax) return;
const v = valueAt(e.clientX);
const t = v < +rMin.value ? rMin
: v > +rMax.value ? rMax
: (v - +rMin.value <= +rMax.value - v ? rMin : rMax);
t.value = v; t.focus(); render();
});
点轨道跳转得自己路由。pointer-events: none 让透明轨道放行点击的同时,也顺手关掉了原生「点 track 跳 thumb」:单值 slider 点轨道任意处 thumb 会跳过去,这里点轨道却没反应——因为两个 input 都铺满全宽且叠在一起,直接给它们开
pointer-events,一次点击会让最上层那个 input 的 thumb 横跨整条轨道(左侧一点可能把上界 thumb 拽到底而交叉)。这不是只能如此:把点击监听挂到容器上、由 JS 按落点算出值并路由给更近的那颗 thumb(落在区间外则给对应一端),即可恢复点轨道跳转——上面这个 demo 已这么做,点轨道试试。
另外两个细节。其一,两颗 thumb 贴到一起时,后绘制(z-index 高)的那颗会盖住另一颗、压住的拖不动——解法是按住哪颗就把哪颗抬到最上层(pointerdown 时调高当前 input 的 z-index、另一个调低)。其二,纯百分比定位的 fill 与 thumb 在两端会有半个 thumb 的微小偏差(原生 thumb 在轨道两端各内缩了半径),要像素级对齐需按 thumb 半径补偿,区间筛选场景通常可忽略。
7 · 相关链接
-
Styling and scripting sliders · quirksmode.org——本页的出处:Peter-Paul Koch 系统梳理 range slider 的跨浏览器 track / thumb 伪元素、进度填充、tooltip 与事件兜底。文章成于 2015 年,其中
-ms-*一系(旧 Edge / IE)随 Edge 转向 Chromium 已退场,track / thumb 模型与「伪元素不能合并」这一核心结论至今成立。 -
MDN ·
<input type="range">· developer.mozilla.org——range 控件的权威参考:min/max/step/value语义,以及厂商伪元素与样式化要点。 - MDN · accent-color · developer.mozilla.org——对应「只换颜色」一节:一条属性给 range / checkbox / radio / progress 统一着色,保留原生交互。
- field-sizing:textarea 一行 CSS 自动长高 · 本系列——同属「原生表单控件的边界行为」:另一个「一条现代 CSS 取代过去一堆 JS」的例子。