Design System: 悬浮标签动效

一套用于大型标签、关键词卡片或入口胶囊的动态逻辑。标签首次出现时执行随机 3D 弹入,鼠标经过时根据指针位置产生平滑透视倾斜,滚动时整组标签形成短暂摆动,点击后可独立替换内容。

参考交互:Símon.com。本文只定义动画与交互机制,不绑定具体颜色、字体、圆角或页面背景。

1. 动效组成

完整标签系统包含五个动画层:

  1. 首次弹入: 标签从放大并随机旋转的姿态回到正面。
  2. 鼠标倾斜: 标签根据指针在自身区域中的位置产生 3D 旋转。
  3. 离开回正: 指针离开后,标签平滑回到中心姿态。
  4. 滚动摆动: 滚动速度转换为整组标签的旋转和纵向位移脉冲。
  5. 按压与替换: 按下时轻微缩小,点击后独立替换标签内容。

图层需要拆分:

标签组容器
→ 负责滚动旋转和纵向位移

单个标签外层
→ 负责按压缩放和透视上下文

单个标签内容
→ 负责首次弹入和鼠标 3D 倾斜

不要把三类 transform 全部写在同一个元素上,否则一个状态会覆盖另一个状态。

2. 数据模型

type FloatingLabel = {
  text: string;
  href?: string;
  image?: string;
  imageAlt?: string;
  theme?: string;
};

type FloatingLabelGroup = {
  labels: FloatingLabel[];
  phrasePool?: string[];
  themePool?: string[];
  dynamicContent?: boolean;
};

运行时状态:

type LabelMotionState = {
  targetX: number;
  targetY: number;
  currentX: number;
  currentY: number;

  rotateX: number;
  rotateY: number;

  bounds: DOMRect;
  active: boolean;
  ready: boolean;
};

标签组状态:

type GroupMotionState = {
  scrollImpulse: number;
  rotation: number;
  translateY: number;
  resetTimer: number | null;
};

3. 初始化顺序

渲染标签
→ 等待 DOM 提交
→ 播放首次弹入
→ 清除临时 transform
→ 测量标签边界
→ 将目标点设为标签中心
→ 将当前点同步到中心
→ 启动鼠标倾斜循环
→ 标记 ready

伪代码:

async function initializeLabel(label, index) {
  await nextFrame();
  await playEntrance(label, index);

  clearEntranceTransform(label);

  await nextFrame();

  const bounds = measureLabel(label);
  const center = getBoundsCenter(bounds);

  state.targetX = center.x;
  state.targetY = center.y;
  state.currentX = center.x;
  state.currentY = center.y;

  state.ready = true;
  startTiltLoop();
}

必须在首次动画完成后再测量鼠标映射边界。动画中的 scale 和 rotate 会改变 getBoundingClientRect()

4. 首次 3D 弹入

起始关键帧:

scale(1.15)
+ 随机 rotate3d(axisX, axisY, axisZ, angle)

结束关键帧:

scale(1)
+ rotate3d(0, 0, 0, 0deg)

随机旋转轴

参考生成方式:

const axisX =
  Math.random() * -10 +
  Math.random() * 20;

const axisY =
  Math.random() * -8 +
  Math.random() * 16;

const axisZ =
  Math.random() * -5 +
  Math.random() * 10;

随机值使用两个随机数相加,分布会更集中在中间区域,极端姿态出现概率较低。

旋转角

const angle =
  (
    Math.random() * -10 +
    Math.random() * 20
  ) * index + 5;
  • 第一张标签仍保持约 的基础旋转量。
  • 后续标签允许更明显的随机角度。
  • 对大量标签需要限制最大角度,避免 index 持续放大。

改进版:

const angle = clamp(
  randomTriangular(-10, 20) * index + 5,
  -24,
  24
);

动画时长

duration = 600 + 250 * index;

两张标签时:

标签 时长
第 1 张 600ms
第 2 张 850ms

标签同时开始,但后面的标签持续更久,因此形成轻微错峰,不需要额外 delay。

