ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯CSS实现创意单选按钮:无JS也能做出高质感交互

纯CSS实现创意单选按钮:无JS也能做出高质感交互 1. 创意雷达先搞明白“为什么默认的单选按钮这么丑”做前端的人尤其是做偏展示型页面的一定都经历过这个痛点input typeradio这个原生控件在不同浏览器里长得完全不一样。Chrome 里是蓝色圆点Firefox 里是系统圆点Safari 里又换一套关键是——它几乎无法用 CSS 精确控制。想改个圆环颜色想加个阴影想让它变成一个卡通图案原生控件统统不答应。这时候很多人的第一反应是引一个 UI 库比如 Element、Ant Design 之类。但如果只是为了一个单选按钮引入整套组件库代价其实不小CSS 文件体积上去了、组件初始化逻辑变多了、后续自定义还受制于框架的样式覆盖规则。我自己做过不少活动页和营销落地页对这种场景有很深的体会——有时候页面里就三五个交互控件剩下的全是静态展示内容为了这几个控件把框架的核心 CSS 全拉进来实在不划算。所以就有了这个项目的核心思路只用纯 HTML CSS完全不用 JavaScript做出一套有质感、有细节、可自定义的创意单选按钮。这里说的“质感”并不是玄学而是可以通过视觉元素还原出来的平滑的过渡动画、层次分明的阴影、微妙的渐变色彩、合理的留白和缩放反馈。这些细节堆叠在一起用户点下去的时候那种“手感”是完全不一样的。这套方案适合谁我觉得至少有三类人可以参考前端初学者想练 CSS 选择器、伪元素、过渡动画的实际用法活动页开发页面简单、不想引入重量级 UI 框架的场景想进阶的产品设计师或前端工程师想理解“如何用 CSS 模拟真实触感”这一套方法。下面我会把整个设计思路、实现过程、踩坑经验都拆开讲代码可以直接复制跑也可以按自己的风格改。2. 技术选型思路为什么坚持不用 JavaScript2.1 原生控件的“隐藏”技巧要自定义单选按钮首先要解决一个核心问题input typeradio本身是“不可完全美化”的。至少在不借助 JS 的情况下你很难让原生圆点完全按照你的设计稿来渲染。常见的思路有两种一种是appearance: none直接干掉默认外观。这个属性一出原生控件的皮肤就没了剩下一个光秃秃的可点击区域你可以在它上面随便写样式。兼容性方面现代浏览器基本都没问题IE 系列就放弃吧现在做项目大多也不要求兼容 IE。另一种是“视觉隐藏保留可访问性”。把input用绝对定位移出可视区域或者用clip-path裁掉但保留它在 DOM 里的位置和可聚焦性然后用label绑定它的for属性再拿一个兄弟元素来当“假按钮”显示。这样屏幕阅读器依然能读到这个单选按钮键盘 Tab 也能聚焦无障碍方面不会减分。这两种方案我实际都用过。如果你不想依赖appearance的兼容性或者不想去记那些浏览器差异隐藏 label 的方案更稳。特别是做企业类项目无障碍审计是个绕不开的环节保留原生语义化结构很重要。这个项目我最终选的是第二种input 用sr-only类视觉隐藏视觉部分全部交给label内部的自定义元素。好处是结构清晰、无障碍友好而且视觉效果的表层代码和原生的控制逻辑完全分离后期维护也省心。2.2 伪元素是质感的关键做“质感”这件事靠一个span是远远不够的。一个圆点、一个边框、一个底阴影如果只用一个元素那最终效果就还是停留在“平面图标”的水平。真正的质感通常需要多层叠加外圈容器负责底色、边框、整体尺寸内部圆点负责选中的状态展示高光层在圆点内部加一个半透明的白色渐变模拟光线照射投影层用box-shadow在底部压一层柔和阴影让元件有“浮在纸上”的感觉。CSS 的伪元素就派上用场了。::before和::after可以让你在不增加额外 DOM 节点的情况下给一个元素叠加两层视觉。再配合box-shadow可以写多层阴影的特性一个元素就能拥有“边框 投影 内阴影”三重视觉叠加。这就是纯 CSS 方案的核心竞争力你不需要去改 HTML 结构就能实现非常丰富的外观。2.3 为什么不做成纯label方案有人可能会问既然 input 要隐藏那我直接用label配for属性不就行了吗这样能少套一层元素。能是能但有个问题当你要用:checked同层兄弟选择器或~来控制视觉状态时input 和视觉元素必须在同一父容器下并且视觉元素得出现在 input 后面。如果直接在一个label内部放 input 和视觉元素这个结构也能实现label input typeradio namedemo / span classcustom-radio/span /label但是当你需要把单选按钮和后面的文字说明整齐排布而且文字很长、可能会换行时这种嵌套结构会带来一些布局上的烦恼input 隐藏了文字和圆点的垂直居中对齐要靠 flex 解决只要把 input 也作为 flex item隐藏后的空位有时候会捣乱。所以最常见的可靠结构还是label input typeradio namedemo / span classradio-visual/span span classradio-text选项文字/span /labelinput 依然在 label 内部用绝对定位或sr-only隐藏它在文档流里不占位置不会影响 flex 布局。这个结构既能利用 label 的隐式绑定点击任意位置都能选中又能保持视觉元素和文字的自由布局是我实测下来最顺手的一种。2.4 动画的取舍过渡要“润”而不是“炫”做 CSS 质感最容易翻车的点是动画。过度夸张的动画会让人觉得廉价就像以前那种 PPT 翻页效果完全没有动画又显得生硬状态切换像瞬间跳变没有“手感”。我的原则是过渡时间控制在 0.15 秒到 0.3 秒之间。具体数值根据元素大小来定大一点的圆点可以 0.25 秒小一点的 0.2 秒就够了。缓动函数优先用cubic-bezier自定义比默认的ease更有弹性。举个实际参数transition: all 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55);这个cubic-bezier是经典的“回弹”曲线先快后慢最后会稍微越过终点再弹回来一点点。用在圆点缩放上选中时那个“啵”一下的手感比线性动画好得多。注意不要在width和height上做动画性能差优先用transform: scale(...)它走 GPU 加速帧率稳定。3. 核心代码实现从零写出高质感单选按钮3.1 HTML 结构干净的语义化骨架先看最终的 HTML 结构这是一个很标准的三件套div classradio-group label classradio-item input classradio-input typeradio namechoose valuehtml checked / span classradio-radio/span span classradio-labelHTML/span /label label classradio-item input classradio-input typeradio namechoose valuecss / span classradio-radio/span span classradio-labelCSS/span /label label classradio-item input classradio-input typeradio namechoose valuejavascript / span classradio-radio/span span classradio-labelJavaScript/span /label /div这个结构里要注意几个点name属性必须一致才能保证它们是同一组单选按钮互斥生效checked放在第一个选项上作为默认选中状态这样用户打开页面就知道这是单选还是多选风格span classradio-radio是视觉核心所有质感都体现在这个元素上span classradio-label是文字部分用 flex 布局和前面的圆点对齐。我把整个组放在.radio-group里这样后面加一组不同尺寸的按钮时只要改外层 class 就能批量调整不用每个 label 都去覆盖样式。3.2 CSS 基础样式先搭出主框架接下来是重头戏。我会分三层写样式控制全局容器和布局、input 隐藏、视觉元素的基础外观。把每一步拆开讲这样你自己调整的时候也容易下手。.radio-group { display: flex; flex-direction: column; gap: 16px; padding: 24px 28px; background: #f7f9fc; border-radius: 20px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); max-width: 360px; }.radio-group的作用是把所有选项竖向排列形成一个组容器。背景色我用了浅灰蓝和白色卡片区分开阴影要柔和rgba(0, 0, 0, 0.06)这种低透明度才不会显得脏。这一层不是单选按钮本身但“质感”的第一步往往来自容器营造的氛围。.radio-item { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #ffffff; border-radius: 14px; cursor: pointer; transition: box-shadow 0.2s ease, background 0.2s ease; } .radio-item:hover { background: #f0f4ff; box-shadow: 0 4px 14px rgba(0, 102, 255, 0.08); }每个选项是一个白色圆角卡片带 hover 反馈。这里 hover 的蓝色投影很淡只为了给用户“这个可以点”的暗示不抢主要内容。接下来隐藏 input用经典的 sr-only 套路.radio-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }这样 input 在屏幕上不可见但对屏幕阅读器依然可读。clip: rect(0 0 0 0)是裁剪到 0 尺寸white-space: nowrap防止换行后产生多余区域。然后是视觉元素.radio-radio.radio-radio { position: relative; width: 22px; height: 22px; border-radius: 50%; border: 2px solid #cbd5e1; background: #ffffff; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; flex-shrink: 0; }圆形框灰色边框白色底。这些基础属性看着平淡但它是后面所有“质感”效果的画布。flex-shrink: 0很关键防止在 flex 布局里圆点被压缩变形。3.3 选中状态的设计从“平面”到“立体”现在让选中状态发生质变。当 input 被选中时利用相邻兄弟选择器:checked .radio-radio精准命中当前选项的视觉元素.radio-input:checked .radio-radio { border-color: #0066ff; background: #0066ff; box-shadow: 0 4px 15px rgba(0, 102, 255, 0.35); }这一下圆点变成蓝色同时加了一个蓝色投影。投影看起来很细但它是“浮起感”的关键——就像按钮真的被按下去周围泛出一圈光晕。但这还不够。如果只有纯色填充还是显得平缺一点“内部构造”。所以继续叠内圆点和光感.radio-input:checked .radio-radio::after { content: ; position: absolute; top: 50%; left: 50%; width: 8px; height: 8px; background: #ffffff; border-radius: 50%; transform: translate(-50%, -50%) scale(1); opacity: 1; transition: transform 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55), opacity 0.2s ease; }这里用::after画了一个白色小圆点居中放在蓝色圆点里。默认状态下这个圆点应该隐藏选中时才弹出来。所以还要补齐未选中状态的样式.radio-radio::after { content: ; position: absolute; top: 50%; left: 50%; width: 8px; height: 8px; background: #ffffff; border-radius: 50%; transform: translate(-50%, -50%) scale(0); opacity: 0; transition: transform 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55), opacity 0.2s ease; }注意::after的默认状态和选中状态我都定义了transition这样切换时动画是双向的选中时从小变大弹出来取消选中时从大变小消失不会出现跳变。3.4 内部高光一像素的“玻璃感”真正让按钮有质感的往往不是主色而是那一点点高光。就像真实世界的塑料圆点表面会有点反光。用 CSS 模拟很容易给圆点叠一层半透明渐变.radio-radio::before { content: ; position: absolute; top: 0; left: 0; right: 0; height: 45%; background: linear-gradient( to bottom, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) ); border-radius: 50% 50% 0 0 / 100% 100% 0 0; }::before覆盖在圆点上三分之一区域从上到下由半透明白渐变为透明模拟顶部受光。border-radius用“椭圆角”写法让高光区域贴合圆形顶部不会露出方角。注意不要给::before加过渡动画它会拖慢整个状态切换。高光属于静态表面效果不需要动。::after是白点::before是高光两个伪元素各司其职互不干扰。这里的顺序也需要注意::before可以先写::after后写它们层叠的顺序会按照代码顺序来。如果你想要高光覆盖在白点上方就调整它们在 CSS 里的先后位置。3.5 增强文字样式细节体现专业度圆点做完之后文字部分也不能太随便。默认的文字样式带着浏览器默认字重跟细圆点搭配在一起显得笨重。.radio-label { font-size: 15px; font-weight: 500; color: #1e293b; line-height: 1; transition: color 0.2s ease; } .radio-input:checked ~ .radio-label { color: #0066ff; font-weight: 600; }选中后文字变蓝变粗和圆点颜色呼应。这里用~通用兄弟选择器因为 label 元素在 input 后面用~或都可以实际用~更灵活后续如果中间插入其他元素依然能命中。注意.radio-label的line-height: 1这是为了让文字在 flex 容器里垂直居中时不产生额外行高偏差避免出现“圆点居中文字却偏上”的视觉错位。到这里一个基础质感版本已经完成了。整个效果包含卡片式容器、圆点边框、选中变色、白点弹出、顶部高光、外部投影、文字联动变色。全部代码大约 60 行 CSS没有一行 JavaScript。4. 实操进阶三个方向做出更不一样的效果基础版做好之后很多人会问“那这就是一个普通的漂亮单选按钮嘛创意在哪里”别急真正的创意在于把刚才这套结构玩出花。我整理三个我实际做过的进阶方向都能在纯 CSS 范围内实现。4.1 缩放反馈模拟“按下去”的真实手感真实世界的物理按钮按下去会有位移和缩小。我们用:active伪类来模拟这个反馈.radio-item:active .radio-radio { transform: scale(0.88); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); }当鼠标按下还没松开时圆点缩小到 88%阴影也变浅变短看起来像是被“压”进去了一样。配合之前在选中状态里定义的box-shadow松手后能自然弹回。这个微反馈非常提升手感但要注意transition得设置合理否则会显得卡顿。4.2 外圈扩散波纹比单纯的圆点更有仪式感很多原生控件显示选中状态就是一个实心点太过无趣。我们可以加一圈扩散波纹在选中瞬间从中心向外扩散一圈半透明色环。原理很简单用::after再叠加一层通过transform: scale()和opacity的动画组合.radio-radio::after { /* 这里是原来的白点逻辑 */ } .radio-input:checked .radio-radio::after { /* 白点出现逻辑 */ } .radio-input:checked .radio-radio::before { content: ; position: absolute; top: 50%; left: 50%; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 102, 255, 0.2); transform: translate(-50%, -50%) scale(1.4); opacity: 0; transition: transform 0.4s ease, opacity 0.4s ease; }这里把::before从高光层改成了波纹层在选中时它从中心放大到 1.4 倍并淡出产生“波”的视觉效果。注意transition的时间要比白点动画略长因为波纹需要扩散到外围后再消失视觉上更顺滑。不过要提醒一句如果想让波纹从 0 开始扩散需要给::before在未选中状态也设置相同的基础位置和transform并且scale(0)否则无法产生“从中心扩散”的错觉。为了更清楚我把高光层移到.radio-item上用.radio-item::before单独做高光这样可以避开伪元素数量限制但会增加代码复杂度。日常做项目我通常保留高光波纹用box-shadow的多层动画来模拟——box-shadow不支持过渡动画但可以用keyframes做逐帧动画。用 keyframes 实现波纹的完整代码keyframes ripple { 0% { box-shadow: 0 0 0 0 rgba(0, 102, 255, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(0, 102, 255, 0); } 100% { box-shadow: 0 0 0 10px rgba(0, 102, 255, 0); } } .radio-input:checked .radio-radio { animation: ripple 0.6s ease-out; }这段动画的效果是选中瞬间从外圈 0 像素开始发散出一个半径 10 像素的淡蓝色光圈然后消失。模拟了涟漪扩散的视觉特征。注意70%和100%帧的阴影透明度都是 0但保留了阴影尺寸这样动画结束不会突然消失而是平滑淡出。4.3 变换形态把圆点变成对勾、爱心、星形单选的“圆”并不是唯一形态。在纯 CSS 里你可以用border-radius组合、旋转、裁剪做出各种形状。我试过把选中态改成对勾.radio-input:checked .radio-radio { background: #0066ff; border-color: #0066ff; } .radio-input:checked .radio-radio::after { content: ; position: absolute; left: 7px; top: 3px; width: 5px; height: 10px; border-right: 2.5px solid #fff; border-bottom: 2.5px solid #fff; transform: rotate(45deg); opacity: 1; transition: none; }这里把::after从白点改成一个旋转 45 度的白色边框角拼成一个对勾。注意位置需要根据圆点尺寸手动调没有公式可以套纯靠视觉微调。这个方法麻烦的地方在于不同浏览器对border-width的渲染有微小差异需要调试。但效果确实独树一帜用户一看就知道“这是一个自定义控件”。改成爱心、星形也是一样的思路通过clip-path裁剪可见区域来实现.radio-radio { clip-path: polygon(50% 100%, 0 40%, 20% 0, 50% 20%, 80% 0, 100% 40%); }上面这段代码直接把圆形裁成五角星配合选中状态的背景色效果非常醒目。但注意clip-path会把box-shadow和border一起裁掉所以做了裁剪之后就不要再依赖边框和阴影了视觉的层次感要重新靠内部渐变和伪元素来补。5. 常见问题与踩坑实录我给新手的 7 条避坑指南这些坑是我自己反复踩过之后总结出来的单独拎出来分享一下。5.1:checked选择器为什么失灵很多新手写input:checked .radio-radio { ... }结果发现根本没作用。原因一般出在 HTML 结构上input 和.radio-radio之间不能有别的兄弟元素。是相邻兄弟选择器必须紧挨着。如果你写了label input typeradio namedemo / span classsome-icon/span span classradio-radio/span /label.some-icon插在中间那么input:checked .radio-radio就会失效必须改成input:checked ~ .radio-radio用通用兄弟选择器。我个人的习惯是和~混用根据结构选择。结构干净时用中间一旦有额外元素就用~。没有哪个一定更好关键是匹配到正确层级。5.2sr-only隐藏后 label 点击区域变小可能有人遇到这种情况input 设成clip隐藏后点击 label 文字的某些区域触发不了切换。原因是 input 被position: absolute移除文档流后它在 label 内部不再占据任何宽高如果 label 的某个边界区域没有其他元素占据点击事件会落在 label 的空白 padding 区域。解决方法是给.radio-item设置display: flex让内部元素撑满整个 item并确保点击整个卡片都能响应。如果还有边界死角给.radio-item::before添加一层覆盖整个卡片的透明层扩大点击区域.radio-item::before { content: ; position: absolute; inset: 0; }这样整个卡片区域点击都能触发 label 的隐式绑定不会出现“点边角没反应”的尴尬。5.3 transition 动画不执行状态突然跳变出现这种情况八成是元素的transition属性只写在“选中状态”里了.radio-input:checked .radio-radio { background: #0066ff; transition: background 0.3s ease; }如果默认状态的.radio-radio上没有写transition那么取消选中时元素会瞬间恢复默认样式没有过渡。正确写法是把transition写在默认状态上这样无论选中还是取消都有动画。5.4box-shadow被容器overflow: hidden裁掉如果.radio-group设置了overflow: hidden来圆角裁剪背景那么圆点外扩的box-shadow也会被裁掉看起来像按钮投影消失了。解决办法不要轻易在容器上写overflow: hidden必须写时把容器 padding 留足或者在容器外层再套一个 overflow 可见的盒子用filter: drop-shadow()替代box-shadow时注意它也会被裁剪影响。5.5 键盘可访问性怎么保证自定义控件很容易破坏无障碍。我踩过的一个坑是input 用clip隐藏后在部分浏览器里无法通过 Tab 聚焦导致键盘用户完全无法操作。解决办法不要用display: none或visibility: hidden它们会让元素彻底脱离可访问性树。要用position: absolute clip的 sr-only 技巧同时在.radio-input:focus .radio-radio上写一个明显的焦点样式.radio-input:focus-visible .radio-radio { outline: 2px solid #0066ff; outline-offset: 4px; }这样键盘用户 Tab 过来时圆点外面会出现一圈可见的蓝色描边既保证了可访问性又不会影响鼠标用户的美感。5.6 不同浏览器下transform: translate(-50%, -50%)导致圆点偏移这个问题的根源是::after里同时用了top: 50%; left: 50%;和transform: translate(-50%, -50%)但在选中状态里transform又被写成了scale(...)覆盖了 translate 部分导致圆点瞬间位移到左上角。正确的写法是无论什么状态transform都要写全transform: translate(-50%, -50%) scale(0); /* 默认 */ transform: translate(-50%, -50%) scale(1); /* 选中 */不要只写scale(...)丢掉 translate。5.7 改变需求单选项数量很多时竖向布局太长怎么处理如果一组单选按钮有 8 个以上竖向卡片布局会非常长。我一般会改成两列网格布局或者横向排列。改起来很简单.radio-group { display: grid; grid-template-columns: repeat(2, 1fr); }把原来的flex-direction: column换成grid两列排布既紧凑又整齐。不过这时要注意文字过长会撑破卡片给.radio-label加上min-width: 0和word-break: break-word。6. 完整示例代码一键复制即可用的模板为避免大家拼代码时遗漏我这里放一个完整的示例包含容器、选项、全部 CSS以及一个简单的页面头部。你直接把这段代码存成.html文件浏览器打开就能看到效果。!DOCTYPE html html langzh-cn head meta charsetutf-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title纯 CSS 创意单选按钮/title style * { padding: 0; margin: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, PingFang SC, Microsoft YaHei, sans-serif; background: #eef2f7; min-height: 100vh; display: flex; align-items: center; justify-content: center; } .demo-title { font-size: 18px; font-weight: 700; margin-bottom: 24px; color: #1e293b; } .radio-group { display: flex; flex-direction: column; gap: 16px; padding: 28px; background: #f8fafc; border-radius: 20px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08); max-width: 380px; width: 100%; } .radio-item { position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #ffffff; border-radius: 14px; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); transition: box-shadow 0.25s ease, background 0.25s ease; } .radio-item:hover { background: #f0f4ff; box-shadow: 0 6px 18px rgba(0, 102, 255, 0.08); } .radio-item:active .radio-radio { transform: scale(0.88); } .radio-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; padding: 0; margin: 0; } .radio-radio { position: relative; width: 24px; height: 24px; border-radius: 50%; border: 2px solid #cbd5e1; background: #ffffff; flex-shrink: 0; transition: border-color 0.25s ease, background 0.25s ease, box-shadow 0.25s ease, transform 0.15s ease; } .radio-radio::before { content: ; position: absolute; top: 0; left: 0; right: 0; height: 45%; background: linear-gradient( to bottom, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) ); border-radius: 50% 50% 0 0 / 100% 100% 0 0; } .radio-radio::after { content: ; position: absolute; top: 50%; left: 50%; width: 9px; height: 9px; border-radius: 50%; background: #ffffff; transform: translate(-50%, -50%) scale(0); opacity: 0; transition: transform 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55), opacity 0.2s ease; } .radio-input:checked .radio-radio { border-color: #0066ff; background: #0066ff; box-shadow: 0 5px 16px rgba(0, 102, 255, 0.35); } .radio-input:checked .radio-radio::after { transform: translate(-50%, -50%) scale(1); opacity: 1; } .radio-input:focus-visible .radio-radio { outline: 2px solid #0066ff; outline-offset: 4px; } .radio-label { font-size: 15px; font-weight: 500; color: #1e293b; line-height: 1; transition: color 0.25s ease, font-weight 0.25s ease; } .radio-input:checked ~ .radio-label { color: #0066ff; font-weight: 600; } /style /head body div classradio-group div classdemo-title请选择你最常用的语言/div label classradio-item input classradio-input typeradio namechoose valuehtml checked / span classradio-radio/span span classradio-labelHTML/span /label label classradio-item input classradio-input typeradio namechoose valuecss / span classradio-radio/span span classradio-labelCSS/span /label label classradio-item input classradio-input typeradio namechoose valuejavascript / span classradio-radio/span span classradio-labelJavaScript/span /label /div /body /html6.1 代码里几个值得注意的细节body用了display: flexmin-height: 100vh让卡片垂直水平居中方便演示。.radio-item:active .radio-radio的transform: scale(0.88)会覆盖选中状态的transform但因为 transition 里写了transform 0.15s ease所以按下去的缩放反馈很快松手后恢复到选中状态的尺寸时走的是 0.25s 的过渡一快一慢层次分明。.radio-radio::before的高光层我放在了圆点内部顶部区域注意它的border-radius特殊写法50% 50% 0 0 / 100% 100% 0 0。前面两个值是水平半径后面两个是垂直半径组合起来才是一个贴合圆形顶部的椭圆区域。.radio-input:checked ~ .radio-label里的~选择器和选择器作用一样因为 label 紧跟 input但用~更安全。如果你在 input 和 label 之间插入图标会失效~依然有效。6.2 如何快速换主题色整个方案的主色是蓝色想换成其他主题色只需要修改四处.radio-input:checked .radio-radio里的background和border-color;.radio-input:checked ~ .radio-label里的color;.radio-item:hover里的box-shadow颜色;.radio-input:focus-visible .radio-radio里的outline颜色。建议使用 CSS 变量统一管理比如在.radio-group上定义.radio-group { --primary: #0066ff; --primary-light: rgba(0, 102, 255, 0.35); --primary-bg: rgba(0, 102, 255, 0.08); }然后把所有写死颜色的地方替换成var(--primary)。这样以后换主题色只需要改一个变量就行不用全局搜索替换。这个小细节在维护阶段能省不少时间。7. 无障碍与性能不能牺牲的底线纯视觉美化很容易忽略无障碍问题但这块又是企业项目和公共项目中必须过关的。我实际测试过几种方案总结出几条硬性要求input 不能被display: none或visibility: hidden隐藏input 必须保留在 DOM 树中且不可aria-hiddentrue焦点时必须有可见的 focus 指示不能仅靠鼠标 hover颜色对比度要足够浅灰色文字在白色背景上要注意我建议.radio-label的字号不低于 14px颜色不低于#64748b否则弱视用户很难看清所有选项必须保持相同的name确保 screen reader 能识别为同一组单选。性能方面这个方案没有 JS、没有图片、没有字体加载只有一个 CSS 文件实际加载成本几乎为零。唯一需要留意的是不要过度使用box-shadow多层叠加在低端安卓机上多个带阴影的圆点同时渲染时GPU 压力会比想象中大。可以用 Chrome DevTools 的 Performance 面板跑一下如果 FPS 掉到 40 以下就把阴影的模糊半径减小或者去掉内部投影。8. 实际体验感受这套方案我后来怎么用的我做过的项目里有一次是一个课程选择页面需要三组不同的单选一组选课程类型一组选上课时间一组选支付方式。三组按钮用同一个组件结构只通过外层的.radio-group--small或.radio-group--large调节尺寸。当时用纯 CSS 实现了三套不同的视觉风格没有引入任何 JS 和 UI 框架整个页面加载非常快。后来设计师想在支付方式那组加一个“推荐”标签我直接在 label 内部加了一个 span用绝对定位放在右上角样式独立不影响原有结构。还有一次是把单选按钮改成了卡片选中效果点击整个卡片时边框变色、内部出现对勾类似现在很多会员套餐选择的界面。这种效果如果用引入 UI 库的方式做反而要覆盖很多默认样式不如从零写来得干净。这几次实践让我真正感觉到HTML CSS 的能力边界比很多人以为的要远得多。最后分享一个我自己一直保持的习惯写完任何自定义控件都会用键盘跑一遍 Tab 流程再让屏幕阅读器读一遍。有时候视觉上很完美的控件在无障碍上用起来可能是一团糟。这个检查成本很低但能救回很多线上事故。这个纯 CSS 单选按钮的方案我后来一直在自己的项目模板里保留作为一种轻量、无依赖、可定制性强的默认选择效果稳定也省心。
返回列表