【每天一个CSS | Day23】边框上有一道光在跑的流光呼吸卡片

写在前面

昨天我们做了3D旋转木马------六个面板绕成环形。

今天换一种完全不同的技术路线:CSS Houdini的第一次登场。

先理解一件很反直觉的事:

CSS变量默认是"不可动画"的。

你可能写过这样的代码:

css 复制代码
/* ❌ 想动画一个CSS变量------但浏览器不认 */
@keyframes rotate { to { --a: 360deg; } }

结果 :动画从0deg瞬间跳到360deg ------没有中间过程 ------"硬切"。

为什么?

因为浏览器不知道--a是什么类型 ------它把--a当作一段"文本字符串",而不是一个"角度数值"。

字符串没法"插值" ------就像你不能问"abc"和"xyz"之间是什么------浏览器只能"跳变"。

怎么解?

用@property把变量"注册成类型"。

css 复制代码
@property --a {
  syntax: '<angle>';        /* 告诉浏览器:--a是一个"角度" */
  inherits: false;
  initial-value: 0deg;
}

注册之后 ------浏览器知道--a是"角度",就能逐帧插值 ------动画才真正"跑起来"。

这就是今天卡片的核心 ------有了可动画的角度变量,conic-gradient的"起始角"就能旋转,光带便沿边框奔跑。


一、先看效果

深色背景上,两张卡片并排:

  • 左边青蓝:边框上一道青蓝光带绕卡片匀速奔跑,跑动时卡片外围的光晕随之一起呼吸
  • 右边粉金:同样的结构,不同的配色------同一套配方,只差两个颜色变量

观感 :像设备充电指示灯的旗舰版------边框有一道光在跑,外壳的光晕在呼吸。

光带跑动周期4.5秒 ------光晕呼吸周期3秒 ------两个周期互质 ,叠加出来的明暗节奏 9秒才重复一次 ------"永不重复"的观感。

怎么跑起来:

  1. 新建一个demo.html
  2. 把下面第二节的完整代码粘进去
  3. 双击用浏览器打开
  4. Chrome / Edge / Safari 16.4+ 体验完整;老Firefox边框静止但布局完好

