写在前面
今天的组件,是表单里最小、最多变、也最容易被随便做的一件:开关。
浏览器默认的<input type="checkbox">是一个带阴影的方框加一个对勾,放在任何设计过的界面里都很违和。所有产品都会重新做开关,但做出来的开关千篇一律:iOS 式胶囊、带文字的状态开关、带图标的多档开关。
今天一口气做三个:iOS式、拟物拨杆、文字反馈。
三个开关长得完全不同,但用的是同一套状态机。三个checkbox,三条:checked规则,差别只在checked之后把状态翻译成什么视觉。
这是状态机最纯粹的一堂示范课:状态归状态,皮归皮。
一、先看效果

浅色设置卡,标题「通用设置」,三行开关纵向排列。
第一行是深色模式,右侧一枚iOS式胶囊开关,默认开启。轨道是青绿色,白色圆钮在右侧。
第二行是机械手感,右侧一枚拟物拨杆。深色内凹轨道里,一枚银灰色金属拨杆,左侧OFF字样,右侧ON字样。默认关闭,拨杆在左边,OFF亮、ON暗。
第三行是邮件摘要,右侧一枚文字反馈开关。轨道里直接写着「开」和「关」两个字。默认开启,青绿渐变轨道,开关字随位置换色。
三款都真的能点、能存状态、能键盘操作。
怎么跑起来:
- 新建一个
demo.html - 把下面第二节的完整代码粘进去
- 双击用浏览器打开
- 三款开关各点几下试试
截图党提示:建议做Before / After对比图。左半放浏览器默认
<input type="checkbox">的截图,右半放三款开关切换的GIF。反差即点击理由。
二、完整代码(复制即用)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Day 41 · 开关三连</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { min-height:100vh; display:flex; align-items:center; justify-content:center;
background:#eef1f5; font-family:"PingFang SC", sans-serif; color:#1c2430; padding:20px; }
.sr { position:absolute; opacity:0; pointer-events:none; }
.card { width:430px; background:#fff; border-radius:18px; padding:14px 26px;
box-shadow:0 24px 60px rgba(20,40,80,.12); }
.t { font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.3em; color:#9aa4b2;
padding:16px 0 4px; }
h2 { font-size:17px; font-weight:600; letter-spacing:.04em; padding-bottom:8px; }
.row { display:flex; align-items:center; justify-content:space-between; gap:16px;
padding:18px 0; border-bottom:1px solid rgba(20,30,50,.06); }
.row:last-of-type { border-bottom:0; }
.row h3 { font-size:14px; font-weight:600; }
.row p { margin-top:4px; font-size:12px; color:#8b95a3; }
.row label.sw { flex:none; cursor:pointer; }
/* ------ 1 · iOS 式 ------ */
.sw1 { display:block; width:46px; height:26px; border-radius:99px; background:#cdd5df;
position:relative; transition:background .3s; }
.sw1::after { content:""; position:absolute; left:3px; top:3px; width:20px; height:20px;
border-radius:50%; background:#fff; box-shadow:0 2px 5px rgba(20,40,80,.3);
transition:transform .3s cubic-bezier(.34,1.4,.5,1); }
#sw1:checked ~ .card .sw1 { background:#0c7a6b; }
#sw1:checked ~ .card .sw1::after { transform:translateX(20px); }
/* ------ 2 · 拟物拨杆:内凹轨道 + 金属钮 ------ */
.sw2 { display:block; width:64px; height:30px; border-radius:9px; position:relative;
background:#181d24; box-shadow:inset 0 2px 5px rgba(0,0,0,.5); }
.sw2 .kn { position:absolute; left:3px; top:3px; width:26px; height:24px; border-radius:7px;
background:linear-gradient(180deg, #f2f5f8, #c6ccd4);
box-shadow:0 2px 4px rgba(0,0,0,.4);
transition:transform .3s cubic-bezier(.34,1.4,.5,1); }
.sw2 .on, .sw2 .off { position:absolute; top:50%; transform:translateY(-50%);
font:600 8px/1 "SF Mono", Consolas, monospace; letter-spacing:.1em;
color:#4a5568; opacity:.45; transition:opacity .3s, color .3s; }
.sw2 .off { left:10px; } .sw2 .on { right:8px; color:#5cc9b4; opacity:0; }
#sw2:checked ~ .card .sw2 .kn { transform:translateX(32px); }
#sw2:checked ~ .card .sw2 .on { opacity:1; }
#sw2:checked ~ .card .sw2 .off { opacity:.2; }
/* ------ 3 · 图标反馈:开/关文字随轨道换色 ------ */
.sw3 { display:block; width:56px; height:26px; border-radius:99px; position:relative;
background:#cdd5df; transition:background .3s; }
.sw3::after { content:""; position:absolute; left:3px; top:3px; width:20px; height:20px;
border-radius:50%; background:#fff; box-shadow:0 2px 5px rgba(20,40,80,.3);
transition:transform .3s cubic-bezier(.34,1.4,.5,1); }
.sw3 .on, .sw3 .off { position:absolute; top:50%; transform:translateY(-50%);
font-size:10px; transition:opacity .25s; }
.sw3 .off { right:8px; color:#7a8698; }
.sw3 .on { left:10px; color:#fff; opacity:0; }
#sw3:checked ~ .card .sw3 { background:linear-gradient(135deg, #5cc9b4, #2a9d8f); }
#sw3:checked ~ .card .sw3::after { transform:translateX(30px); }
#sw3:checked ~ .card .sw3 .on { opacity:1; }
#sw3:checked ~ .card .sw3 .off { opacity:0; }
.hint { font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.28em; color:#a8b2bf; }
.tag { position:fixed; right:18px; bottom:14px; font:400 10px/1 "SF Mono", Consolas, monospace;
letter-spacing:.28em; color:rgba(28,36,48,.3); }
</style>
</head>
<body>
<input type="checkbox" id="sw1" class="sr" checked>
<input type="checkbox" id="sw2" class="sr">
<input type="checkbox" id="sw3" class="sr" checked>
<div class="card">
<div class="t">TOGGLE FAMILY · T02</div>
<h2>通用设置</h2>
<div class="row">
<div><h3>深色模式</h3><p>跟随系统自动切换明暗</p></div>
<label class="sw sw1" for="sw1"></label>
</div>
<div class="row">
<div><h3>机械手感</h3><p>拨杆式开关,带确认段落感</p></div>
<label class="sw sw2" for="sw2"><span class="off">OFF</span><span class="on">ON</span><span class="kn"></span></label>
</div>
<div class="row">
<div><h3>邮件摘要</h3><p>每天一封昨日数据速览</p></div>
<label class="sw sw3" for="sw3"><span class="on">开</span><span class="off">关</span></label>
</div>
</div>
<div class="hint">THREE SWITCHES · ONE STATE MACHINE RECIPE</div>
<div class="tag">每天一个惊艳 CSS · DAY 41</div>
</body>
</html>
关键结构说明 :三个checkbox都放在body最外层,是.card的前置兄弟,#swN:checked ~ .card .swN才能匹配到。每个label用for关联到对应的checkbox,label和input平级、不嵌套。.sr把checkbox视觉隐藏但保留焦点链。
三、三个核心技法拆解
今天这三个技法,从状态机到拟物到缓动,一层一层往上走。
技法1:一份checked规则,三种视觉翻译
先看三个开关的状态规则:
css
/* iOS式:轨道换色 + 滑块平移 */
#sw1:checked ~ .card .sw1 { background: #0c7a6b; }
#sw1:checked ~ .card .sw1::after { transform: translateX(20px); }
/* 拟物拨杆:金属钮位移 + 文字明灭 */
#sw2:checked ~ .card .sw2 .kn { transform: translateX(32px); }
#sw2:checked ~ .card .sw2 .on { opacity: 1; }
#sw2:checked ~ .card .sw2 .off { opacity: .2; }
/* 文字款:轨道渐变 + 开关字交换 */
#sw3:checked ~ .card .sw3 { background: linear-gradient(135deg, #5cc9b4, #2a9d8f); }
#sw3:checked ~ .card .sw3::after { transform: translateX(30px); }
#sw3:checked ~ .card .sw3 .on { opacity: 1; }
#sw3:checked ~ .card .sw3 .off { opacity: 0; }
三组规则的结构完全一致:#swN:checked ~ .card .swN { ... }。
差别只在checked之后翻译成什么视觉。
iOS式翻译成轨道换色加滑块平移。拟物拨杆翻译成金属钮位移加文字明灭。文字款翻译成轨道渐变加开关字交换。
状态归状态,皮归皮。
为什么三个开关是三个独立的checkbox,不是一组radio?
因为开关是"二元独立状态",不是"多元互斥状态"。
深色模式可以开、机械手感可以开、邮件摘要可以开,三个开关互不干扰。这不是"三选一",是"三个独立开关"。
checkbox是二元状态,radio是多元互斥状态。用对控件,是表单设计的第一步。
控件的选择,决定了交互的语义。
技法2:拨杆的拟物两件套
中间那个机械手感开关,是三个里最"硬核"的一个。它用了两件拟物装备:
第一,内凹轨道。
css
.sw2 {
background: #181d24;
box-shadow: inset 0 2px 5px rgba(0,0,0,.5);
}
inset阴影把轨道"压下去",观感是一个嵌在卡片里的凹槽。光从上方打来,凹槽的上边缘被压暗,下边缘微微反光。
第二,金属旋钮。
css
.sw2 .kn {
background: linear-gradient(180deg, #f2f5f8, #c6ccd4);
box-shadow: 0 2px 4px rgba(0,0,0,.4);
}
上下渐变模拟"顶部受光、底部背光",再加一层底部阴影,旋钮看起来有厚度。
金属、内凹、投影,三样齐了就有"机械感"。
位移量要精确算。
css
#sw2:checked ~ .card .sw2 .kn { transform: translateX(32px); }
32px是怎么来的?
轨道宽64px,旋钮宽26px,两侧内边距各3px。
旋钮从左到右的可移动距离是:64 − 26 − 3 − 3 = 32px。
位移量 = 轨道宽 − 旋钮宽 − 2 × 内边距。
如果算错了会怎样?
算大了,旋钮"拨过头",露出一截轨道在右边。算小了,旋钮拨不到头,左边还留一道缝。
这是自定义滑块的通用算术,分段控件的thumb平移是同一个公式:宽度 − 滑块宽 − 两侧 padding。
记住这个公式,所有"滑块在轨道里移动"的组件都能算对。
文字明灭是第二个细节。
css
.sw2 .on, .sw2 .off {
opacity: .45;
transition: opacity .3s, color .3s;
}
.sw2 .on { color: #5cc9b4; opacity: 0; }
#sw2:checked ~ .card .sw2 .on { opacity: 1; }
#sw2:checked ~ .card .sw2 .off { opacity: .2; }
默认OFF亮、ON暗。checked之后ON亮、OFF暗。
两个字的透明度互换,观感是"拨到哪边,哪边的字就亮"。
两个元素叠在一起,按状态切换透明度。
技法3:bounce缓动的段落感
三个开关的旋钮位移,共用同一条缓动曲线:
css
transition: transform .3s cubic-bezier(.34, 1.4, .5, 1);
cubic-bezier(.34, 1.4, .5, 1)这条曲线,第二个参数y1 = 1.4超过了1。
超过1意味着"冲过目标位置再弹回来"。
旋钮从左边移到右边,不是"匀速滑到终点",而是"冲过一点、再回弹到位"。观感是机械开关的"咔哒"一声。
如果换成ease,就是平滑滑过,没有段落感,像滑盖不像开关。
如果换成linear,就是匀速滑过,像进度条不像开关。
.34, 1.4, .5, 1是机械开关的专属曲线。
每一条曲线对应一种物的质感。磁吸是"吸铁的回弹",视差是"推门的柔落",开关是"机械的咔哒"。
三款开关共用这条曲线。
iOS式的圆钮、拟物拨杆的金属钮、文字款的圆钮,位移都用cubic-bezier(.34, 1.4, .5, 1)。
材质不同、手感统一。这正是"一套状态机,三种视觉翻译"的最后一层:不只是状态机共用,连"手感"都共用。
统一的手感,是设计系统的一部分。 所有交互元素的缓动曲线应该属于同一个家族,让用户在任何组件上都感受到"同一款产品"。
四、踩坑位(我全踩过,你直接避)
① 状态机 input忘了.sr隐藏
html
<!-- 错:三个checkbox直接裸奔,竖排圆圈露在页面顶部 -->
<input type="checkbox" id="sw1" checked>
<input type="checkbox" id="sw2">
<input type="checkbox" id="sw3" checked>
<!-- 对:加.sr视觉隐藏,保留焦点链 -->
<input type="checkbox" id="sw1" class="sr" checked>
<input type="checkbox" id="sw2" class="sr">
<input type="checkbox" id="sw3" class="sr" checked>
css
.sr { position: absolute; opacity: 0; pointer-events: none; }
不加.sr,三个checkbox会以"竖排圆圈"的形式露在页面顶部,观感非常明显。
检查单已经固化为常规项:所有input状态机,都要.sr隐藏。
② label里塞了input
html
<!-- 错:点击双触发 -->
<label><input type="checkbox" id="sw1">深色模式</label>
<!-- 对:input与label平级、用for关联 -->
<input type="checkbox" id="sw1" class="sr">
<label for="sw1">深色模式</label>
<label><input></label>会造成点击双触发。
点label时,浏览器先把点击传给label,label又把点击传给内部input,input原生又被点一次。
结果是一次点击切两次,状态"卡卡的"。
input和label平级、用for关联最稳。点label只触发一次input状态切换。
③ 旋钮位移量算错
css
/* 错:忘记扣除两侧内边距,开关拨过头 */
.sw2 { width: 64px; padding: 0 3px; }
.sw2 .kn { width: 26px; }
#sw2:checked ~ .card .sw2 .kn { transform: translateX(38px); } /* 64 - 26 = 38,错了 */
/* 对:扣除两侧内边距 */
#sw2:checked ~ .card .sw2 .kn { transform: translateX(32px); } /* 64 - 26 - 3 - 3 = 32 */
忘记扣除两侧内边距,开关会"拨过头",露出一截轨道。
位移 = 轨道宽 − 旋钮宽 − 2 × 内边距。
这个公式是所有"滑块在轨道里移动"的通用算术。
记住这个公式,所有轨道滑块都能算对。
④ 文字开关的双span都常亮
css
/* 错:开/关两个字叠加显示 */
.sw3 .on, .sw3 .off { opacity: 1; }
/* 对:两个span的opacity按checked互换 */
.sw3 .off { opacity: 1; }
.sw3 .on { opacity: 0; }
#sw3:checked ~ .card .sw3 .on { opacity: 1; }
#sw3:checked ~ .card .sw3 .off { opacity: 0; }
两个span都常亮,开和关两个字叠在一起,看不清。
两个span的opacity必须按checked互换。默认OFF亮、ON暗;checked后ON亮、OFF暗。
两个元素叠在一起,按状态切换透明度。
⑤ 触控目标偏小
css
/* 错:26px高的开关偏矮,手机上不好点 */
.sw1 { height: 26px; }
/* 对:label加padding把命中区垫到44px */
.sw1 {
height: 26px;
position: relative;
}
label.sw {
padding: 9px 0;
display: inline-block;
}
26px高的开关在桌面上没问题,但在手机上偏矮,不好点。
正式使用给label加padding,把命中区垫到44px高。视觉上开关还是26px,但可点击区域是44px。
可操作区域比视觉区域大,是移动端友好的底线。
五、变式挑战(10--30分钟,动手才有收获)
任务:开关驱动流光边框。
把流光边框卡片与本篇一个开关放进同一页面,checkbox的checked规则直接控制卡片的animation-play-state。
html
<input type="checkbox" id="sw-flow" class="sr" checked>
<label for="sw-flow" class="sw sw1"></label>
<div class="card-flow">...</div>
css
.card-flow::before {
animation: rotate-border 4.5s linear infinite;
animation-play-state: paused;
}
#sw-flow:checked ~ .card-flow::before {
animation-play-state: running;
}
开关拨到ON,边框流光开始跑;拨到OFF,流光暂停。
纯CSS跨组件联动的关键:checkbox必须是两个组件共同的前置兄弟。
checkbox要放在页面的最外层、label和卡片之前。#sw-flow:checked才能同时匹配到label(开关本身)和.card-flow(被控制的组件)。
为什么要"跨组件联动"?
因为真实的产品里,组件不是孤立的。一个开关可以控制一个卡片的开合、一个滑块可以控制一个图表的缩放、一个输入框可以控制一个列表的过滤。
纯CSS能做跨组件联动的关键,就是"状态元素和受控元素必须是兄弟"。
这也是本专栏一直强调的"纯CSS状态机"的极限。超过两三个组件之后,状态关系就会变复杂,该用JS了。
其他方向(选做):
一是加"三态开关"。不是开/关两个状态,是"关/中/开"三个状态。用radio三选一,thumb有三个位置。这是"多档位开关",代码量翻倍。
二是加"开关组"。三个开关并排,每个控制一个"属性",下面实时显示"当前属性组合"。需要JS读取状态。
三是加"保存状态"。用localStorage存每个开关的状态,下次打开时读取。纯CSS做不到"记忆",这是诚实边界。
四是换成"iOS式暗色模式"。开关拨到ON时,整页从浅色切到深色。
六、小结
今天这一篇里藏着6个通用技能点:
| 技能 | 适用场景 |
|---|---|
一份checked规则,三种视觉翻译 |
所有"同状态不同皮"的组件 |
| 内凹轨道加金属旋钮 | 所有拟物开关、拟物按钮 |
| 位移量 = 轨道宽 − 旋钮宽 − 2 × 内边距 | 所有轨道滑块 |
cubic-bezier(.34, 1.4, .5, 1)过冲缓动 |
所有机械感开关、按钮 |
label与input平级、用for关联 |
所有表单控件 |
.sr视觉隐藏状态机 |
所有纯CSS状态机 |
最值得记住的三条。
第一,状态归状态,皮归皮。
三个开关长得完全不同,但用的是同一套状态机。
状态规则结构完全一致:#swN:checked ~ .card .swN { ... }。差别只在checked之后翻译成什么视觉。
**这是T02状态机最纯粹的示范课。**理解了这个分离,你就能用一套状态机做十种皮。
第二,位移量 = 轨道宽 − 旋钮宽 − 2 × 内边距。
忘记扣除内边距,开关会"拨过头"。算大了露一截轨道,算小了留一道缝。
记住这个公式,所有轨道滑块都能算对。
第三,统一的手感,是设计系统的一部分。
三款开关共用cubic-bezier(.34, 1.4, .5, 1)这条过冲曲线,材质不同、手感统一。
所有交互元素的缓动曲线应该属于同一个家族,让用户在任何组件上都感受到"同一款产品"。