Design System: 悬浮标签动效
一套用于大型标签、关键词卡片或入口胶囊的动态逻辑。标签首次出现时执行随机 3D 弹入,鼠标经过时根据指针位置产生平滑透视倾斜,滚动时整组标签形成短暂摆动,点击后可独立替换内容。
参考交互:Símon.com。本文只定义动画与交互机制,不绑定具体颜色、字体、圆角或页面背景。
1. 动效组成
完整标签系统包含五个动画层:
- 首次弹入: 标签从放大并随机旋转的姿态回到正面。
- 鼠标倾斜: 标签根据指针在自身区域中的位置产生 3D 旋转。
- 离开回正: 指针离开后,标签平滑回到中心姿态。
- 滚动摆动: 滚动速度转换为整组标签的旋转和纵向位移脉冲。
- 按压与替换: 按下时轻微缩小,点击后独立替换标签内容。
图层需要拆分:
标签组容器
→ 负责滚动旋转和纵向位移
单个标签外层
→ 负责按压缩放和透视上下文
单个标签内容
→ 负责首次弹入和鼠标 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;
- 第一张标签仍保持约
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 写在同一元素。
- 不使用超过约
8°的常态倾斜。 - 不让首次随机旋转产生多圈翻转。
- 不使用弹性回弹让标签边缘剧烈晃动。
- 不在 mousemove 中反复测量布局。
- 不在触摸设备上持续运行无用的 hover rAF。
- 不在页面隐藏或元素离屏后继续更新。
- 不点击一张标签时重置整组标签。
- 不允许内容池不同短语的单词数量不一致。
- 不忽略减少动态效果设置。
23. 验收标准
- 首次加载时标签从
scale(1.15)和轻微随机 3D 姿态回到平面。 - 后续标签的进入时间略长,但所有标签同时开始。
- 鼠标位于标签中心时旋转接近
0°。 - 鼠标移动到左右边缘时 Y 轴倾斜接近
±4°。 - 鼠标移动到上下边缘时 X 轴倾斜接近
±8°。 - 横向跟随比纵向跟随更慢。
- 鼠标离开约半秒后标签基本回正。
- 按下时标签外层缩小到约
0.95。 - 点击一张标签只替换该标签对应位置的单词。
- 较大滚动会产生短促位移和较慢旋转余波。
- 滚动停止后标签组在短时间内回到中性姿态。
- Resize 后鼠标映射中心与边缘仍然准确。
- 触摸设备不运行无意义的 hover 循环。
- 减少动态效果模式下不执行 3D 弹入、倾斜和滚动摆动。