缓动

cubic-bezier(0, 0.29, 0.26, 1)

特点:

  • 前段快速摆脱放大姿态。
  • 后段柔和回到平面。
  • 不发生弹性回弹。

Web Animations API

async function playEntrance(
  element,
  index
) {
  const animation = element.animate(
    [
      {
        transform:
          `scale(1.15)
           rotate3d(
             ${axisX},
             ${axisY},
             ${axisZ},
             ${angle}deg
           )`
      },
      {
        transform:
          "scale(1) rotate3d(0,0,0,0deg)"
      }
    ],
    {
      duration: 600 + 250 * index,
      easing:
        "cubic-bezier(0,.29,.26,1)",
      fill: "none"
    }
  );

  await animation.finished;
}

完成后清除临时 inline transform,再启动鼠标倾斜。

5. 鼠标坐标

鼠标进入标签时:

state.active = true;

鼠标移动时保存目标位置:

function onMouseMove(event) {
  state.targetX =
    event.pageX - bounds.left;

  state.targetY =
    event.pageY;
}

更稳妥的通用实现使用视口坐标:

function onPointerMove(event) {
  state.targetX =
    event.clientX - bounds.left;

  state.targetY =
    event.clientY - bounds.top;
}

这样无需额外处理页面滚动偏移。

中心点

function resetTargetToCenter() {
  state.targetX = bounds.width / 2;
  state.targetY = bounds.height / 2;
}

鼠标离开时:

state.active = false;
resetTargetToCenter();

不要直接把旋转角设置成 0,应让插值循环自然回正。

6. 平滑跟随

标签不直接使用鼠标坐标,而是在每帧向目标点插值。

function lerp(
  current,
  target,
  amount
) {
  return (
    current * (1 - amount) +
    target * amount
  );
}

参考参数:

currentX = lerp(
  currentX,
  targetX,
  0.05
);

currentY = lerp(
  currentY,
  targetY,
  0.10
);
  • 横向系数 0.05:跟随更慢,产生较明显惯性。
  • 纵向系数 0.10:响应更快。
  • 鼠标离开后,同样使用这两个系数回到中心。

参考实现会把每次结果保留到小数点后两位,以减少无意义的微小更新:

function roundedLerp(
  current,
  target,
  amount
) {
  return Number(
    lerp(
      current,
      target,
      amount
    ).toFixed(2)
  );
}

7. 坐标映射为 3D 旋转

通用映射函数:

function mapRange(
  value,
  inputMin,
  inputMax,
  outputMin,
  outputMax
) {
  return (
    (value - inputMin) *
      (outputMax - outputMin) /
      (inputMax - inputMin) +
    outputMin
  );
}

横向坐标映射到 Y 轴旋转:

rotateY = mapRange(
  currentX,
  0,
  bounds.width,
  -4,
  4
);

纵向坐标映射到 X 轴旋转:

rotateX = mapRange(
  currentY,
  0,
  bounds.height,
  -8,
  8
);

应用:

.floating-label {
  transform:
    rotateY(var(--rotate-y))
    rotateX(calc(var(--rotate-x) * -1));
}

旋转范围:

方向 范围
Y 轴 -4°~4°
X 轴 -8°~8°

纵向倾斜大于横向倾斜,能让宽而扁的标签产生更清晰的空间反馈。

8. 透视

透视设置在标签外层:

.floating-label-wrap {
  perspective: 30vh;
  perspective-origin: 50% 50%;
}

1000px 高的视口中:

30vh = 300px

透视值越小,3D 变形越强。

建议限制:

perspective:
  clamp(280px, 30vh, 520px);

不要把 perspective 写在发生 rotateX/rotateY 的同一个元素上。

9. 倾斜动画循环

