【每天一个CSS | Day11】赛博朋克故障风文字:红青分离 + 切片抖动

写在前面

我们第一次引入JS,做了一张"光跟着鼠标走"的聚光卡片。

今天回到零JS ,做一种完全不同的质感:赛博朋克故障风文字。

如果你看过《赛博朋克2077》《攻壳机动队》或者任何赛博朋克视觉作品,一定见过这种效果------屏幕上的大字偶尔"犯病":

  • 一层红色副本向左撕开
  • 一层青色副本向右错位
  • 主文本被横切成几条乱跳
  • 零点几秒后恢复如常,再过一会儿又犯

这种"信号不良"的观感,全部用纯CSS 做------两个伪元素 + 两组关键帧。

核心三个技法:content: attr()让副本自动同步 、clip-path切片 + steps(1)跳帧 、红青双通道错相位。


一、先看效果

纯黑底上铺着细密的扫描线 ,四周一圈暗角。左上角一颗 REC红点在闪------暗示"这是一个正在录制的屏幕"。

正中「SIGNAL LOST」白色大字大体安静,但每隔一两秒突然"犯病":

  • 一层红色副本 向左撕开、一层青色副本向右错位
  • 主文本被横切成几条跳位------像录像带卡了一下
  • 零点几秒后恢复如常,再过一会儿又犯

两条错位的爆发时机各自独立 ,红青两层的"乱"是不同步的------这是"随机感"的关键,不是节拍器。

底部一行小字CH-04 · 44.1kHz · PURE CSS NOISE------像老式显示器的频道信息。

怎么跑起来:

  1. 新建一个demo.html
  2. 把下面第二节的完整代码粘进去
  3. 双击用浏览器打开

📸 截图党提示 :故障动画是偶发 的------大部分时间画面安静,只在某一瞬间爆发。建议录 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属性的值,当作我伪元素的内容"

三个好处:

  1. 改一处 :想换文字,只改data-text(或者只改主元素内的文本,同时改data-text)
  2. 自动同步:三层永远一模一样
  3. 语义清晰 :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的篇目会在标题显式标注。

目标更新:不断更。

相关推荐
liangshanbo12151 小时前
React Fiber:从“解决卡顿”到完整更新链路
前端·javascript·react.js
Timmy1 小时前
前端转全栈笔记:讲框架之前,先把 TypeScript 这关过了
前端·人工智能·node.js
计算机魔术师2 小时前
年收入650亿、亏420亿,Anthropic凭什么敢叫价万亿?
前端
Frag0ut2 小时前
Chrome Canary 最新实验功能:AI Mode、按设备筛选历史记录与 Gemini 自动改密
前端·人工智能·chrome·dev·beta·canary·最新实验功能
anew___2 小时前
《从零手写操作系统 (19):Ext2文件系统实战——从内存到磁盘的跨越》
java·服务器·前端·数据库·算法
吴声子夜歌2 小时前
Nginx应用与运维——Nginx Web服务应用实战(HTTPS安全服务器的搭建)
运维·前端·nginx
liangshanbo12152 小时前
Monorepo 工程化面试题:如何设计 Web + Node.js 中间层 + Shared + Scripts 的 Monorepo?
前端·node.js
IT_陈寒2 小时前
Redis集群切换主节点时,服务竟然全员掉线
前端·人工智能·后端
wangbing11252 小时前
开发指南147-WebSocket-前端
前端·websocket·网络协议