写在前面
昨天做了展开式搜索框,label包裹input、:focus-within开合、checkbox锁定。
今天的组件,是表单里最容易被忽略、也最难美化的一件:滑块。
浏览器默认的range输入框,是组件惊艳化里最扎眼的出厂件。
灰色轨道、灰色小圆钮、拖动时没有任何反馈,放在任何一个设计过的界面里都像是一个bug。
今天把它拆到最底层:轨道、拇指、填充段,三层全部手绘。webkit和Firefox两套伪元素体系一次讲全。
填充比例要实时跟随拖动,这需要一行JS。除此之外,全部是CSS。
这一篇也是本专栏第一次做“原生控件换皮”。
原生控件有自己的默认样式、自己的伪元素体系、自己的兼容性陷阱。做对了,比从头造一个更稳。
一、先看效果
深色声音设置卡片。
两根滑块纵向排列,间距够宽。
第一根:主音量,翡翠绿。左侧一个喇叭图标,右边数值60%。轨道6px圆角,已填充段是翡翠绿,未填充段是深灰,滑块是一枚白色圆钮带外圈。轨道下方三个刻度:0、50、100。
第二根:低音增强,天蓝。同结构,数值35%。
拖动时:白钮跟着手走,左侧填充段同步变长变短,右侧数值实时更新。按下去的时候,白钮外圈从3px扩散到6px,观感像真的按下去了一点。
Chrome和Edge走webkit体系,Firefox走-moz原生进度填充。两边都做了,观感一致。
怎么跑起来:
- 新建一个
demo.html - 把下面第二节的完整代码粘进去
- 双击用浏览器打开
- 拖到一半松手试试
截图党提示:建议做Before / After对比图。左半放浏览器默认滑块截图,右半放手绘滑块拖动GIF。反差即点击理由。
二、完整代码(复制即用)
<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Day 40 · 自定义滑块</title><style>*{margin:0;padding:0;box-sizing:border-box;}html, body{height:100%;}body{display:flex;align-items:center;justify-content:center;background:#10151c;font-family:"PingFang SC",sans-serif;color:#e6edf3;padding:20px;}.card{width:420px;background:#161d26;border-radius:18px;padding:28px;border:1px solidrgba(255,255,255,.08);box-shadow:0 26px 60pxrgba(0,0,0,.45);}.t{font:400 10px/1"SF Mono",Consolas,monospace;letter-spacing:.3em;color:#5b6878;}h2{margin-top:12px;font-size:17px;font-weight:600;letter-spacing:.04em;}.row{margin-top:26px;}.row .head{display:flex;align-items:center;gap:9px;margin-bottom:14px;font-size:13.5px;color:#c7d1de;}.row .head .val{margin-left:auto;font:500 13px/1"SF Mono",Consolas,monospace;color:var(--fill);}.row svg{flex:none;}/* —— 滑块:webkit / moz 双体系 —— */input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:20px;background:transparent;cursor:pointer;--v:60%;}input[type="range"].r2{--v:35%;}input[type="range"]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:linear-gradient(90deg,var(--fill)var(--v),#26303dvar(--v));}input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;margin-top:-7px;/* (track 6 - thumb 20) / 2 = -7 */box-shadow:0 2px 8pxrgba(0,0,0,.45),0 0 0 3pxrgba(255,255,255,.08);}input[type="range"]:active::-webkit-slider-thumb{box-shadow:0 2px 8pxrgba(0,0,0,.45),0 0 0 6pxrgba(92,201,180,.18);}input[type="range"]::-moz-range-track{height:6px;border-radius:3px;background:#26303d;}input[type="range"]::-moz-range-progress{height:6px;border-radius:3px;background:var(--fill);}input[type="range"]::-moz-range-thumb{width:20px;height:20px;border:0;border-radius:50%;background:#fff;box-shadow:0 2px 8pxrgba(0,0,0,.45);}.r1{--fill:#5cc9b4;}.r2{--fill:#7ab8ff;}.scale{display:flex;justify-content:space-between;margin-top:8px;font:400 9px/1"SF Mono",Consolas,monospace;letter-spacing:.14em;color:#39434f;}.hint{margin-top:24px;font:400 10px/1"SF Mono",Consolas,monospace;letter-spacing:.24em;color:#39434f;}.tag{position:fixed;right:18px;bottom:14px;font:400 10px/1"SF Mono",Consolas,monospace;letter-spacing:.28em;color:rgba(230,237,243,.22);}</style></head><body><divclass="card"><divclass="t">RANGE · T01</div><h2>声音设置</h2><divclass="row"><divclass="head"><svgwidth="15"height="15"viewBox="0 0 24 24"fill="none"stroke="#5cc9b4"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"><pathd="M11 5 6 9H3v6h3l5 4z"/><pathd="M15.5 8.5a5 5 0 0 1 0 7"/></svg>主音量<spanclass="val">60%</span></div><inputclass="r1"type="range"min="0"max="100"value="60"><divclass="scale"><span>0</span><span>50</span><span>100</span></div></div><divclass="row"><divclass="head"><svgwidth="15"height="15"viewBox="0 0 24 24"fill="none"stroke="#7ab8ff"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"><pathd="M11 5 6 9H3v6h3l5 4z"/><pathd="M19 5a9 9 0 0 1 0 14"/></svg>低音增强<spanclass="val">35%</span></div><inputclass="r2"type="range"min="0"max="100"value="35"><divclass="scale"><span>0</span><span>50</span><span>100</span></div></div><divclass="hint">FIREFOX 走 ::-moz-range-progress 原生填充 · 拖到一半松手试试</div></div><divclass="tag">每天一个惊艳 CSS · DAY 40</div><script>// +1 行 JS:拖动时把当前值写进 --v,填充段与拇指实时同步document.querySelectorAll('input[type=range]').forEach(s=>s.addEventListener('input',()=>{s.style.setProperty('--v',s.value+'%');s.closest('.row').querySelector('.val').textContent=s.value+'%';}));</script></body></html>三、三个核心技法拆解
今天这三个技法,从双体系到对齐到变量接口,一层一层往上走。
技法1:双伪元素体系,webkit与-moz各谈各的
range 的可定制部分分属两套伪元素体系。
Chromium 和 Safari 走 webkit 体系:::-webkit-slider-runnable-track是轨道,::-webkit-slider-thumb是拇指。
Firefox走moz体系:::-moz-range-track是轨道,::-moz-range-progress是已填充段,::-moz-range-thumb是拇指。
两边都要写。缺一边就是半残。
/* webkit 体系 */input[type="range"]::-webkit-slider-runnable-track{/* 轨道 */}input[type="range"]::-webkit-slider-thumb{/* 拇指 */}/* moz 体系 */input[type="range"]::-moz-range-track{/* 轨道 */}input[type="range"]::-moz-range-progress{/* 已填充段 */}input[type="range"]::-moz-range-thumb{/* 拇指 */}Firefox比webkit多一个“已填充段”伪元素。
::-moz-range-progress是“从最小值到当前值”的那一段,Firefox原生提供,不需要自己画。
webkit没有这个伪元素。
在Chrome里,“已填充段”要靠渐变硬切来做,这是下一个技法的事。
Firefox的-moz-range-progress反而比webkit省事。这是“两套体系各有优劣”的典型案例。
写跨浏览器组件的时候,不要假设一套体系一定比另一套简单,两边都要看。
还有一个必写的属性:appearance: none。
input[type="range"]{-webkit-appearance:none;appearance:none;}webkit和标准两侧都要置none。否则系统皮肤会叠在手绘层上,你画的轨道、拇指都被系统默认样式盖住。
这是“原生控件换皮”的第一道关。任何input、button、select想自己画样式,都要先appearance: none。
技法2:webkit拇指的margin-top陷阱
这是自定义滑块90% 的“对不齐”问题的根源。
webkit体系里,thumb默认与track顶部对齐。轨道高度是6px,拇指高度是20px。如果不管它,拇指会吊在轨道上方,看起来像“漂浮的圆钮”,不是“嵌在轨道里的滑块”。
要把居中修正手动算回来:
input[type="range"]::-webkit-slider-thumb{width:20px;height:20px;margin-top:-7px;}margin-top = (轨道高 − 拇指高) / 2。
本例:(6 − 20) / 2 = -7px。
负值把拇指往上提7px,正好让拇指的中心对齐轨道的中心。
不写这行,拇指会吊在轨道上方。
这是自定义滑块的头号问题,也是很多教程一笔带过、新手反复踩的坑。
记住这个公式:
margin-top = (track 高度 − thumb 高度) / 2
轨道6px、拇指20px,就是(6 - 20) / 2 = -7px。
轨道8px、拇指24px,就是(8 - 24) / 2 = -8px。
轨道4px、拇指16px,就是(4 - 16) / 2 = -6px。
这是webkit体系的“数学税”—— 你必须自己算居中,浏览器不会帮你。
moz体系没这个问题,-moz-range-thumb默认就居中。这是两套体系的差异,写的时候要分开处理。
技法3:填充段等于渐变加1行JS实时接口
webkit 没有“已填充段”伪元素,填充段要靠渐变硬切来做:
input[type="range"]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:linear-gradient(90deg,var(--fill)var(--v),#26303dvar(--v));}linear-gradient(90deg, var(--fill) var(--v), #26303d var(--v))读一遍:
- 从0到
--v:用--fill颜色(品牌色) - 从
--v到100%:用#26303d颜色(深灰)
--v是分界点。--v: 60%时,前60%是品牌色,后40%是深灰。
这就是“已填充段”和“未填充段”的视觉分割。
--v从哪来?
默认值写在input[type="range"]上:
input[type="range"]{--v:60%;}input[type="range"].r2{--v:35%;}拖动时,一行JS把当前值写进去:
s.style.setProperty('--v',s.value+'%');s.value是当前值(0 到 100),加%变成百分比字符串,写进--v变量。
渐变实时更新,填充段跟着手指走。
这就是“静态是姿态,变量是接口”—— CSS只关心“--v是多少”,JS只关心“--v怎么变”。两者解耦,各司其职。
为什么用1行JS,而不是纯CSS?
因为input[type="range"]的当前值,CSS拿不到。
:valid只知道“是否合法”,不知道“具体是多少”。
attr()读不到value属性,因为用户拖动改变的是“属性值”,不是“HTML上的value属性”。
纯CSS拿不到“输入值的具体数值”,这是CSS的边界。
1行JS就是把这个值暴露给CSS的最短路径。
能少用一行就少用一行,是“组件惊艳化”的态度。
四、踩坑位
① 漏appearance: none
/* 错:系统皮肤叠在手绘层上 */input[type="range"]{/* 没有 appearance */}/* 对:webkit和标准两侧都置none */input[type="range"]{-webkit-appearance:none;appearance:none;}appearance: none是“原生控件换皮”的第一道关。不写,你画的轨道和拇指都被系统默认样式盖住。
webkit和标准两侧都要置none。这是所有input、button、select自定义样式的通用前提。
② thumb忘margin-top
/* 错:拇指吊在轨道上方,看起来像漂浮的圆钮 */input[type="range"]::-webkit-slider-thumb{width:20px;height:20px;}/* 对:手动修正居中 */input[type="range"]::-webkit-slider-thumb{width:20px;height:20px;margin-top:-7px;}见技法2,对不齐的头号元凶。
margin-top = (track 高度 − thumb 高度) / 2。
不写这行,拇指会吊在轨道上方。这是自定义滑块90%的“对不齐”问题。
记住这个公式,就不会再踩。
③input自身高度塌陷
/* 错:appearance: none后input高度归零,拇指没有活动区 */input[type="range"]{appearance:none;}/* 对:显式给高度,给拇指留活动区 */input[type="range"]{appearance:none;height:20px;}appearance: none之后,input自身高度归零。拇指虽然视觉上显示,但可拖动的区域只有0高度,鼠标点不中。
显式给height: 20px,给拇指留活动区。
这是“视觉尺寸”和“可操作尺寸”分离的通用做法。
视觉上轨道只有6px,但input本身要20px高,鼠标好点中。
可操作区域比视觉区域大,是移动端友好的底线。
④ 触控目标太小
/* 错:视觉轨道6px,可拖动区域也只有6px */input[type="range"]{height:6px;}/* 对:视觉6px,可操作20px */input[type="range"]{height:20px;}input[type="range"]::-webkit-slider-runnable-track{height:6px;}视觉轨道6px没关系,但input的点击区保持20px高,拇指20px。
移动端可拖是底线。手指比鼠标粗,6px的轨道根本点不中。
这是“视觉”和“交互”分离的通用心法—— 视觉可以精致,交互区域必须足够大。
⑤ 拖动时加transition
/* 错:拇指跟手场景下transform过渡会造成拖手延迟 */input[type="range"]::-webkit-slider-thumb{transition:transform .3s;}/* 对:只对box-shadow做过渡,位置不过渡 */input[type="range"]::-webkit-slider-thumb{transition:box-shadow .2s;}拇指跟手的场景,transform过渡会造成“拖手延迟”。
拖动时,拇指应该跟着鼠标走,不该有过渡。
过渡只用在box-shadow上——按下去的时候外圈扩散,松手的时候收回。
“跟手的东西不能有过渡”—— 这是所有拖拽类交互的通用原则。
⑥ 两套体系只写一套
/* 错:只写webkit,Firefox里滑块还是默认样式 */input[type="range"]::-webkit-slider-runnable-track{/* ... */}/* 对:两套都写 */input[type="range"]::-webkit-slider-runnable-track{/* ... */}input[type="range"]::-moz-range-track{/* ... */}只写webkit体系,Firefox里滑块还是默认样式。用户切到Firefox,观感崩一半。
两套都写,跨浏览器观感一致。
这是“跨浏览器组件”的通用原则—— 每个浏览器有自己的一套,都要照顾到。
五、变式挑战(10–30分钟,动手才有收获)
任务:竖向滑块。
现代写法一行:
input[type="range"]{writing-mode:vertical-lr;direction:rtl;}writing-mode: vertical-lr让range竖向书写,direction: rtl让值从下往上。宽高互换、轨道竖走。
老浏览器用-webkit-appearance: slider-vertical兼容,但现在已经很少需要。
效果是一条竖向调音台推子。上下拖动,填充段从下往上。
竖向滑块适合调音台、音量条、亮度条这类“从下往上”的场景。
进阶方向(选做):
一是给拇指加数值气泡。拖动时拇指上方浮出一个气泡显示当前值,气泡位置随--v移动。
.bubble{transform:translateY(calc(-20px -var(--v)* 0.01 * 20px));}--v是百分比,0.01 * 20px是“每1%移动0.2px”,加起来就是气泡相对拇指的偏移。
对比滑块“分割线位置随--p移动”的同类思路。
二是加“双滑块”。两个拇指在一根轨道上,选一个范围。需要JS处理“哪个拇指在前”,代码量翻倍。
三是加“刻度线”。轨道下方画一排小刻度,每隔10%一个,像调音台上的刻度。用repeating-linear-gradient画。
四是换成“环形滑块”。轨道是一个圆环,拇指沿着圆环转。这是“音量旋钮”的常见做法,和线性滑块是两种不同的交互模型。
六、小结
今天这一篇里藏着6个通用技能点:
| 技能 | 适用场景 |
|---|---|
appearance: none清空默认样式 | 所有原生控件自定义 |
| webkit / moz双伪元素体系 | 所有跨浏览器range滑块 |
margin-top修正拇指居中 | 所有 webkit 滑块 |
| 渐变硬切做“已填充段” | 所有webkit滑块 |
--v变量做实时接口 | 所有“拖动实时反馈”的控件 |
| 视觉尺寸和可操作尺寸分离 | 所有触控友好的控件 |
最值得记住的三条。
第一,appearance: none是原生控件换皮的第一道关。
不写,你画的轨道、拇指都被系统默认样式盖住。
webkit和标准两侧都要置none。这是所有input、button、select自定义样式的通用前提。
第二,webkit拇指的margin-top是数学税。
margin-top = (track 高度 − thumb 高度) / 2。
不写这行,拇指会吊在轨道上方。这是自定义滑块90%的“对不齐”问题的根源。
记住这个公式,就不会再踩。
第三,静态是姿态,变量是接口。
CSS只关心--v是多少,JS只关心--v怎么变。
1行JS就是把“数值”暴露给CSS的最短路径。