📸 截图党提示 :录5秒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 23 · 流光呼吸边框卡片</title>
<style>
  /* ------ 注册角度变量:--a 从「字符串」变成「可动画的角」------ */
  @property --a {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
  }

  * { margin:0; padding:0; box-sizing:border-box; }
  html, body { height:100%; }
  body { display:flex; align-items:center; justify-content:center; gap:26px;
    overflow:hidden; background:#0a0e14; font-family:"PingFang SC", sans-serif; }

  .card { position:relative; width:300px; height:400px; border-radius:18px;
    background:#11161d; color:#dbe4ee; padding:34px 28px;
    display:flex; flex-direction:column; }
  .card::before { content:""; position:absolute; inset:0; border-radius:inherit; padding:2px;
    background: conic-gradient(from var(--a),
      var(--c1), transparent 26%, var(--c2) 42%, transparent 58%, var(--c1) 78%, var(--c1));
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
            mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask-composite: exclude;
    animation: rotate-border 4.5s linear infinite; }
  /* 呼吸:外圈光晕与流光同周期 */
  .card::after { content:""; position:absolute; inset:-2px; border-radius:20px; z-index:-1;
    background: conic-gradient(from var(--a), var(--c1), transparent 40%, var(--c2), transparent 75%, var(--c1));
    filter: blur(26px); opacity:.5;
    animation: rotate-border 4.5s linear infinite, breath 3s ease-in-out infinite; }
  @keyframes rotate-border { to { --a: 360deg; } }
  @keyframes breath { 0%,100% { opacity:.32; } 50% { opacity:.62; } }

  .card .k { font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.3em; color:#5b6878; }
  .card h2 { margin-top:22px; font-size:24px; font-weight:600; letter-spacing:.04em; }
  .card p { margin-top:14px; font-size:13.5px; line-height:1.85; color:#8b97a8; }
  .card .foot { margin-top:auto; display:flex; justify-content:space-between; align-items:center;
    font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.2em; color:#5b6878; }
  .card .dot { width:8px; height:8px; border-radius:50%; background:var(--c1);
    box-shadow:0 0 12px 2px var(--c1); }

  .t1 { --c1:#5cc9b4; --c2:#7ab8ff; }
  .t2 { --c1:#ff8ad0; --c2:#ffd479; }

  .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>
  <div class="card t1">
    <div class="k">@PROPERTY · ANGLE</div>
    <h2>流光呼吸边框</h2>
    <p>边框上有一道光在跑,外壳的光晕随之呼吸。秘诀是把自定义属性注册成 &lt;angle&gt; 类型------注册之后,CSS 变量第一次「可以」被动画插值。</p>
    <div class="foot"><span>DAY 23</span><span class="dot"></span></div>
  </div>
  <div class="card t2">
    <div class="k">@PROPERTY · ANGLE</div>
    <h2>同一配方 · 换两色</h2>
    <p>流光的颜色全部来自 --c1 / --c2 两个变量。换配色只改两个值,角度动画一行不动------这就是注册类型带来的工程红利。</p>
    <div class="foot"><span>DAY 23</span><span class="dot"></span></div>
  </div>
  <div class="tag">每天一个惊艳 CSS · DAY 23</div>
</body>
</html>

三、三个核心技法拆解

代码不长,但三个点层层递进------每一个都是"CSS高级特性"的第一次登场。

技法1:@property让CSS变量「可动画」

这是本篇最重要的一条认知转变:

CSS变量默认是"一段字符串",不是"一个数值"。

默认情况下:

css 复制代码
:root { --a: 0deg; }      /* 在浏览器眼里------"0deg"是一段文本 */

当你试图动画它时:

css 复制代码
@keyframes rotate { to { --a: 360deg; } }   /* ❌ 没有中间过程 */

浏览器看到的是 ------"从文本'0deg'变成文本'360deg' "------字符串怎么插值? ------只能"硬切"。

这是 @property 存在的全部理由。

css 复制代码
@property --a {
  syntax: '<angle>';        /* 明确告诉浏览器:这是一个"角度" */
  inherits: false;          /* 不继承------每张卡片独立控制 */
  initial-value: 0deg;      /* 默认值------必须给,否则注册失败 */
}

逐行理解:

行 作用
syntax: '<angle>' 告诉浏览器:--a是一个"角度"
inherits: false 不继承父元素的值------每张卡片独立
initial-value: 0deg 默认值------没写的时候用它

注册之后,动画才能"跑起来":

css 复制代码
@keyframes rotate-border { to { --a: 360deg; } }

浏览器知道--a是角度 ------就能逐帧插值 ------从0deg一路平滑到360deg。

inherits: false是什么?

CSS自定义属性默认是"继承"的 ------父元素设了--a,子元素会自动继承。

本页有两张卡片 ------如果--a继承 ------它们会共享同一个角度 ------流光完全同步 ------看起来像"一张大卡片被切成两半"。

inherits: false ------每张卡片有自己的--a ------各自独立跑 ------观感更自然。

syntax的可选值:

syntax 值 类型
'<length>' 长度(px、em、vh...)
'<number>' 数字
'<percentage>' 百分比
'<angle>' 角度(deg、turn、rad)
'<color>' 颜色
'*' 任意值(相当于"不注册")

syntax必须带引号、尖括号 ------写成angle或<length>都算错误。

这是Houdini API的一部分 ------Chrome / Edge / Safari 16.4+ 支持 ------老Firefox还不认 ------所以本篇附了降级说明。


技法2:conic + 双层mask 削出2px边框

"只有边框在流光"怎么实现?

这是**"双层mask挖空"技法的复刻------但这次用在边框流光**上。

css 复制代码
.card::before {
  content: "";
  position: absolute;
  inset: 0;                    /* 铺满整卡 */
  border-radius: inherit;      /* 圆角和卡片一致 */
  padding: 2px;                /* 关键:留出2px边框宽度 */

  background: conic-gradient(from var(--a),
    var(--c1), transparent 26%, var(--c2) 42%, transparent 58%, var(--c1) 78%, var(--c1));

  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;
}

拆开读:

① padding: 2px ------ 伪元素铺满整卡,但内部留出2px的padding

② 背景是一段conic-gradient

css 复制代码
conic-gradient(from var(--a),
  var(--c1),         /* 0% ------ 青绿 */
  transparent 26%,   /* 26% ------ 透明 */
  var(--c2) 42%,     /* 42% ------ 蓝 */
  transparent 58%,   /* 58% ------ 透明 */
  var(--c1) 78%,     /* 78% ------ 青绿 */
  var(--c1))         /* 100% ------ 青绿 */

这就是"光带":

  • 0% ~ 26%:从青绿渐隐到透明
  • 26% ~ 42%:从透明渐显到蓝
  • 42% ~ 58%:从蓝渐隐到透明
  • 58% ~ 78%:从透明渐显到青绿
  • 78% ~ 100%:青绿保持

这是一个"亮---暗---亮---暗"的循环 ------像两段光带绕着圆周分布。

from var(--a) 让这个渐变的起点角度 跟着--a走--------a从0deg转到360deg,渐变就"转一圈" ------光带便"跑起来"。

③ 双层mask挖空

css 复制代码
-webkit-mask: linear-gradient(#000 0 0) content-box,    /* 只留内容区 */
              linear-gradient(#000 0 0);                /* 全铺 */
-webkit-mask-composite: xor;                            /* 异或 */
  • 第一层:只保留"内容区"(去掉padding)
  • 第二层:保留"整个盒子"
  • 异或 :两层重合的部分消失,只剩"2px边框"那一圈

结果 :conic-gradient被"削成"一圈2px的边框------"只有边框在流光"。

外圈光晕怎么加的?

css 复制代码
.card::after {
  position: absolute;
  inset: -2px;                 /* 稍微比卡片大2px */
  border-radius: 20px;
  background: conic-gradient(from var(--a), ...);   /* 同一个角度变量 */
  filter: blur(26px);          /* 大范围模糊------变成"光雾" */
  opacity: .5;
  z-index: -1;                 /* 压到卡片内容之下 */
  animation: rotate-border 4.5s linear infinite, breath 3s ease-in-out infinite;
}

关键:

  • inset: -2px ------ 比卡片大一圈
  • filter: blur(26px) ------ 大范围模糊------实心渐变变成"光雾"
  • background 用的是同一段conic ------ 光晕的亮度位置永远和边框同步
  • z-index: -1 ------ 压到卡片下面------"光从卡片背后渗出"

"光晕"的本质 ------"把边框背景图再放大、再模糊一遍" ------和边框共享同一个角度变量 ------两者永远同步。

"一个conic做两层" ------这是"边框 + 光晕"效果的通用做法。


技法3:呼吸与流光的节奏叠加

光晕同时挂了两条动画:

css 复制代码
.card::after {
  animation:
    rotate-border 4.5s linear infinite,        /* 跟随流光,4.5s一圈 */
    breath        3s   ease-in-out infinite;   /* 透明度起伏,3s一拍 */
}

两条动画各管各的属性:

动画 改的属性 周期 时序
rotate-border --a(角度) 4.5s linear(匀速)
breath opacity 3s ease-in-out(两头慢)

"属性分工"是关键 ------两条动画改不同属性 ------永不冲突 ------自然叠加。

为什么周期是4.5s和3s?

因为 4.5和3的最小公倍数是9 ------两条动画的"节拍"9 秒才对齐一次。

观感:

  • 4.5s内:光带绕一圈
  • 3s内:光晕明暗一拍
  • 9s内 :整个"明暗 + 位置"的组合不重复

"永不重复感" ------这就是"互质周期"的价值。

"4.5和3互质吗?"

严格来说,4.5和3都是0.5的倍数------"非严格互质" ------但它们的最小公倍数9秒 ------足够长 ------观感上"很久不重复"。

严格互质的例子 :4.3s和3.1s------最小公倍数133秒 ------更"永不重复"。

实践中,"相差不大但不成倍数"就够了 ------不必严格互质。

为什么breath用ease-in-out?

因为**"呼吸"的自然节奏是"两头慢、中间快"**------用linear会像"眨眼" ------生硬。

所有"呼吸 / 脉动 / 心跳"类的动画,都用ease-in-out。


四、踩坑位(我全踩过,你直接避)

① 没注册就动画变量

css 复制代码
/* ❌ --a没经过@property,0deg→360deg只会硬切,光带"瞬移"不"奔跑" */
@keyframes rotate-border { to { --a: 360deg; } }

/* ✅ 先注册成<angle>,动画才能逐帧插值 */
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }

这是本篇90%复现失败的原因。

没注册 ------浏览器把--a当字符串 ------0deg到360deg之间没有"中间值" ------只能"跳变"。

观感 :光带不是"在跑",而是"每4.5秒瞬间换个位置" ------像故障闪屏。

先注册,再动画 ------这是所有"动画化CSS变量"场景的必经步骤。

② syntax写错

css 复制代码
/* ❌ 三种常见错误 */
@property --a { syntax: angle; }          /* 少了引号 + 尖括号 */
@property --a { syntax: '<length>'; }     /* 类型错了------明明是角度 */
@property --a { syntax: <angle>; }        /* 少了引号 */

正确写法:

css 复制代码
/* ✅ 必须带单引号、必须带尖括号 */
@property --a {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

三个组成部分一个都不能少:

  • syntax :'<angle>'(字符串,带尖括号)
  • inherits :false或true
  • initial-value :0deg(必须写,不写注册失败)

写错了浏览器会在控制台报错 ------但页面不会崩 ------只是效果"没发生" ------容易查不出。

遇到"CSS 变量动画不生效" ------第一件事就是检查syntax写法。

③ mask-composite只写一套前缀

css 复制代码
/* ❌ 只有Firefox生效,Chromium里2px边框光不出来 */
mask-composite: exclude;

/* ✅ 两套都写 */
-webkit-mask-composite: xor;    /* Chromium / Safari */
        mask-composite: exclude; /* Firefox */

这两个值不是"同一个词的两种拼写" ------是"两个不同的关键字":

  • Chromium系、Safari用xor
  • Firefox用exclude

必须两套都写 ------这是mask-composite的跨浏览器"必修课"。

④ 光晕z-index忘了-1

css 复制代码
/* ❌ 糊脸光盖住文字------卡片内容全被遮 */
.card::after { /* 没有z-index */ }

/* ✅ 光晕层压到卡片内容之下 */
.card::after { z-index: -1; }

::after默认是"在元素内容之上" ------ 光晕铺满整卡 ------文字全被"糊住" ------看不见。

加z-index: -1 ------ 光晕跑到卡片下面 ------"光从卡片背后渗出" ------这才是想要的效果。

注意 :用z-index: -1需要"父元素有定位" ------ 卡片本身就是position: relative------满足条件。

⑤ 老Firefox完全空框

css 复制代码
/* 老Firefox:@property不支持--------a不动、conic静止 */

老版本Firefox不支持@property ------ --a无法注册 ------ 动画不动 ------ 光带静止在0deg位置。

"这算可接受降级" ------ 边框还在 (静态渐变)------只是不跑。

但"要求更高"的场景 ------ 用@supports换回纯色边框:

css 复制代码
@supports not (background: paint(something)) {
  /* 或者更精确的检测方式 */
  .card::before {
    background: var(--c1);   /* 降级为纯色边框 */
    animation: none;
  }
}

"可接受降级 vs 完全降级" ------ 看你的产品需求。

这是"新特性上生产线"的通用判断


五、变式挑战(10--30分钟,动手才有收获)

任务:做成直播间封面边框。

基础版(10分钟) ------ 改成"正在直播"的视觉语言:

css 复制代码
/* 卡片改成16:9,更像直播封面 */
.card { width: 360px; height: 202px; }

/* 边框加粗到3px,更"厚实" */
.card::before { padding: 3px; }

/* 流光周期压到2.5s,更"急躁" */
.card::before { animation-duration: 2.5s; }

/* 光晕透明度拉满 */
.card::after { opacity: .8; }

再加一个左上角LIVE红点:

css 复制代码
.card::after {  /* 注意:这里要换一个伪元素或子元素 */
  content: "● LIVE";
  position: absolute;
  top: 14px; left: 14px;
  color: #ff3b5c;
  font-size: 11px;
  letter-spacing: .28em;
  animation: blink 1.2s steps(2) infinite;
}
@keyframes blink { 50% { opacity: .15; } }

"直播视觉语言三件套" ------ 红点 + 光边 + 呼吸 ------ 齐了。

进阶版(20--30分钟) ------ 让流光边跑边变色:

再注册一个数字变量 --h :

css 复制代码
@property --h {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

@keyframes hue-rotate {
  to { --h: 360; }
}

.card::before {
  background: conic-gradient(from var(--a),
    hsl(calc(var(--h)) 80% 60%),         /* ← 色相跟着 --h 走 */
    transparent 26%,
    hsl(calc(var(--h) + 60) 80% 60%),    /* ← 第二个颜色色相差 60° */
    transparent 58%,
    hsl(calc(var(--h)) 80% 60%) 78%,
    hsl(calc(var(--h)) 80% 60%));
  animation:
    rotate-border 4.5s linear infinite,
    hue-rotate    12s  linear infinite;   /* 色相 12 秒转一圈 */
}

效果:

  • 光带绕圈跑(4.5s一圈)
  • 颜色同时缓慢变化(12s一个色相周期)
  • 两条动画周期互质(4.5s和12s,最小公倍数36s)

"跑动的光带 + 流动的色彩" ------ 这是"直播间"/"活动页"最喜欢的效果。

关键点:

  • --h注册为<number> ------ 是"数字"不是"角度" ------ 因为它用在hsl()的第一个参数位置
  • hsl(calc(var(--h)) 80% 60%) ------ calc()包住变量 ------ 让浏览器"知道"这是数值计算
  • 第二个色相var(--h) + 60 ------ 让两个颜色始终差60° ------ "渐变色对"保持相对关系

其他方向(选做):

① 用--a驱动更多属性

比如让一个圆点跟着--a旋转:

css 复制代码
.dot {
  transform: rotate(var(--a)) translateX(140px);
}

"光带跑到哪,圆点就跟到哪" ------ "追踪"效果。

② 加个"点击绽放" ------ 点击卡片时,光带短暂爆发。

③ 加"鼠标悬停加速" ------ hover时动画时长缩短** ------ "光带跑得更快"。


六、小结

今天这一篇里藏着 5个通用技能点:

技能 适用场景
@property注册变量类型 所有"动画化CSS变量"的场景
syntax正确写法(带引号 + 尖括号) 所有@property使用场景
双层mask挖空做边框 所有"只有边框"的效果
conic-gradient做圆周流光 所有"边框流光 / 环形光带"效果
互质周期做"永不重复"叠加 所有"多条动画并存"的场景

最值得记住的三条:

① CSS变量默认是"字符串",注册成类型才能"动画"。

这是本篇最重要的认知转变。

默认情况 :--a是"一段文本"------没法插值 ------动画只能"硬切"。

@property之后 :浏览器知道--a是"角度"------可以逐帧插值 ------动画才真正"跑起来"。

这是Houdini API的核心价值 ------它让CSS变量从"文本替换"升级为"类型化数值"。

这个能力打开了新世界的大门 ------所有"以前不能动画的CSS变量" ------现在都可以了。

  • 角度 (<angle>)------ 旋转渐变
  • 长度 (<length>)------ 动态尺寸
  • 颜色 (<color>)------ 平滑变色
  • 数字 (<number>)------ 各种计算

记住:要用CSS变量做动画,先@property注册。

② 一个conic做两层------边框 + 光晕。

"边框流光"和"外圈光晕" ------ 本质是"同一个conic-gradient" ------ 一个被mask削成2px边框,一个被blur糊成光雾。

"共享同一个角度变量" ------ 两者永远同步 ------ 观感上是"一个完整的光在运动"。

这是"边框 + 光晕"效果的通用公式 ------不用两个conic,一个就够。

③ 互质周期 = "永不重复"的高级感。

两条动画,周期4.5s和3s ------ 最小公倍数9秒 ------ 9秒内整个画面不重复 ------ 观感上"永远在变化"。

"均匀即廉价,错拍才高级"


如果这一篇对你有帮助,三连支持一下这个专栏 🙏

点赞 = 更新加速,收藏 = 随时复制代码,评论 = 你想看什么效果,我排进日程。

本专栏所有demo,复制即用,随便商用。

目标更新:不断更。

相关推荐
小马不起床1 小时前
零基础玩转 FastAPI_10:前端登录、权限与布局
前端·状态模式·fastapi
程序员Sunday1 小时前
JavaScript 深拷贝和浅拷贝有什么区别?structuredClone 能替代 JSON 拷贝吗?
前端·javascript
ShineWinsu1 小时前
对于CSS:选择器的解析
前端·css·面试·html·web·选择器·网页
m0_587383002 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
Pu_Nine_92 小时前
Vue‑Flow 从零入门实战|AI‑Agent 拓扑图开发
前端·javascript·vue.js
keyipatience2 小时前
CSS 学习(1):语法、引入方式、选择器、字体与背景
前端·css·html
程序人生8882 小时前
本地向量底座怎么搭?DocConverter Web 的 BGE 嵌入模型、ChromaDB 与外部 Milvus 接入
前端·milvus
恋猫de小郭2 小时前
Flutter Android 改动:APK 不再允许通过 Intent 动态修改 Engine 参数
android·前端·flutter
程序员大阳2 小时前
副队长HTML+CSS博客网站实战 (5)-文章详情页 article.html开发
css·html·博客网站·详情页