function updateTilt() {
  state.currentX = roundedLerp(
    state.currentX,
    state.targetX,
    0.05
  );

  state.currentY = roundedLerp(
    state.currentY,
    state.targetY,
    0.10
  );

  state.rotateY = mapRange(
    state.currentX,
    0,
    state.bounds.width,
    -4,
    4
  );

  state.rotateX = mapRange(
    state.currentY,
    0,
    state.bounds.height,
    -8,
    8
  );

  applyTilt(state);

  animationFrameId =
    requestAnimationFrame(updateTilt);
}

使用 CSS 变量:

element.style.setProperty(
  "--rotate-y",
  `${state.rotateY}deg`
);

element.style.setProperty(
  "--rotate-x",
  `${state.rotateX}deg`
);

只更新 CSS 变量和 transform,不修改元素尺寸或布局位置。

10. Hover 状态

鼠标进入:

function onPointerEnter() {
  state.active = true;
  element.classList.add("is-active");
}

鼠标离开:

function onPointerLeave() {
  state.active = false;
  element.classList.remove("is-active");
  resetTargetToCenter();
}

Hover 可以同时触发:

  • 轻微阴影淡入。
  • 可选图片内容出现。
  • 图片从轻微放大回到正常大小。

参考时序:

属性 时长
阴影变化 500ms
图片 opacity / visibility 200ms
图片 scale 600ms

可选图片状态:

默认:opacity 0 + scale 1.075
激活:opacity 1 + scale 1

图片不是实现悬浮倾斜的必要条件。

11. 按压反馈

按下标签外层:

.floating-label-wrap:active {
  transform: scale(0.95);
  transition-duration: 100ms;
}

松开后:

.floating-label-wrap {
  transition: transform 300ms;
}
  • 按压 scale 作用在外层。
  • 3D tilt 作用在内层。
  • 两种 transform 不互相覆盖。
  • 标签是链接时,按压不能延迟正常导航。

12. 点击内容替换

动态标签组可以从双词短语池初始化:

phrasePool = [
  "Visuals Written",
  "Code Designed",
  "Language Visual"
];

初始化:

const words =
  randomItem(phrasePool).split(" ");

两张标签分别显示:

label[0] = words[0]
label[1] = words[1]

点击某一张标签时,只替换相同索引的单词:

function replaceWord(index) {
  let nextWord;

  do {
    nextWord =
      randomItem(phrasePool)
        .split(" ")[index];
  } while (
    nextWord === labels[index].text
  );

  labels[index].text = nextWord;
}

可同时更新该标签的主题状态:

labels[index].theme =
  randomNonRepeatingItem(themePool);

内容池要求

  • 所有短语应具有相同单词数量。
  • 每个索引都必须存在有效单词。
  • 点击只更新当前标签,不重置另一张标签。
  • 避免立即重复当前单词。
  • 内容替换不重新播放首次 3D 弹入。
  • 按压反馈结束后直接显示新内容。

13. 滚动速度转为摆动脉冲

标签组监听滚动系统提供的:

type ScrollEvent = {
  position: number;
  delta: number;
};

将滚动增量转换为脉冲:

scrollImpulse =
  -clamp(
    scrollDelta / 4,
    -10,
    10
  );
  • 最大目标幅度约 ±10
  • 使用负号让标签组对滚动方向产生反向惯性。
  • 每次滚动后约 50ms 将目标脉冲重置为 0
clearTimeout(resetTimer);

resetTimer = setTimeout(() => {
  scrollImpulse = 0;
}, 50);

脉冲不是持续速度。滚动停止后,标签组会形成短暂余波并回正。

14. 标签组摆动插值

使用两个不同速度的输出:

groupRotation = roundedLerp(
  groupRotation,
  scrollImpulse,
  0.05
);

groupTranslateY = roundedLerp(
  groupTranslateY,
  scrollImpulse,
  0.15
);
  • rotation 系数 0.05:较慢,形成长一些的摆动余韵。
  • translateY 系数 0.15:更快,形成短促位移。

应用:

.floating-label-group {
  transform:
    translate3d(
      0,
      var(--group-translate-y),
      0
    )
    rotate(var(--group-rotation));
}

