【每天一个CSS | Day41】开关三连:一套状态机,三种视觉翻译

写在前面

今天的组件,是表单里最小、最多变、也最容易被随便做的一件:开关。

浏览器默认的<input type="checkbox">是一个带阴影的方框加一个对勾,放在任何设计过的界面里都很违和。所有产品都会重新做开关,但做出来的开关千篇一律:iOS 式胶囊、带文字的状态开关、带图标的多档开关。

今天一口气做三个:iOS式、拟物拨杆、文字反馈。

三个开关长得完全不同,但用的是同一套状态机。三个checkbox,三条:checked规则,差别只在checked之后把状态翻译成什么视觉。

这是状态机最纯粹的一堂示范课:状态归状态,皮归皮。


一、先看效果

浅色设置卡,标题「通用设置」,三行开关纵向排列。

第一行是深色模式,右侧一枚iOS式胶囊开关,默认开启。轨道是青绿色,白色圆钮在右侧。

第二行是机械手感,右侧一枚拟物拨杆。深色内凹轨道里,一枚银灰色金属拨杆,左侧OFF字样,右侧ON字样。默认关闭,拨杆在左边,OFF亮、ON暗。

第三行是邮件摘要,右侧一枚文字反馈开关。轨道里直接写着「开」和「关」两个字。默认开启,青绿渐变轨道,开关字随位置换色。

三款都真的能点、能存状态、能键盘操作。

怎么跑起来:

  1. 新建一个demo.html
  2. 把下面第二节的完整代码粘进去
  3. 双击用浏览器打开
  4. 三款开关各点几下试试

截图党提示:建议做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)这条过冲曲线,材质不同、手感统一。

所有交互元素的缓动曲线应该属于同一个家族,让用户在任何组件上都感受到"同一款产品"。

相关推荐
变与不变8061 小时前
css定位布局精讲
前端·css
FRing032 小时前
JavaScript入门到精通三十三(异常处理、call方法、apply方法、bind方法、防抖、节流)
开发语言·前端·javascript
计算机魔术师2 小时前
OpenAI 砍掉 100 亿融资目标,AI 资本市场的温度计变了
前端
jktantan3 小时前
(二)Vue 3 的 attrs 不是响应式:el-form 扩展属性同步的三种方案
前端·javascript·vue.js·element-plus
前端 贾公子3 小时前
第 5 章:Codex 从可运行程序到可交付工具
java·前端·数据库
用户360055579003 小时前
Kestrel_VLLM - 26 · 发射空档三步曲:事件计时 → nsys → 判别实验
前端
echome8884 小时前
JavaScript async/await 错误处理:别让 Promise 静默失败
开发语言·前端·javascript·promise
PedroQue994 小时前
v0.2.0 架构升级:class + reactive 重塑状态管理
前端·uni-app
鶴哥只手遮天4 小时前
Angular 22 源码级深度解析-第 02 章 编译链路总览:从模板字符串到 `ɵɵ` 指令
前端·开源·angular.js