用 CSS 3D 做一个「星空隧道」词云 Loading(Vue 实战拆解)
写在前面
做 Loading 最容易翻车的两种姿势:
- 纯 CSS spinner:转啊转,用户看了三秒开始怀疑人生。
- 上 Canvas / Three.js:效果是真炫,但为了一页 Loading 拉一整座 3D 引擎,像是为了煮泡面开了核电站。
有没有第三条路?有------浏览器原生的 CSS 3D Transform + 一点可控随机。
核心就一句话:
每个词的平面坐标
(x, y)一辈子不变,只让它在深度轴z上飞;再给父级加perspective,透视会自动把远处的点收向屏幕中心的灭点。
经典屏保「星空隧道 / Warp Speed」本质上就是这个把戏。我们只不过把星星换成了会说话(还会抬杠)的关键词。
本文会从零拆到能抄:
- 透视舞台怎么搭
- 单字飞行的 keyframes 怎么写
- 词云坐标怎么「看起来随机又不像乱码糊脸」
- 负
animation-delay为什么能让首屏立刻热闹 - 光晕、进度环、流光按钮怎么叠层
- 一份可直接跑的 Vue SFC 精简版
一、先建立直觉:你到底在看什么?
1.1 灭点是免费的
给容器写上:
css
.stage {
perspective: 900px;
perspective-origin: 50% 50%;
}
浏览器会假装你面前有一台相机。同样大小的物体,越远越小,越靠近画面中心 。这个中心点就叫灭点(vanishing point)。

你不需要自己算投影矩阵。只要把元素用 translate3d(x, y, z) 丢进 3D 空间,屏幕上的位置和大小会「长」出来。
1.2 为什么只动 z 就够了?
很多人一上来就同时改 x/y/z,结果词在屏幕上横冲直撞,像被洗衣机甩过。
隧道感其实来自更克制的运动:
| 量 | 动画过程中 | 作用 |
|---|---|---|
x |
恒定 | 决定它落在灭点的哪一侧 |
y |
恒定 | 同上 |
z |
从 z0 插到 z1 | 唯一的运动自由度 |
在 CSS 约定里:+z 朝向镜头(更近),-z 远离镜头(更远)。
我们的关键帧是 z0 → z1,且 z1 = z0 - travel(更负),也就是飞向远方 。透视一开,屏幕观感就是:词从环带外侧逐渐缩小并汇向灭点------隧道感就来了。

可以把它想成:每个词都钉在一条平行于光轴的「轨道」上,自己不会左右变道;变的是你和它的距离。
二、页面分层:先把舞台搭好
实际页面是四层三明治(从底到顶):