参考实测中,单次较大滚动后:

  • 30ms 已出现明显位移。
  • 120ms 位移快速衰减。
  • 340ms 接近回正。
  • 旋转余波比位移持续更久。

15. 滚动层级切换

标签区域位于页面顶部时可以拥有较高层级。

滚动超过小阈值后:

scrolled =
  scrollPosition > 10;

通过状态类降低标签组层级:

group.classList.toggle(
  "is-scrolled",
  scrolled
);

作用:

  • 首屏时标签可以覆盖或强调其他内容。
  • 开始滚动后,标签退到项目列表或正文之后。

这属于图层交互逻辑,具体 z-index 数值由项目决定。

16. Resize 与边界测量

每次以下事件后重新测量:

  • 首次弹入完成。
  • 窗口尺寸改变。
  • 标签文字改变且尺寸可能改变。
  • 字体加载完成。
  • 容器布局改变。
function measure() {
  state.bounds =
    wrapper.getBoundingClientRect();
}

推荐使用 ResizeObserver

const resizeObserver =
  new ResizeObserver(() => {
    measure();

    if (!state.active) {
      resetTargetToCenter();
    }
  });

避免在每个 mousemove 中调用 getBoundingClientRect()

移动浏览器地址栏变化可能频繁触发 resize。只在宽度或真实容器尺寸变化时重新测量。

17. 移动端与触摸

粗指针设备没有 hover:

  • 不运行持续的鼠标倾斜循环。
  • 保留首次 3D 弹入。
  • 保留滚动摆动。
  • 保留按压 scale。
  • 点击仍可替换单词或执行链接。

判断:

const canHover = matchMedia(
  "(hover: hover) and (pointer: fine)"
).matches;

如果需要触摸倾斜:

  • 仅在按下期间读取触点。
  • 松手立即以插值回正。
  • 不阻止页面纵向滚动。

18. 减少动态效果

参考页面的原始组件没有完整关闭所有动画;可复用规范必须补充:

const reduceMotion = matchMedia(
  "(prefers-reduced-motion: reduce)"
);

减少动态效果时:

  • 跳过首次随机 3D 弹入。
  • 不启动鼠标倾斜循环。
  • 不执行滚动摆动。
  • 按压反馈可缩短为 1ms 或保留极轻反馈。
  • 点击内容仍可替换,但直接切换。
  • 图片直接显示或使用短透明度过渡。
@media (
  prefers-reduced-motion: reduce
) {
  .floating-label,
  .floating-label-wrap,
  .floating-label-media {
    animation: none !important;
    transition-duration: 1ms !important;
  }
}

19. 生命周期与性能

启动

  • 标签首次弹入结束后才启动 tilt rAF。
  • 标签组只启动一个 scroll rAF。
  • 多标签推荐共享一个 tilt rAF,而不是每张标签一个循环。

暂停

以下情况暂停:

  • 页面隐藏。
  • 标签区域完全离开视口。
  • 减少动态效果开启。

销毁

function destroy() {
  cancelAnimationFrame(
    tiltAnimationFrameId
  );

  cancelAnimationFrame(
    groupAnimationFrameId
  );

  clearTimeout(
    scrollResetTimer
  );

  resizeObserver.disconnect();

  removeEventListeners();
}

每次递归调用 requestAnimationFrame 时都要保存最新 ID。只保存第一次 rAF 的 ID 无法可靠停止后续循环。

20. 推荐状态机

单个标签:

type LabelState =
  | "entering"
  | "idle"
  | "hovered"
  | "pressed"
  | "disabled";
entering
→ 首次动画完成
→ idle

idle
→ pointerenter
→ hovered

hovered
→ pointerleave
→ idle + 回正

hovered
→ pointerdown
→ pressed

pressed
→ pointerup
→ hovered + 可选内容替换

标签组:

type GroupState =
  | "at-top"
  | "scrolled"
  | "paused";

21. 完整伪代码

