我用 CSS 3D 做了个星空隧道 Loading,没有 Three.js

0 阅读17分钟

用 CSS 3D 做一个「星空隧道」词云 Loading(Vue 实战拆解)


写在前面

做 Loading 最容易翻车的两种姿势:

  1. 纯 CSS spinner:转啊转,用户看了三秒开始怀疑人生。
  2. 上 Canvas / Three.js:效果是真炫,但为了一页 Loading 拉一整座 3D 引擎,像是为了煮泡面开了核电站。

有没有第三条路?有——浏览器原生的 CSS 3D Transform + 一点可控随机

核心就一句话:

每个词的平面坐标 (x, y) 一辈子不变,只让它在深度轴 z 上飞;再给父级加 perspective,透视会自动把远处的点收向屏幕中心的灭点。

经典屏保「星空隧道 / Warp Speed」本质上就是这个把戏。我们只不过把星星换成了会说话(还会抬杠)的关键词。

本文会从零拆到能抄:

  1. 透视舞台怎么搭
  2. 单字飞行的 keyframes 怎么写
  3. 词云坐标怎么「看起来随机又不像乱码糊脸」
  4. animation-delay 为什么能让首屏立刻热闹
  5. 光晕、进度环、流光按钮怎么叠层
  6. 一份可直接跑的 Vue SFC 精简版

一、先建立直觉:你到底在看什么?

1.1 灭点是免费的

给容器写上:

.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(更负),也就是飞向远方。透视一开,屏幕观感就是:词从环带外侧逐渐缩小并汇向灭点——隧道感就来了。

xy 固定,只飞 z

可以把它想成:每个词都钉在一条平行于光轴的「轨道」上,自己不会左右变道;变的是你和它的距离。


二、页面分层:先把舞台搭好

实际页面是四层三明治(从底到顶):

分层结构

对应到 Vue 模板,大致是:

<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>

几个容易被忽略的细节:

  1. overflow-hidden:词飞出视野时别把页面撑出滚动条。
  2. pointer-events-none:词云和光晕是装饰,别挡住按钮点击。
  3. transform-style: preserve-3d:子元素的 translateZ 才真正参与 3D,而不是被拍扁成 2D。
  4. will-change: transform:提示浏览器把这些字丢进合成层(别滥用到全站,Loading 页用一下还好)。

三、单字动画:CSS 变量才是灵魂

3.1 每个词一份「飞行档案」

每个关键词不是同一段动画的复制粘贴,而是同一套 keyframes + 不同的自定义属性

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:

<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

@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 搞笑词库(这里根据你需要自行填写需要展示的文案)

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,同一套参数就能复现同一片星空:

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 环形(甜甜圈)采样

中心要留给进度环,所以词只能长在环带上:

环形采样

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 };
}

三个关键小技巧:

  1. sqrt(rnd):极坐标半径如果直接线性随机,密度会向内圈堆;开方后更接近「环带面积均匀」。
  2. * 1.18 / * 0.9:故意做成扁椭圆,更贴合宽屏构图。
  3. 失败兜底:理论上极难触发,但随机世界里总要给自己留条退路。

这里的 px/py 还不是最终 CSS 值,后面会乘上 vw/vh,让布局跟视口走。

4.4 拒绝采样:能不重叠就不重叠

光随机还不够——字会打架。做法是经典的 Poisson-ish / rejection sampling

拒绝采样流程

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;
}

放置循环的策略是「先严后松」:

// 伪代码
for each keyword:
  先随机出 z0,算出 nearness
  for relax in 0..5:
    minFactor = 1 - relax * 0.12   // 逐步放宽
    尝试若干次 samplePoint
    若与所有已放置点都不 tooClose → 接受
  若仍失败 → 硬放一个点(靠 z 景深错开观感)

这比「纯随机」稳,又比真正的泊松盘采样好写。Loading 页的预算是毫秒级,别上太重的空间索引。

4.5 nearness:把「远近」写成可调参数

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:让首帧就「已经在飞」