对应到 Vue 模板,大致是:
html
<template>
<div class="relative min-h-screen overflow-hidden bg-[#0C081E]">
<!-- L1 词云隧道 -->
<div class="pointer-events-none absolute inset-0 z-[5] [perspective:900px]">
<div class="absolute inset-0 [transform-style:preserve-3d]">
<span v-for="(item, i) in keywords" :key="i" class="loading-kw" :style="...">
{{ item.label }}
</span>
</div>
</div>
<!-- L2 中心光晕(氛围灯) -->
<div class="pointer-events-none absolute ... z-10 blur-[200px]">
<img src="/your-glow.png" />
</div>
<!-- L3 进度环 + 按钮 -->
<div class="absolute left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2">
<!-- 双环 + 百分比 + CTA -->
</div>
</div>
</template>
几个容易被忽略的细节:
overflow-hidden:词飞出视野时别把页面撑出滚动条。pointer-events-none:词云和光晕是装饰,别挡住按钮点击。transform-style: preserve-3d:子元素的translateZ才真正参与 3D,而不是被拍扁成 2D。will-change: transform:提示浏览器把这些字丢进合成层(别滥用到全站,Loading 页用一下还好)。
三、单字动画:CSS 变量才是灵魂
3.1 每个词一份「飞行档案」
每个关键词不是同一段动画的复制粘贴,而是同一套 keyframes + 不同的自定义属性:
ts
type LoadingKeyword = {
label: string;
x: string; // 例如 "23.5vw"
y: string; // 例如 "-18.2vh"
z0: number; // 起点深度(px)
z1: number; // 终点深度(px,更负)
opacity: number;// 峰值透明度
duration: number;// 秒
delay: number; // 秒(常为负数,见后文)
};
模板里把它们灌进 style:
html
<span
class="loading-kw absolute left-1/2 top-1/2 whitespace-nowrap text-white"
:style="{
'--kw-x': item.x,
'--kw-y': item.y,
'--kw-z0': `${item.z0}px`,
'--kw-z1': `${item.z1}px`,
'--kw-op': item.opacity,
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
}"
>
{{ item.label }}
</span>
注意定位:left-1/2 top-1/2 把原点放到屏幕中心,再在 transform 里用 calc(-50% + var(--kw-x)) 做自身居中 + 轨道偏移。这样 --kw-x/y 的语义就是「相对灭点的偏移」,心智模型很干净。
3.2 Keyframes:透明度有呼吸,位置只走 z
css
@keyframes loading-kw-converge {
0% {
opacity: 0;
transform: translate3d(
calc(-50% + var(--kw-x)),
calc(-50% + var(--kw-y)),
var(--kw-z0)
);
}
12% {
opacity: var(--kw-op);
}
78% {
opacity: calc(var(--kw-op) * 0.55);
}
100% {
opacity: 0;
transform: translate3d(
calc(-50% + var(--kw-x)),
calc(-50% + var(--kw-y)),
var(--kw-z1)
);
}
}
.loading-kw {
transform-style: preserve-3d;
backface-visibility: hidden;
animation-name: loading-kw-converge;
animation-timing-function: linear;
animation-iteration-count: infinite;
}