function createFloatingLabel(
  wrapper,
  options
) {
  const label =
    wrapper.querySelector(
      ".floating-label"
    );

  const state = {
    targetX: 0,
    targetY: 0,
    currentX: 0,
    currentY: 0,
    bounds: null,
    active: false,
    ready: false
  };

  let frameId = 0;

  async function initialize() {
    if (!reduceMotion.matches) {
      await playEntrance(
        label,
        options.index
      );
    }

    measure();
    resetTargetToCenter();

    state.currentX =
      state.targetX;

    state.currentY =
      state.targetY;

    state.ready = true;

    if (
      canHover &&
      !reduceMotion.matches
    ) {
      frameId =
        requestAnimationFrame(update);
    }
  }

  function measure() {
    state.bounds =
      wrapper.getBoundingClientRect();
  }

  function resetTargetToCenter() {
    state.targetX =
      state.bounds.width / 2;

    state.targetY =
      state.bounds.height / 2;
  }

  function onPointerMove(event) {
    state.targetX =
      event.clientX -
      state.bounds.left;

    state.targetY =
      event.clientY -
      state.bounds.top;
  }

  function update() {
    state.currentX = roundedLerp(
      state.currentX,
      state.targetX,
      0.05
    );

    state.currentY = roundedLerp(
      state.currentY,
      state.targetY,
      0.10
    );

    const rotateY = mapRange(
      state.currentX,
      0,
      state.bounds.width,
      -4,
      4
    );

    const rotateX = mapRange(
      state.currentY,
      0,
      state.bounds.height,
      -8,
      8
    );

    label.style.setProperty(
      "--rotate-y",
      `${rotateY}deg`
    );

    label.style.setProperty(
      "--rotate-x",
      `${rotateX}deg`
    );

    frameId =
      requestAnimationFrame(update);
  }

  wrapper.addEventListener(
    "pointerenter",
    () => {
      state.active = true;
      wrapper.classList.add(
        "is-active"
      );
    }
  );

  wrapper.addEventListener(
    "pointermove",
    onPointerMove
  );

  wrapper.addEventListener(
    "pointerleave",
    () => {
      state.active = false;
      wrapper.classList.remove(
        "is-active"
      );
      resetTargetToCenter();
    }
  );

  initialize();

  return {
    destroy() {
      cancelAnimationFrame(frameId);
    }
  };
}

22. 禁用逻辑

  • 不直接把鼠标坐标映射到旋转而跳过插值。
  • 不在 pointerleave 时瞬间归零。
  • 不把组滚动 transform 和单标签 tilt 写在同一元素。
  • 不使用超过约 的常态倾斜。
  • 不让首次随机旋转产生多圈翻转。
  • 不使用弹性回弹让标签边缘剧烈晃动。
  • 不在 mousemove 中反复测量布局。
  • 不在触摸设备上持续运行无用的 hover rAF。
  • 不在页面隐藏或元素离屏后继续更新。
  • 不点击一张标签时重置整组标签。
  • 不允许内容池不同短语的单词数量不一致。
  • 不忽略减少动态效果设置。

23. 验收标准

  • 首次加载时标签从 scale(1.15) 和轻微随机 3D 姿态回到平面。
  • 后续标签的进入时间略长,但所有标签同时开始。
  • 鼠标位于标签中心时旋转接近
  • 鼠标移动到左右边缘时 Y 轴倾斜接近 ±4°
  • 鼠标移动到上下边缘时 X 轴倾斜接近 ±8°
  • 横向跟随比纵向跟随更慢。
  • 鼠标离开约半秒后标签基本回正。
  • 按下时标签外层缩小到约 0.95
  • 点击一张标签只替换该标签对应位置的单词。
  • 较大滚动会产生短促位移和较慢旋转余波。
  • 滚动停止后标签组在短时间内回到中性姿态。
  • Resize 后鼠标映射中心与边缘仍然准确。
  • 触摸设备不运行无意义的 hover 循环。
  • 减少动态效果模式下不执行 3D 弹入、倾斜和滚动摆动。