const delay = -rnd() * duration;

如果所有词都 delay >= 0,打开页面会有一小段「集体淡入」的空洞期。
给一个落在 (-duration, 0] 的负延迟,等于告诉浏览器:

「这段循环动画你已经播过一会儿了,请从中间某一帧开始。」

于是首屏立刻是热闹的星空,而不是集体彩排。

4.7 完整 buildKeywords

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/yvw/vh,换窗口大小时轨道会跟着拉伸;zpx,深度尺度更稳定,不跟字体/布局耦合太死。


五、响应式:词数别写死

1440 宽塞 80 个刚好;4K 还是 80 个会显得寂寞;手机塞 160 个又像下雪。

一个简单分段:

/** 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 重建:

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);
});
:key="${keywords.length}-${i}`" // 可以在词数变化时强制重挂动画,避免旧 delay 状态残留。

六、中心光晕:一张图 + 大模糊

隧道中间如果太「空」,进度环会像贴在黑板上的便利贴。解法很土很有效:

<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 径向渐变,少一次图片请求;用图则更容易拿到设计稿同款气质。


七、进度环与「假装在加载」

真实项目里进度通常来自任务回调;演示页可以自己模拟:

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 阴影,让中心像被光罩盖住:

<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 循环移动即可:

@keyframes loading-bg-flow {
  from { background-position: 200% 0; }
  to   { background-position: 0% 0; }
}
<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-position200% 走到 0%,视觉上渐变是从右往左「流」;想反过来就交换 from/to。

Hover 时用伪元素再糊一层同款渐变,像给按钮套了外发光,成本极低。


九、把它们组装成一个可跑的 Vue 精简版

下面是「教学用」单文件版本:去掉路由、i18n、UI 库,只留动画骨架。你可以把 keywords.ts 逻辑内联,也可以继续拆文件。

<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~16s
  • z 行程:约 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 时可直接停动画、只留进度环——这是对前庭敏感用户的基本礼貌。

@media (prefers-reduced-motion: reduce) {
  .loading-kw { animation: none !important; opacity: 0 !important; }
}

十二、这套方案的边界

适合

  • 品牌感 Loading / 等待页
  • 不想引入 Three.js 的中轻度 3D 氛围
  • 需要可调、可复现、可响应式的词云

不太适合

  • 要真实遮挡、光照、碰撞的 3D 场景
  • 上万粒子(DOM 节点不是干这个的)
  • 强交互的可点选词云(你得重新考虑命中与层级)

DOM 方案的上限大概在「好看的舞台灯光」,不是「开放世界」。认清边界,反而更容易做漂亮。


十三、复盘:动画是怎么「生成」的?

把整条链路收成一张清单:

  1. 种子随机数决定词、角度、半径、深度、时长。
  2. 环形采样保证词长在中心之外。
  3. 拒绝采样 + 放宽尽量减少重叠。
  4. nearness 统一驱动透明度、速度、间距。
  5. CSS 变量把每个词的飞行档案注入同一套 keyframes。
  6. perspective + 只动 z 免费获得灭点汇聚与近大远小。
  7. 负 delay 让时间轴在首帧就错开。
  8. 光晕 / 进度环 / 流光按钮做分层叙事:氛围 → 信息 → 行动。

所以它不是「设计师导出了一段视频」,而是:

数据生成轨道,CSS 负责摄影机。

下次你要做星空、弹幕隧道、口号风暴、甚至「代码雨但要有透视」,都可以先问自己一句:

有没有一个轴可以锁死,只把运动留给深度?

如果有,恭喜,你已经站在灭点上了。


在线 Demo

不想先抄代码、想直接感受手感,可以打开这个可交互示例:

Word Tunnel · fxshelf

同一套思路也拆成了 npm 包 @fxshelf/word-tunnel(React / Vue),文档页里有安装、Props 和自定义词条用法。适合当背景层叠进度环、标题或 CTA,而不是整页重写一遍采样逻辑。