写在前面
我们第一次引入JS,做了一张"光跟着鼠标走"的聚光卡片。
今天回到零JS ,做一种完全不同的质感:赛博朋克故障风文字。
如果你看过《赛博朋克2077》《攻壳机动队》或者任何赛博朋克视觉作品,一定见过这种效果------屏幕上的大字偶尔"犯病":
- 一层红色副本向左撕开
- 一层青色副本向右错位
- 主文本被横切成几条乱跳
- 零点几秒后恢复如常,再过一会儿又犯
这种"信号不良"的观感,全部用纯CSS 做------两个伪元素 + 两组关键帧。
核心三个技法:content: attr()让副本自动同步 、clip-path切片 + steps(1)跳帧 、红青双通道错相位。
一、先看效果

纯黑底上铺着细密的扫描线 ,四周一圈暗角。左上角一颗 REC红点在闪------暗示"这是一个正在录制的屏幕"。
正中「SIGNAL LOST」白色大字大体安静,但每隔一两秒突然"犯病":
- 一层红色副本 向左撕开、一层青色副本向右错位
- 主文本被横切成几条跳位------像录像带卡了一下
- 零点几秒后恢复如常,再过一会儿又犯
两条错位的爆发时机各自独立 ,红青两层的"乱"是不同步的------这是"随机感"的关键,不是节拍器。
底部一行小字CH-04 · 44.1kHz · PURE CSS NOISE------像老式显示器的频道信息。
怎么跑起来:
- 新建一个
demo.html - 把下面第二节的完整代码粘进去
- 双击用浏览器打开
📸 截图党提示 :故障动画是偶发 的------大部分时间画面安静,只在某一瞬间爆发。建议录 5秒GIF(两条动画时长2.6s / 2.1s,5秒内必出一次大红错位);或者耐心多截几张,挑一张"红青分离最狠"的瞬间做头图。
二、完整代码(复制即用)
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 11 · 赛博朋克故障风文字</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height:100%; }
body { display:flex; align-items:center; justify-content:center; overflow:hidden;
background:#0a0c10; font-family:"SF Mono", Consolas, monospace; }
/* ------ 扫描线 + 暗角,故障的「载体」------ */
.scan { position:fixed; inset:0; pointer-events:none;
background:repeating-linear-gradient(0deg, rgba(255,255,255,.028) 0 1px, transparent 1px 3px); }
.vig { position:fixed; inset:0; pointer-events:none;
background:radial-gradient(ellipse at center, transparent 52%, rgba(0,0,0,.55)); }
.rec { position:fixed; top:24px; left:26px; display:flex; align-items:center; gap:8px;
font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.3em; color:#5b6878; }
.rec i { width:9px; height:9px; border-radius:50%; background:#ff3b5c;
animation: blink 1.2s steps(2) infinite; }
@keyframes blink { 50% { opacity:.15; } }
.stage { text-align:center; }
/* ------ 故障主体:伪元素克隆自己,红青双通道错相位 ------ */
.glitch { position:relative; display:inline-block;
font:600 clamp(44px, 9vw, 84px)/1.1 "SF Mono", Consolas, monospace;
letter-spacing:.06em; color:#e6edf3; }
.glitch::before, .glitch::after { content:attr(data-text);
position:absolute; left:0; top:0; width:100%; }
.glitch::before { color:#ff3b5c; z-index:-1;
animation: g-a 2.6s steps(1,end) infinite; }
.glitch::after { color:#22e0ff; z-index:-2;
animation: g-b 2.1s steps(1,end) infinite; }
@keyframes g-a {
0%, 6%, 26%, 53%, 78%, 100% { opacity:0; transform:translate(0,0); clip-path:inset(0 0 100% 0); }
7% { opacity:1; transform:translate(-7px,-2px); clip-path:inset(10% 0 58% 0); }
10% { transform:translate(5px,1px); clip-path:inset(64% 0 6% 0); }
13% { transform:translate(-5px,0); clip-path:inset(30% 0 52% 0); }
16% { opacity:0; }
54% { opacity:1; transform:translate(7px,2px); clip-path:inset(72% 0 4% 0); }
58% { transform:translate(-4px,-1px); clip-path:inset(18% 0 66% 0); }
62% { opacity:0; }
80% { opacity:1; transform:translate(-8px,1px); clip-path:inset(48% 0 34% 0); }
84% { transform:translate(6px,-2px); clip-path:inset(8% 0 78% 0); }
88% { opacity:0; } }
@keyframes g-b {
0%, 9%, 33%, 61%, 86%, 100% { opacity:0; transform:translate(0,0); clip-path:inset(0 0 100% 0); }
10% { opacity:1; transform:translate(6px,2px); clip-path:inset(58% 0 12% 0); }
14% { transform:translate(-5px,-1px); clip-path:inset(6% 0 80% 0); }
18% { transform:translate(4px,0); clip-path:inset(40% 0 38% 0); }
22% { opacity:0; }
62% { opacity:1; transform:translate(-6px,-2px); clip-path:inset(22% 0 60% 0); }
66% { transform:translate(7px,1px); clip-path:inset(66% 0 10% 0); }
70% { opacity:0; }
87% { opacity:1; transform:translate(6px,-1px); clip-path:inset(34% 0 48% 0); }
91% { opacity:0; } }
.sub { margin-top:26px; font:400 11px/1 "SF Mono", Consolas, monospace;
letter-spacing:.32em; color:#5b6878; }
.sub b { color:#ff3b5c; font-weight:400; }
.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="scan"></div>
<div class="vig"></div>
<div class="rec"><i></i>REC</div>
<div class="stage">
<div class="glitch" data-text="SIGNAL LOST">SIGNAL LOST</div>
<div class="sub">CH-04 · <b>44.1kHz</b> · PURE CSS NOISE</div>
</div>
<div class="tag">每天一个惊艳 CSS · DAY 11</div>
</body>
</html>
三、三个核心技法拆解
代码看着长,其实只有三个点------每一个拆开都能单独用在别的地方。
技法1:content: attr(data-text)让副本永远同步
这是整篇最"省心"的设计。
故障效果需要三层一模一样的文字(主文本 + 红色副本 + 青色副本)。如果每层都手动写一遍:
html
<!-- ❌ 三层文本要手写三遍 -->
<div class="glitch">
<span class="text">SIGNAL LOST</span>
<span class="red">SIGNAL LOST</span>
<span class="cyan">SIGNAL LOST</span>
</div>
改一个字要改三处------一旦漏改一处,主文本和副本就错位了,效果全崩。
优雅做法 :用content: attr(data-text)让伪元素自动克隆主元素的内容:
css
.glitch::before, .glitch::after {
content: attr(data-text); /* 读取主元素的data-text属性 */
position: absolute; left: 0; top: 0; width: 100%;
}
html
<div class="glitch" data-text="SIGNAL LOST">SIGNAL LOST</div>
读一遍attr():
attr()是CSS里唯一能读取 HTML 属性值的函数content: attr(data-text)意思是"把我的data-text属性的值,当作我伪元素的内容"
三个好处:
- 改一处 :想换文字,只改
data-text(或者只改主元素内的文本,同时改data-text) - 自动同步:三层永远一模一样
- 语义清晰 :
data-text就是"这是给伪元素用的文本"
局限 :attr()只能在content里用------你不能拿它去读颜色、坐标、尺寸。它就是个"取属性文本"的工具。
这是故障字最重要的一条工程经验------不这么写,改一次文案要痛苦三轮。
技法2:clip-path切片 + steps(1)跳帧
故障字看起来"撕裂",本质上是"文字的一部分被切片并位移"。
怎么实现"只显示文字的一条横带"?
用clip-path: inset(上 右 下 左):
css
clip-path: inset(10% 0 58% 0); /* 顶部留10%,底部留58%,只显示中间那一横条 */
inset()的四个值(顺时针:上、右、下、左)表示"从对应边向内裁掉多少"。
inset(10% 0 58% 0)→ 上边裁10%、下边裁58% → 只留下10% ~ 42%之间的一条横带inset(0 0 100% 0)→ 下边裁100% → 什么都不显示(整段消失)
所以每个关键帧的clip-path就是在说:"这一帧,只显示哪一条横带。"
但光有切片还不够------切片之间必须是"硬切",不是"平滑滑"。
css
.glitch::before { animation: g-a 2.6s steps(1, end) infinite; }
steps(1, end)是关键的时序函数。
普通ease / linear会让关键帧之间平滑插值------文字会像"慢舞"一样从一条横带缓缓滑到另一条,观感完全不对。
steps(1, end)意思是"每个关键帧区间内只取1步,且停在区间末尾 "------数值直接跳变,从这条横带"啪"地跳到下一条。
这就是故障感的来源------真实信号干扰本来就是硬切,不是渐变的。
再看g-a关键帧的整体节奏:
css
@keyframes g-a {
0%, 6%, 26%, 53%, 78%, 100% { opacity:0; ... } /* 六段"静默"区间 */
7% { opacity:1; ... } /* 短暂爆发 */
10% { ... }
13% { ... }
16% { opacity:0; } /* 恢复静默 */
/* 后面还有两轮小爆发 */
}
注意两个数量级:
- 静默占比 ≥ 70% :06、1626、2653、6278......大部分时间
opacity: 0(完全看不见) - 爆发只有3秒左右:7~16秒之间的密集变化
"大部分时间安静、偶尔犯病"------这才是故障感的关键。如果文字一直在撕,会变成"乱码字",不叫故障。
这是所有"故障风 / 信号干扰 / 随机闪烁"效果的通用心法:
不要让效果一直在发生。让它"大部分时间沉默,偶尔抽一下"。
沉默得越久,爆发那一下的冲击力越强。
技法3:红青双通道错相位
为什么用红色和青色?
因为这是模拟老式显像管(CRT)的RGB三枪失准------红色通道向左偏、蓝色/青色通道向右偏,中间的主文本保持原位。
《赛博朋克2077》标志的"红青重影"就是这个原理。
css
.glitch::before { color:#ff3b5c; } /* 红:向左撕 */
.glitch::after { color:#22e0ff; } /* 青:向右错 */
关键:两层副本用不同的动画时长。
css
.glitch::before { animation: g-a 2.6s steps(1, end) infinite; }
.glitch::after { animation: g-b 2.1s steps(1, end) infinite; }
2.6s vs 2.1s------两个互质的时长。
为什么必须不一样?
如果两层都是2.6s、同相位触发,那么红色和青色副本会同时出现、同时消失、同时偏移------观感变成"红青合体闪一下",像节拍器,一点"乱"的感觉都没有。
2.6s和2.1s的最小公倍数约是54.6s ------也就是说,两层副本要转21圈 / 26圈才会有一次同时爆发。
在54.6秒内,红青两层的爆发时机永远对不上。
这才是"乱"的来源 ------不是把参数调随机,而是用互质时长制造长期的不同步。
"错拍才高级"是本专栏最重要的心法之一------每次都是同样的原理,只是换了不同的参数形式。
最后,z-index压到主文本之下:
css
.glitch::before { z-index: -1; }
.glitch::after { z-index: -2; }
为什么是负值?
主文本是正常的文字(在文档流里),伪元素需要叠在主文本下方 ------所以用负的z-index,让它们"垫在文字背后"。
主文本永远在最上层,永远清晰可读------这就是为什么故障字依然能读清楚是什么字。
四、踩坑位(我全踩过,你直接避)
① attr()用在content以外的地方
css
/* ❌ 想用attr()读颜色、读坐标------不存在的 */
.glitch {
color: attr(data-color); /* 无效 */
transform: translateX(attr(data-x)); /* 无效 */
}
/* ✅ 只用在content里 */
.glitch::before { content: attr(data-text); }
attr()是CSS里唯一读HTML属性的函数 ,但它只被标准允许用在content属性里。
虽然现在有些浏览器实验性地支持在其他属性里用attr()读类型化数值(比如颜色、长度),但那不是标准,跨浏览器不可靠。
记住 :attr()就等于"取一段文本,塞进content",仅此而已。
② 伪元素没对齐
css
/* ❌ 忘了定位三件套,副本跑到别处,"分离"变成了"分家" */
.glitch::before { content: attr(data-text); }
/* ✅ 必须完全叠在主文本上 */
.glitch::before, .glitch::after {
content: attr(data-text);
position: absolute; left: 0; top: 0; width: 100%;
}
三条定位缺一不可:
position: absolute→ 脱离文档流,可以自由叠放left: 0; top: 0→ 从主文本的左上角开始铺width: 100%→ 宽度撑满,文字才不会被压成一行竖排
别忘了还有一条隐性的 :.glitch本身要position: relative------否则absolute是相对最近的定位祖先,而不是主文本。
③ 忘了steps,故障变舞蹈
css
/* ❌ 关键帧之间平滑插值,文字在"慢舞" */
.glitch::before { animation: g-a 2.6s ease infinite; }
/* ✅ steps(1, end),硬切跳变 */
.glitch::before { animation: g-a 2.6s steps(1, end) infinite; }
不加steps是新手最常犯的错。
ease会让两条横带之间渐变过渡------看起来不像"信号撕裂",像"文字在变魔术"。
steps(1, end)让每一帧直接跳变------这才是故障该有的质感。
end是可选参数 :steps(1)默认就是end。写成steps(1, end)是为了明确。
④ 动画太规律
css
/* ❌ 单一循环 + 固定节奏 → 一眼看穿是节拍器 */
@keyframes g-a {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
故障感需要"不规则"------你不能让它整点报时一样每2秒准时抽一次。观众一眼就看出来是"程序在放动画",不是"信号在干扰"。
两个做法:
- 时长互质:红2.6s、青2.1s,长期不同步
- 静默段占比 ≥ 70% :大部分时间
opacity: 0,偶尔爆发
合并起来就是"随机感"------虽然实际上完全是确定性的,但观感上像"真的信号在乱"。
⑤ 可访问性
这条不是"坑",是责任:
html
<!-- ✅ 主文本保留为真实文本 -->
<div class="glitch" data-text="SIGNAL LOST">SIGNAL LOST</div>
- 屏幕阅读器会读主文本 (
<div>里的"SIGNAL LOST") - 伪元素内容不会被读 (
content: attr(data-text)只是视觉副本)
所以主文本必须保留真实文字------别为了效果把正文换成图片,那会让屏幕阅读器用户完全无法访问。
顺便说一下data-text的写法:
html
<!-- ⚠️ 主文本和data-text必须一致 -->
<div class="glitch" data-text="SIGNAL LOST">SIGNAL LOST</div>
如果两者不一致,视觉上你会看到主文本,但副本会显示data-text的内容------错位会非常明显。
记得改文案时,两处一起改。
五、变式挑战(10--30分钟,动手才有收获)
任务:默认安静、hover才"犯病"。
现在这版是一直在自我表演------页面一打开就在随机抽。
进阶版 :把两条动画挂到.glitch:hover::before / :after上,平时伪元素opacity: 0:
css
/* 默认安静 */
.glitch::before,
.glitch::after { opacity: 0; }
/* hover时才犯病 */
.glitch:hover::before { animation: g-a 2.6s steps(1, end) infinite; }
.glitch:hover::after { animation: g-b 2.1s steps(1, end) infinite; }
效果:
- 平时:正常标题,安静
- 鼠标压上去:标题当场抽搐
- 移开:立刻恢复
这个版本放进真实网站毫无违和感------导航logo、主按钮、Hero区的品牌字,hover时的"故障一下"就是很好的交互彩蛋。
关键 :默认安静是克制------你不用它的时候它不打扰你,你需要它的时候它给你惊喜。这是"效果用在正确的地方"。
进阶方向(有余力再玩):
① 加第三层副本------"重影拖尾"
再借一个子元素(.glitch > .ghost)做第三层:
css
.glitch > .ghost {
content: attr(data-text);
position: absolute; left: 0; top: 0; width: 100%;
color: #fff;
opacity: 0;
animation: g-c 4.3s steps(1, end) infinite; /* 更长的时长,更低的爆发频率 */
}
@keyframes g-c {
0%, 95%, 100% { opacity: 0; }
96% { opacity: 1; transform: translate(-14px, 3px); }
97% { transform: translate(12px, -2px); }
98% { opacity: 0; }
}
白色 + 大位移 + 极低概率闪现------只在关键时刻"重影"一下,让整个效果更炸。
② 让"REC"点随故障同步闪
现在REC点是独立闪烁的(1.2s steps(2))。可以让它和故障同步------红色副本爆发时,REC 点也跳一下。
做法 :给REC点加一个独立的animation-delay,让它在g-a第一次爆发的时刻(7% of 2.6s ≈ 0.18s)也闪一下。
③ 换文字、换颜色
- 换成「SYSTEM ERROR」「NO SIGNAL」「404」------
- 把红青换成洋红+青绿 (更电子感)或橙红+宝蓝(更复古 CRT)
六、小结
今天这一篇里藏着 5个通用技能点:
| 技能 | 适用场景 |
|---|---|
content: attr(data-text) 克隆文本 |
所有需要"一份内容多份视觉副本"的效果 |
clip-path: inset() 切片保留横带 |
切片、扫描线、异形裁切 |
steps(1, end) 硬切跳帧 |
故障、闪烁、机械感、像素动画 |
| 静默占比 ≥ 70% 的爆发式动画 | 所有"偶尔抽一下"的效果 |
| 互质时长制造长期不同步 | 所有多点动画的错拍 |
最值得记住的两条:
① 让效果"大部分时间沉默,偶尔抽一下"。
这条不只适用于故障字------所有"随机感"效果的通用心法:
- 不要把动画做成"每2秒规律发生一次"
- 静默段拉到70%以上
- 爆发段集中在几帧里
沉默得越久,爆发那一下的冲击力越强。
② 互质时长 = 长期不同步 = 观感上的"乱"。
红2.6s、青2.1s------两个互质的时长意味着两层副本要转21圈和26圈才会同时爆发。在54.6秒内,观感永远是"随机"。
不需要真的"随机数" ------互质时长 + 静默占比,就能骗过眼睛。
如果这一篇对你有帮助,三连支持一下这个专栏 🙏
点赞 = 更新加速,收藏 = 随时复制代码,评论 = 你想看什么效果,我排进日程。
本专栏所有demo,复制即用,随便商用。
带JS的篇目会在标题显式标注。
目标更新:不断更。