设计意图拆开说:
- 0% → 12%:淡入,避免词突然「啪」一下出现。
- 12% → 78% :缓慢降到
0.55 × 峰值,中间段可读,又不抢进度环风头。 - 78% → 100%:淡出到 0,和飞远缩小同步,结尾不脏。
linear:深度匀速变化时,透视造成的「屏幕速度」本来就会近快远慢,这正是隧道感的一部分;再叠加 ease 反而容易发腻。
backface-visibility: hidden 是小保险:极端角度下减少背面闪烁(字本身几乎不翻面,但保平安)。
3.3 为什么 keyframes 要写在「非 scoped」里?
Vue 的 <style scoped> 会给选择器加属性选择器。@keyframes 名称若被改写,而 UnoCSS / 全局 class 引用的是原始名,就会对不上。
实战建议:
- 动画名 + keyframes :放全局
<style>(或独立 css 文件)。 - 第三方组件皮肤 (比如改 Progress 轨道圆角):继续用 scoped +
:deep()。
四、词云是怎么「生成」的?(重点中的重点)
动画好看,一半靠 CSS,一半靠出生点质量。如果 120 个词全挤在中心,进度环会被淹没;如果全挤一边,又像坏了的烟花。
我们用的是一套「可控随机」流水线。
4.1 搞笑词库(这里根据你需要自行填写需要展示的文案)
ts
const LABELS = [
"我本地是好的",
"先上线再说",
"需求又改了",
"重启试试",
"咖啡续命",
"摸鱼中",
"LGTM",
"生产炸了",
"加个班",
"明天再说",
"又双叒叕",
"不是Bug是Feature",
"等等我再看一眼",
"这个很难评",
"建议下班再说",
] as const;
/** 长词需要更大间距,别让它们接吻 */
const LABEL_WEIGHT: Record<(typeof LABELS)[number], number> = {
"我本地是好的": 1.15,
"先上线再说": 1.1,
"需求又改了": 1.1,
"重启试试": 1,
"咖啡续命": 1,
"摸鱼中": 0.9,
"LGTM": 0.85,
"生产炸了": 1,
"加个班": 0.95,
"明天再说": 1,
"又双叒叕": 1.05,
"不是Bug是Feature": 1.25,
"等等我再看一眼": 1.2,
"这个很难评": 1.05,
"建议下班再说": 1.15,
};
4.2 伪随机:Mulberry32
Math.random() 每次刷新都长一张新脸,Debug 时你会怀疑人生。给生成器一个 seed,同一套参数就能复现同一片星空:
ts
function mulberry32(seed: number) {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
特点:短、快、足够「看起来随机」。Loading 又不是抽奖系统,不必上密码学 RNG。
4.3 环形(甜甜圈)采样
中心要留给进度环,所以词只能长在环带上:

ts
const R_MIN = 18;
const R_MAX = 54;
function samplePoint(rnd: () => number): { px: number; py: number } {
for (let tryN = 0; tryN < 32; tryN++) {
const angle = rnd() * Math.PI * 2;
// sqrt:按面积均匀,避免点都挤在内圈
const r = R_MIN + Math.sqrt(rnd()) * (R_MAX - R_MIN);
const px = Math.cos(angle) * r * 1.18; // 横向略拉宽
const py = Math.sin(angle) * r * 0.9; // 纵向略压扁
if (Math.hypot(px, py) >= R_MIN) return { px, py };
}
return { px: R_MIN * 1.2, py: 0 };
}
三个关键小技巧:
sqrt(rnd):极坐标半径如果直接线性随机,密度会向内圈堆;开方后更接近「环带面积均匀」。* 1.18 / * 0.9:故意做成扁椭圆,更贴合宽屏构图。- 失败兜底:理论上极难触发,但随机世界里总要给自己留条退路。
这里的 px/py 还不是最终 CSS 值,后面会乘上 vw/vh,让布局跟视口走。
4.4 拒绝采样:能不重叠就不重叠
光随机还不够------字会打架。做法是经典的 Poisson-ish / rejection sampling:

ts
type Placed = { px: number; py: number; nearness: number; weight: number };
function baseMinDistance(count: number): number {
const area = Math.PI * (R_MAX * 1.12) ** 2 - Math.PI * R_MIN ** 2;
// 词越多,最小间距越小,但夹在 [5.5, 13]
return Math.min(13, Math.max(5.5, Math.sqrt(area / count) * 0.92));
}
function tooClose(a: Placed, b: Placed, baseMin: number): boolean {
// 近景字视觉更大 → 间距要求更高;长词再加一点
const scale = 0.65 + 0.55 * ((a.nearness + b.nearness) / 2);
const weight = (a.weight + b.weight) / 2;
const min = baseMin * scale * weight;
const dx = a.px - b.px;
const dy = a.py - b.py;
return dx * dx + dy * dy < min * min;
}
放置循环的策略是「先严后松」:
ts
// 伪代码
for each keyword:
先随机出 z0,算出 nearness
for relax in 0..5:
minFactor = 1 - relax * 0.12 // 逐步放宽
尝试若干次 samplePoint
若与所有已放置点都不 tooClose → 接受
若仍失败 → 硬放一个点(靠 z 景深错开观感)
这比「纯随机」稳,又比真正的泊松盘采样好写。Loading 页的预算是毫秒级,别上太重的空间索引。
4.5 nearness:把「远近」写成可调参数
ts
const z0 = Math.round(-80 + rnd() * 280); // [-80, 200]
const nearness = Math.min(1, Math.max(0, (z0 + 80) / 280)); // 映射到 [0,1]
const travel = 320 + rnd() * 480; // 飞行路程
const z1 = Math.round(z0 - travel); // 更远
const opacity = 0.18 + nearness * 0.72 + rnd() * 0.08;
const duration = 5.5 + (1 - nearness) * 6 + rnd() * 5;
// 近的:更亮、更快;远的:更淡、更慢 ------ 层次就出来了
nearness 同时影响:
- 峰值透明度
- 飞行时长
- 与其它词的最小间距
一个参数撬动三处,比给每个字段各写一套魔法数更不容易调崩。
4.6 负 delay:让首帧就「已经在飞」
ts
const delay = -rnd() * duration;
如果所有词都 delay >= 0,打开页面会有一小段「集体淡入」的空洞期。
给一个落在 (-duration, 0] 的负延迟,等于告诉浏览器:
「这段循环动画你已经播过一会儿了,请从中间某一帧开始。」
于是首屏立刻是热闹的星空,而不是集体彩排。
4.7 完整 buildKeywords
ts
export function buildKeywords(count: number, seed = 20250913): LoadingKeyword[] {
const rnd = mulberry32(seed + count * 997);
const list: LoadingKeyword[] = [];
const placed: Placed[] = [];
const baseMin = baseMinDistance(count);
for (let i = 0; i < count; i++) {
const label = LABELS[Math.floor(rnd() * LABELS.length)]!;
const weight = LABEL_WEIGHT[label];
const z0 = Math.round(-80 + rnd() * 280);
const nearness = Math.min(1, Math.max(0, (z0 + 80) / 280));
let px = 0;
let py = 0;
let ok = false;
for (let relax = 0; relax < 6 && !ok; relax++) {
const minFactor = 1 - relax * 0.12;
const tries = 40 + relax * 20;
for (let t = 0; t < tries; t++) {
const p = samplePoint(rnd);
const candidate: Placed = { px: p.px, py: p.py, nearness, weight };
if (placed.every((o) => !tooClose(candidate, o, baseMin * minFactor))) {
px = p.px;
py = p.py;
ok = true;
break;
}
}
}
if (!ok) {
const p = samplePoint(rnd);
px = p.px;
py = p.py;
}
placed.push({ px, py, nearness, weight });
const travel = 320 + rnd() * 480;
const z1 = Math.round(z0 - travel);
const opacity = Math.round((0.18 + nearness * 0.72 + rnd() * 0.08) * 100) / 100;
const duration = Math.round((5.5 + (1 - nearness) * 6 + rnd() * 5) * 100) / 100;
const delay = Math.round(-rnd() * duration * 100) / 100;
list.push({
label,
x: `${Math.round(px * 100) / 100}vw`,
y: `${Math.round(py * 100) / 100}vh`,
z0,
z1,
opacity,
duration,
delay,
});
}
return list;
}
单位选择也有讲究:x/y 用 vw/vh,换窗口大小时轨道会跟着拉伸;z 用 px,深度尺度更稳定,不跟字体/布局耦合太死。
五、响应式:词数别写死
1440 宽塞 80 个刚好;4K 还是 80 个会显得寂寞;手机塞 160 个又像下雪。
一个简单分段:
ts
/** 1440→80、1920→120、2560→160;每约 480px 一档 +40 */
export function keywordCountForWidth(width: number): number {
const steps = Math.round((width - 1440) / 480);
return Math.min(280, Math.max(40, 80 + steps * 40));
}
挂载时算一次,resize 时 debounce 重建:
ts
const keywords = ref<LoadingKeyword[]>(buildKeywords(80));
let lastCount = 80;
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
function syncKeywordsByWidth() {
const count = keywordCountForWidth(window.innerWidth);
if (count === lastCount && keywords.value.length === count) return;
lastCount = count;
keywords.value = buildKeywords(count);
}
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(syncKeywordsByWidth, 150);
}
onMounted(() => {
syncKeywordsByWidth();
window.addEventListener("resize", onResize);
});
onUnmounted(() => {
if (resizeTimer) clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
});
ts
:key="${keywords.length}-${i}`" // 可以在词数变化时强制重挂动画,避免旧 delay 状态残留。
六、中心光晕:一张图 + 大模糊
隧道中间如果太「空」,进度环会像贴在黑板上的便利贴。解法很土很有效:
html
<div
class="pointer-events-none absolute left-1/2 top-1/2 z-10
size-[min(90vw,744px)] -translate-x-1/2 -translate-y-1/2 blur-[200px]"
>
<img src="/home/loading-glow-mask.png" />
</div>
要点:
- 先放大再 blur:模糊半径吃的是元素自身像素,图太小会糊成脏污。
- 圆形裁切:和进度环同心,像一盏氛围灯。
- 别指望它提供信息:它只负责「有光」,信息架构仍在进度环上。
你也可以改成纯 CSS 径向渐变,少一次图片请求;用图则更容易拿到设计稿同款气质。
七、进度环与「假装在加载」
真实项目里进度通常来自任务回调;演示页可以自己模拟:
ts
const simulated = ref(1);
let timer: ReturnType<typeof setInterval> | undefined;
const progress = computed(() =>
Math.min(100, Math.max(1, Math.round(props.progress ?? simulated.value))),
);
onMounted(() => {
if (props.progress != null) return;
timer = setInterval(() => {
if (simulated.value >= 92) {
clearInterval(timer);
return;
}
// 越到后面越慢一点的手感:这里用随机步长近似
simulated.value += Math.random() * 3 + 0.6;
}, 400);
});
故意停在 92% 附近是交互老传统了------真完成时再跳到 100 并离场,比匀速走到 100 再干等更不容易被骂「假进度」。
环的视觉可以很简单:外环淡描边 + 内环实色描边 + inset 阴影,让中心像被光罩盖住:
html
<div class="rounded-full border border-[rgba(84,98,246,0.4)]">
<div
class="rounded-full border-2 border-[#5653F8]
shadow-[inset_0_4px_40px_#5653F8]
bg-[rgba(12,8,30,0.05)]"
>
<span class="text-2xl font-bold text-white tabular-nums">{{ progress }}%</span>
<!-- 进度条组件或手写 div 皆可 -->
</div>
</div>
tabular-nums 很重要:否则 11% → 12% 会抖宽度,环心文字左右蹦迪。
八、按钮流光:背景位置在跑步
CTA 想要一点「还活着」的感觉,不必上粒子。一个 200% 宽的双色渐变,让 background-position 循环移动即可:
css
@keyframes loading-bg-flow {
from { background-position: 200% 0; }
to { background-position: 0% 0; }
}
html
<button
type="button"
class="border border-white rounded-[100px]
bg-[length:200%_100%]
bg-[linear-gradient(to_right,rgb(84_98_246/90%),rgb(165_103_254/90%),rgb(84_98_246/90%))]
animate-[loading-bg-flow_4s_linear_infinite]
before:absolute before:inset-[-4px] before:-z-1 before:rounded-[100px]
before:bg-[length:200%_100%] before:opacity-0 before:blur-[10px]
hover:before:opacity-100"
>
后台继续卷
</button>
注意方向:background-position 从 200% 走到 0%,视觉上渐变是从右往左「流」;想反过来就交换 from/to。
Hover 时用伪元素再糊一层同款渐变,像给按钮套了外发光,成本极低。
九、把它们组装成一个可跑的 Vue 精简版
下面是「教学用」单文件版本:去掉路由、i18n、UI 库,只留动画骨架。你可以把 keywords.ts 逻辑内联,也可以继续拆文件。
html
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
type LoadingKeyword = {
label: string;
x: string;
y: string;
z0: number;
z1: number;
opacity: number;
duration: number;
delay: number;
};
const LABELS = [
"我本地是好的",
"先上线再说",
"需求又改了",
"重启试试",
"咖啡续命",
"摸鱼中",
"LGTM",
"生产炸了",
"不是Bug是Feature",
"建议下班再说",
] as const;
function mulberry32(seed: number) {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function buildKeywords(count: number, seed = 20250913): LoadingKeyword[] {
const rnd = mulberry32(seed + count * 997);
const R_MIN = 18;
const R_MAX = 54;
const area = Math.PI * (R_MAX * 1.12) ** 2 - Math.PI * R_MIN ** 2;
const baseMin = Math.min(13, Math.max(5.5, Math.sqrt(area / count) * 0.92));
const placed: { px: number; py: number; nearness: number }[] = [];
const list: LoadingKeyword[] = [];
const sample = () => {
const angle = rnd() * Math.PI * 2;
const r = R_MIN + Math.sqrt(rnd()) * (R_MAX - R_MIN);
return { px: Math.cos(angle) * r * 1.18, py: Math.sin(angle) * r * 0.9 };
};
for (let i = 0; i < count; i++) {
const z0 = Math.round(-80 + rnd() * 280);
const nearness = Math.min(1, Math.max(0, (z0 + 80) / 280));
let px = 0;
let py = 0;
let ok = false;
for (let relax = 0; relax < 6 && !ok; relax++) {
const minFactor = 1 - relax * 0.12;
for (let t = 0; t < 40 + relax * 20; t++) {
const p = sample();
const min = baseMin * minFactor * (0.65 + 0.55 * nearness);
if (placed.every((o) => (o.px - p.px) ** 2 + (o.py - p.py) ** 2 >= min ** 2)) {
px = p.px;
py = p.py;
ok = true;
break;
}
}
}
if (!ok) ({ px, py } = sample());
placed.push({ px, py, nearness });
const travel = 320 + rnd() * 480;
const duration = Math.round((5.5 + (1 - nearness) * 6 + rnd() * 5) * 100) / 100;
list.push({
label: LABELS[Math.floor(rnd() * LABELS.length)]!,
x: `${Math.round(px * 100) / 100}vw`,
y: `${Math.round(py * 100) / 100}vh`,
z0,
z1: Math.round(z0 - travel),
opacity: Math.round((0.18 + nearness * 0.72) * 100) / 100,
duration,
delay: Math.round(-rnd() * duration * 100) / 100,
});
}
return list;
}
const keywords = ref(buildKeywords(80));
const simulated = ref(1);
const progress = computed(() => Math.min(100, Math.round(simulated.value)));
let timer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
timer = setInterval(() => {
if (simulated.value >= 92) return clearInterval(timer);
simulated.value += Math.random() * 3 + 0.6;
}, 400);
});
onUnmounted(() => timer && clearInterval(timer));
</script>
<template>
<div class="relative min-h-screen overflow-hidden bg-[#0C081E] text-white">
<div class="pointer-events-none absolute inset-0 [perspective:900px]">
<div class="absolute inset-0 [transform-style:preserve-3d]">
<span
v-for="(item, i) in keywords"
:key="i"
class="loading-kw absolute left-1/2 top-1/2 whitespace-nowrap text-[22px]"
:style="{
'--kw-x': item.x,
'--kw-y': item.y,
'--kw-z0': item.z0 + 'px',
'--kw-z1': item.z1 + 'px',
'--kw-op': item.opacity,
animationDuration: item.duration + 's',
animationDelay: item.delay + 's',
}"
>
{{ item.label }}
</span>
</div>
</div>
<div class="absolute left-1/2 top-1/2 z-20 flex size-48 -translate-x-1/2 -translate-y-1/2
items-center justify-center rounded-full border border-indigo-400/40">
<div class="flex size-36 flex-col items-center justify-center rounded-full
border-2 border-indigo-500 shadow-[inset_0_4px_40px_#5653F8]">
<div class="text-2xl font-bold tabular-nums">{{ progress }}%</div>
<p class="mt-3 text-sm opacity-50">系统正在严肃地摸鱼...</p>
</div>
</div>
</div>
</template>
<style>
@keyframes loading-kw-converge {
0% {
opacity: 0;
transform: translate3d(calc(-50% + var(--kw-x)), calc(-50% + var(--kw-y)), var(--kw-z0));
}
12% { opacity: var(--kw-op); }
78% { opacity: calc(var(--kw-op) * 0.55); }
100% {
opacity: 0;
transform: translate3d(calc(-50% + var(--kw-x)), calc(-50% + var(--kw-y)), var(--kw-z1));
}
}
.loading-kw {
transform-style: preserve-3d;
backface-visibility: hidden;
animation-name: loading-kw-converge;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
</style>
如果你的项目用了 UnoCSS,上面的 utility class 可以直接用;没有的话,把 class 翻成普通 CSS 即可,动画逻辑不变。
十、调参手册(把「手感」调到你想要的形状)
| 你想要的感觉 | 拧哪颗螺丝 |
|---|---|
| 更「冲进虫洞」 | 增大 travel,或加大 perspective(例如 600 → 更夸张) |
| 更克制、更商务 | 减小 R_MAX、降低 opacity 上限、词数减半 |
| 词太挤 | 提高 baseMinDistance 下限,或减小 count |
| 中心进度看不清 | 增大 R_MIN,或提高光晕/环的对比 |
| 首屏太空 | 检查是否忘了负 delay |
| 像在震、发飘 | 确认 x/y 没被写进 keyframes;只要动 z |
| 性能发烫 | 降词数;避免额外 filter 叠太多;光晕 blur 别再套一层 |
经验值(本文参数量级):
perspective: 900px- 词数:桌面约 80~160
duration: 约 5.5s~16sz行程:约 320~800px
十一、常见坑(我替你踩过)
1. 写成了「平面飞向中心」
有人会用 2D translate 把词往 (50%, 50%) 挪。那是汇聚,不是隧道------缺少近大远小,速度曲线也对不上透视。
正确 :3D 里固定 x/y,只插值 z。
2. 忘记 preserve-3d
父级被拍扁后,子级 translateZ 可能表现怪异。隧道容器和词节点都建议保留 3D 上下文。
3. scoped 吃掉了 keyframes 名
表现:class 在,动画没有。把 @keyframes 放到全局 style。
4. Math.random() 导致无法复现
设计师说「刚才那帧好好看再来一次」,你却再也抽不出来。用 seed。
5. resize 时疯狂重建
没 debounce 的话,拖窗口等于对主线程放烟花。150ms 合并一次足够。
6. 无障碍与性能
装饰层加 aria-hidden="true";prefers-reduced-motion: reduce 时可直接停动画、只留进度环------这是对前庭敏感用户的基本礼貌。
css
@media (prefers-reduced-motion: reduce) {
.loading-kw { animation: none !important; opacity: 0 !important; }
}
十二、这套方案的边界
适合
- 品牌感 Loading / 等待页
- 不想引入 Three.js 的中轻度 3D 氛围
- 需要可调、可复现、可响应式的词云
不太适合
- 要真实遮挡、光照、碰撞的 3D 场景
- 上万粒子(DOM 节点不是干这个的)
- 强交互的可点选词云(你得重新考虑命中与层级)
DOM 方案的上限大概在「好看的舞台灯光」,不是「开放世界」。认清边界,反而更容易做漂亮。
十三、复盘:动画是怎么「生成」的?
把整条链路收成一张清单:
- 种子随机数决定词、角度、半径、深度、时长。
- 环形采样保证词长在中心之外。
- 拒绝采样 + 放宽尽量减少重叠。
- nearness 统一驱动透明度、速度、间距。
- CSS 变量把每个词的飞行档案注入同一套 keyframes。
perspective+ 只动z免费获得灭点汇聚与近大远小。- 负 delay 让时间轴在首帧就错开。
- 光晕 / 进度环 / 流光按钮做分层叙事:氛围 → 信息 → 行动。
所以它不是「设计师导出了一段视频」,而是:
数据生成轨道,CSS 负责摄影机。
下次你要做星空、弹幕隧道、口号风暴、甚至「代码雨但要有透视」,都可以先问自己一句:
有没有一个轴可以锁死,只把运动留给深度?
如果有,恭喜,你已经站在灭点上了。
在线 Demo
不想先抄代码、想直接感受手感,可以打开这个可交互示例:
同一套思路也拆成了 npm 包 @fxshelf/word-tunnel(React / Vue),文档页里有安装、Props 和自定义词条用法。适合当背景层叠进度环、标题或 CTA,而不是整页重写一遍采样逻辑。