写在前面
昨天我们做了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秒才重复一次 ------"永不重复"的观感。
怎么跑起来:
- 新建一个
demo.html - 把下面第二节的完整代码粘进去
- 双击用浏览器打开
- 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>边框上有一道光在跑,外壳的光晕随之呼吸。秘诀是把自定义属性注册成 <angle> 类型------注册之后,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或trueinitial-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,复制即用,随便商用。
目标更新:不断更。