前几天接了一个视觉稿,卡片四周要带一圈会流动的彩色渐变边框,设计师原话是“就一个流光描边,一下午能上吧”。我盯着那个匀速转圈的亮斑看了几秒,第一反应是交给 Canvas 或者 Lottie,但冷静下来之后意识到,这类效果用 CSS 就能做得非常干净:圆锥渐变(conic-gradient)负责生成沿角度分布的光源,mask 把光裁成一条边框,再让角度变量动起来,就成了流光效果的边框样式。这篇文章把我从第一版能出效果的“伪边框”写法,到顺滑动画版本,再到兼容性和性能取舍的完整过程都写下来,适合已经写过基础 CSS、想在按钮或卡片上做高级视觉的同学直接参考。
1. 圆锥渐变为什么天然适合做“流动的光”
1.1 conic-gradient 其实就是一张“角度调色盘”
linear-gradient 是沿一条直线方向渐变,radial-gradient 是从中心向外扩散,而 conic-gradient 的坐标系是“绕圆心转一圈”。你可以把它想象成一张钟表盘:0 度相当于 12 点方向,90 度是 3 点方向,180 度是 6 点方向,360 度又回到 12 点方向。
background: conic-gradient(from 0deg, #ff0080, #7928ca, #ff0080);这段代码的意思是:从 0 度开始,颜色从粉色过渡到紫色,再过渡回粉色,整个渐变在圆盘上形成一个环形色带。如果把它放到一个正方形元素里,会看到明显的扇形过渡;如果把圆角矩形裁掉中间区域,只保留边缘,就是一条彩色描边。这正是流光边框的基础素材。
圆锥渐变最特别的地方在于它的起点和终点是天然闭合的。线性渐变从左边到右边,结束之后没有“接缝”的概念;但圆锥渐变绕一圈回来,只要首尾颜色一致,就能形成无缝循环。这对“流动”效果是决定性的,因为流光本质上就是一个周期循环的视觉信号,圆锥渐变天生就符合这个模型。
1.2 流光效果需要的三个视觉要素
把“流光”拆开看,其实就三个要素:一个集中的光源、一个可变化的角度起点、一条窄边框作为载体。
很多新手第一次看到 conic-gradient 做边框,会直接写满六个颜色,让整圈边框不断变色。这种效果确实在转,但视觉上是“彩虹在旋转”,不是“一道光在扫过”。真正的流光效果应该像手电筒照过墙壁:大部分区域是暗的,只有一小段角度范围内是亮的,而且亮斑会随着时间移动。
所以更合理的渐变结构是让亮部集中在一小段角度里。
background: conic-gradient( from var(--angle), transparent 0deg, rgba(255, 255, 255, 0.9) 8deg, #22d3ee 22deg, #a78bfa 80deg, transparent 140deg, transparent 360deg );这段代码里,0 度到 8 度之间非常亮,8 度到 22 度之间是主色,22 度到 140 度逐渐衰减,140 度之后就完全透明,直到转回起点。这样画面上只会看到一段窄光带,再加上一段较长的余晖,视觉上就是“一道光在描边”,而不是整圈颜色均匀旋转。
角度起点就是控制这束光位置的开关。只要让--angle从 0 度匀速变到 360 度,光带就会沿着边框完成一圈流动。这个“角度变量”是整套实现的核心,后面动画部分会重点讲。
2. 第一版实现:双层背景加 mask 裁出真正的渐变边框
2.1 先别急着上 mask:双层背景的快速方案
最直观的做法是用border: 2px solid transparent占住边框宽度,然后叠两层背景:一层放在 padding-box,作为卡片内部的底色;另一层放在 border-box,作为圆锥渐变的辖区。
.quick-border { border: 2px solid transparent; border-radius: 16px; background: linear-gradient(#0f1220, #0f1220) padding-box, conic-gradient(from 0deg, #22d3ee, #a78bfa, #f472b6, #22d3ee) border-box; }为什么这样能形成边框?因为padding-box的底色层刚好覆盖从边框内缘到内容区的全部区域,而border-box的渐变层覆盖到边框外缘。两层叠加之后,内部区域被不透明的深色层盖住,只有最外圈 2px 的边框区域露出圆锥渐变色。
这个方案的优点是结构简单,不需要伪元素,也不用管 mask-composite 的兼容性。缺点是内部背景必须是不透明的,否则圆锥渐变会从半透明背景下面透出来,整个卡片都会花掉。如果设计师要的是半透明毛玻璃卡片,或者背景需要透出页面底图,这个方案就不能用。
2.2 透明内芯版本:用 mask 裁出边框环
真正的通用方案是用伪元素承载渐变,再用 mask 把伪元素的中间区域“挖空”,只留下边框环。
<div class="card card--glow"> <div class="card__body"> 这里是卡片内容 </div> </div>.card--glow { --border-size: 2px; --radius: 18px; position: relative; border-radius: var(--radius); } .card--glow::before { content: ""; position: absolute; inset: 0; z-index: 2; border-radius: inherit; padding: var(--border-size); background: conic-gradient(from var(--angle), #22d3ee, #a78bfa, #f472b6, #22d3ee); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask-composite: exclude; pointer-events: none; }mask 在这里做了两件事:第一层linear-gradient(#fff 0 0) content-box只让内容区域可见,第二层默认的 border-box 让整个伪元素区域可见,mask-composite: exclude取两者的差集,也就是只保留 border 区域那 2px 的环。
伪元素外层仍然有一层 padding,但内容是空字符串,所以 content-box 区域是透明空气。渐变背景虽然覆盖了整个矩形,但 mask 把中间裁掉,最终用户只看到一圈渐变色边框。
有一个很关键的坑必须提醒:mask 是作用在整个元素上的,如果直接把文字内容放进这个伪元素,文字也会被裁掉。因此实际内容必须放在.card__body这类独立子元素中,层级上让内容在伪元素下方,视觉上伪元素作为最外层的“描边覆盖层”存在。
2.3 为什么有人做出来边框里外全是渐变色
最常见的问题就是内容和渐变层放在同一个元素上,或者 mask 层叠顺序没写对。如果你看到整个元素都被渐变色填满,大概率是 mask-composite 没生效。
现代浏览器推荐标准写法mask-composite: exclude,但 Safari 和旧版 Chrome 需要-webkit-mask-composite: xor。两种写法的语义在“排除”这个场景下是等价的,但属性名和取值都不同,所以代码里两个都要留:
-webkit-mask-composite: xor; mask-composite: exclude;另一个容易翻车的地方是圆角。外层border-radius: 18px,边框宽度 2px,那么内层内容的圆角大约是18px - 2px = 16px,否则深色内芯的直角会戳出边框。我在实际项目里经常看到有人外层圆角写 20px,内层内容圆角也写 20px,结果边框在四个角上突然变宽,视觉上像接口处鼓起一个包,非常明显。
3. 让光动起来:用 @property 驱动角度而不是折腾 background
3.1 为什么不直接改 background-position 或 transform
拿到静态渐变边框后,下一步就是让它动。我见过有人用 animation 去改background-position,对线性渐变可能还凑合,但圆锥渐变根本没有 x/y 方向的概念,改 background-position 只会让整张渐变背景在盒子里平移,光带绕着边界走的效果完全出不来。
还有人想到旋转整个元素或伪元素。这个方法在正方形元素上勉强能用,但在长宽比很大的卡片上问题很明显:元素几何中心和圆形渐变中心不重合,旋转之后光带速度一会儿快一会儿慢;而且伪元素旋转到非整数角度时,四个角会露出空白,必须把伪元素放大 150% 以上再配 overflow 裁剪,整体方案会变得很难控制。
所以最合理的做法不是“移动”或者“旋转”,而是让圆锥渐变的起始角度from var(--angle)本身随时间变化。角度从 0 度平滑增长到 360 度,视觉效果就是光带沿着边框跑完一整圈。
3.2 注册自定义属性 --angle 的完整姿势
要让 CSS 动画能够平滑修改一个自定义属性,必须用 @property 先把这个变量注册成<angle>类型,否则浏览器不知道这个变量是角度,动画会从 0 度直接跳到 360 度。
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; } .card--glow::before { background: conic-gradient( from var(--angle), transparent 0deg, rgba(255, 255, 255, 0.95) 8deg, #22d3ee 22deg, #a78bfa 80deg, transparent 140deg, transparent 360deg ); animation: border-flow 4s linear infinite; } @keyframes border-flow { to { --angle: 360deg; } }@property声明里最关键的是syntax: "<angle>"。只要注册成角度类型,浏览器在做动画插值时就会把它当作角度处理,从 0 到 360 生成连续帧;如果不注册,浏览器只会把--angle当成普通字符串变量,动画极可能只有首尾两帧,中间完全丢失。这一点和 CSS 变量常见的字符串拼接逻辑很不一样,很容易踩坑。
3.3 调节“流光”观感的几个旋钮
光带快不快、亮不亮、像不像“一道光”,主要由四个参数决定。
第一个是animation-duration。4 到 6 秒适合卡片,2 到 3 秒适合按钮,头像可以稍微快一点,3 秒左右比较有活力。
第二个是亮部区域宽度。在 conic-gradient 中,亮斑占的角度越窄,光束越锐利;占的角度越宽,越接近大面积彩色漫反射。想要高级感,我建议亮部控制在 8 到 20 度之间,余晖可以拖到 80 到 120 度。
第三个是颜色 stop 的密度。同一个渐变里如果所有颜色集中在一半角度内,另一半完全透明,视觉上就会产生明显的“一段亮一段暗”对比。这种对比是流光感的核心,为了让效果更明显,我会在亮斑后面加一个纯白透明度的 stop:
transparent 0deg, rgba(255, 255, 255, 0.95) 8deg, #22d3ee 16deg, transparent 120deg第四个是动画的 timing-function。流光必须用linear,如果使用ease或ease-in-out,亮斑会在起点和终点附近减速,看着像卡顿。
| 参数 | 卡片场景 | 按钮场景 | 头像场景 |
|---|---|---|---|
| 动画时长 | 6s | 2.5s | 3s |
| 亮斑角度 | 12deg | 18deg | 8deg |
| 光带数量 | 2 个 | 1 个 | 1 个 |
| 推荐方向 | 顺时针 | hover 时反向 | 顺时针 |
4. 进阶玩法:辉光、不同元素形态和动效降级
4.1 给边框加一层柔光
纯边框流光在某些深色背景下会显得边缘太锐利,像贴了一道塑料条。这时候可以叠加一个模糊的辉光层,让光带周围有一圈柔和泛光。
辉光层的做法和主边框层几乎一样,区别是它要放在主边框层下面,并且加 filter blur。
.card--glow::after { content: ""; position: absolute; inset: 0; z-index: 0; border-radius: inherit; padding: calc(var(--border-size) + 6px); background: conic-gradient(from var(--angle), #22d3ee, #a78bfa, transparent 60%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask-composite: exclude; filter: blur(12px); opacity: 0.5; pointer-events: none; }这里我把辉光层的 padding 加大到border-size + 6px,让裁出来的“边框环”更宽,再配合 blur 模糊,视觉上就形成了一圈从边框向外扩散的光晕。要注意父容器不能设置 overflow: hidden,否则 blur 产生的溢出部分会被裁掉,辉光效果大打折扣。
4.2 头像、按钮、Tab 选中态的不同改造
头像和卡片的实现逻辑相同,但有几个细节必须调整。第一,圆角要改成 50%,并且内层内容也要是圆形;第二,如果头像尺寸小,比如 48px 的头像,边框宽度最好降到 2px 以下,否则会盖住太多头像内容;第三,小尺寸元素上光带角度不宜太宽,8 度左右刚好,太宽会变成整个圆圈都在发光。
按钮场景我喜欢把流光放在 hover 态触发。原本是静态渐变边框,鼠标移入时才开始流动,这样不会在列表页里造成大范围视觉噪音。具体做法是默认不给伪元素加 animation,hover 时再开启:
.btn--glow::before { animation: none; } .btn--glow:hover::before { animation: border-flow 1.2s linear infinite; }需要注意 animation 从无到有会有一个“启动”过程,第一次触发时可能会亮斑从当前位置突然出现。为了避免突兀,可以给 animation 加一个animation-delay: 0s,并且保持渐变结构一致,用户感知上就会是一个平滑出现的动态描边。
Tab 选中态更适合静态渐变,或者用极慢的流光作为背景辅助。因为 Tab 是信息导航,用户视线需要快速定位,一旦有强烈动效持续闪烁,会严重影响扫视效率。我的经验是:功能性控件动效越克制越好,装饰性大卡片可以稍微放开。
4.3 prefers-reduced-motion 与静态兜底
动效不是所有人都能接受,尤其是前庭功能敏感的用户,持续环绕运动的光斑会让人头晕。CSS 里可以用prefers-reduced-motion媒体查询做无障碍适配:
@media (prefers-reduced-motion: reduce) { .card--glow::before { animation: none; } }静态状态下,conic-gradient 边框仍然保留,只是光斑不转。这样既不丢失视觉层次,也不会强迫用户长时间观看高动态画面。把这条规则加到任何动效组件里,是提升质量下限成本最低的一步。
5. 真机调试时踩过的坑和最终验证过的模板
5.1 Safari 和旧浏览器的兼容性处理
这套实现最大的兼容性变量来自两部分:@property和mask-composite。
@property在现代 Chromium 和较新版本的 Safari/Firefox 中都能用,但如果目标环境是较旧的 Safari 15 或更低版本,@property可能没有被支持,动画会退化成只有首尾两帧。表现就是边框静态不流动,但不会报错,页面也不会崩溃。
如果一定要在旧环境里让光流动起来,可以用 JS 每帧更新 CSS 变量。注意这时候 CSS 里的 animation 必须关掉,否则两套逻辑会同时写同一个变量,出现闪烁和抖动。
const el = document.querySelector('.card--glow'); let angle = 0; function tick() { angle = (angle + 0.8) % 360; el.style.setProperty('--angle', angle + 'deg'); requestAnimationFrame(tick); } tick();mask-composite 也有版本差异。写的时候把-webkit-mask-composite: xor和mask-composite: exclude都写上,并且把 Webkit 前缀版本放在标准版本前面。
5.2 光带“忽快忽慢”的真相
我调这个效果时遇到过一种非常隐蔽的问题:光带看起来在某个角度区间明显变慢,过了某个角度又突然加速。排查下来主要有两个原因。
第一个原因是 timing-function 用了非 linear。默认的 ease 会让动画在开始和结束时减速,如果光带正好跑一圈,起点终点附近自然会慢。解决方法是明确写成linear。
第二个原因是伪元素的几何中心和渐变圆心不重合。这种情况常见于用 transform 旋转伪元素代替角度动画,元素越长,速度不均匀越明显。卡片宽高比大到 2:1 时,光带在长边和短边上运动速度差异肉眼可见。所以我最终只使用 @property 角度动画,不推荐用 transform 旋转伪元素的方案来模拟。
5.3 可直接抄走的最佳实践模板
下面是我在项目中验证过的最终方案:外层卡片负责定位和圆角,伪元素负责承载动态渐变边框,内层子元素负责承载真实内容。所有关键参数都用 CSS 变量暴露出来,换色或者调速不需要翻动关键帧。
<div class="card-glow"> <div class="card-glow__inner"> <h3>流光会员卡</h3> <p>conic-gradient 实现动态渐变边框</p> </div> </div>@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; } .card-glow { --border-size: 2px; --radius: 20px; --c1: #22d3ee; --c2: #a78bfa; --c3: #f472b6; --duration: 5s; position: relative; border-radius: var(--radius); isolation: isolate; } .card-glow::before { content: ""; position: absolute; inset: 0; z-index: 2; border-radius: inherit; padding: var(--border-size); background: conic-gradient( from var(--angle), transparent 0deg, rgba(255, 255, 255, 0.95) 8deg, var(--c1) 22deg, var(--c2) 80deg, var(--c3) 140deg, transparent 190deg, transparent 360deg ); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask-composite: exclude; pointer-events: none; animation: border-flow var(--duration) linear infinite; } .card-glow__inner { position: relative; z-index: 1; border-radius: calc(var(--radius) - var(--border-size)); background: #0f1220; padding: 24px; } @keyframes border-flow { to { --angle: 360deg; } } @media (prefers-reduced-motion: reduce) { .card-glow::before { animation: none; } }调试时有个小技巧:把--duration临时改成 1s,光带运动速度会非常快,你一眼就能看出亮斑是否沿着边框走、方向对不对、四角有没有露出异常;确认无误后再改回 5s 或者 6s。我自己做这类装饰效果时会先在小按钮上试,确认光带宽度和颜色不抢内容主次,再决定要不要铺到整张大卡片上。光带越窄、颜色越克制,出来的质感越接近设计稿,而不是满脸跑马灯。