Nuxt 4 中如何使用 GSAP 实现按钮悬停和流动背景动画?
可以把按钮封装成独立的 Vue 组件。组件在客户端挂载后创建一条可重复播放、反向播放的 GSAP timeline,用按钮内部的背景层实现扩散动画。每个按钮各自管理 DOM 引用和动画实例。
下面采用圆形背景从鼠标进入位置展开的方式,模拟流动填充效果。仅凭问题描述,无法确认 Museum of Money 的实际实现,也不能保证与原站效果完全一致。
现有代码需要调整什么?
当前代码主要改变整个按钮的 backgroundColor、borderRadius 和 y。要做出背景流入的效果,需要增加一个独立的背景元素,让它在按钮内部移动或缩放,再用 overflow: hidden 裁剪。
动画管理还有两个问题:
- 每次进入、离开都会创建新的 timeline。快速移入移出时,多个 timeline 可能同时修改同一个属性,导致跳动或回弹异常。
- 通过
addEventListener注册的监听器没有在组件卸载时移除,动画也没有对应的清理逻辑。
对于这种固定的进入和退出效果,可以创建一条暂停的 timeline,进入时播放,离开时反向播放。
实现可复用的按钮组件
先安装 GSAP:
npm install gsap
创建 components/FluidButton.vue。组件会根据鼠标进入位置展开背景,也支持键盘焦点和用户的减少动态效果偏好。
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
const button = ref<HTMLButtonElement | null>(null);
const background = ref<HTMLSpanElement | null>(null);
const label = ref<HTMLSpanElement | null>(null);
let disposed = false;
let cleanup: (() => void) | undefined;
onBeforeUnmount(() => {
disposed = true;
cleanup?.();
});
onMounted(async () => {
const { gsap } = await import('gsap');
// 动态导入完成前,组件可能已经卸载。
if (disposed) return;
const el = button.value;
const bg = background.value;
const text = label.value;
if (!el || !bg || !text) return;
const media = gsap.matchMedia();
cleanup = () => media.revert();
media.add('(prefers-reduced-motion: no-preference)', () => {
let pointerInside = false;
let focused = false;
let active = false;
gsap.set(bg, {
xPercent: -50,
yPercent: -50,
scale: 0,
});
const timeline = gsap.timeline({
paused: true,
defaults: {
duration: 0.4,
ease: 'power2.out',
},
});
timeline
.to(bg, { scale: 1 }, 0)
.to(text, { color: '#153620' }, 0);
function reveal(event?: PointerEvent) {
if (active) return;
active = true;
const rect = el.getBoundingClientRect();
const x = event ? event.clientX - rect.left : rect.width / 2;
const y = event ? event.clientY - rect.top : rect.height / 2;
// 直径足够覆盖从按钮任意位置展开的情况。
const diameter = 2 * Math.hypot(rect.width, rect.height);
timeline.pause(0);
gsap.set(bg, {
left: x,
top: y,
width: diameter,
height: diameter,
});
timeline.play(0);
}
function hideIfInactive() {
if (pointerInside || focused) return;
active = false;
timeline.reverse();
}
function onEnter(event: PointerEvent) {
if (event.pointerType === 'touch') return;
pointerInside = true;
reveal(event);
}
function onLeave() {
pointerInside = false;
hideIfInactive();
}
function onFocus() {
focused = true;
reveal();
}
function onBlur() {
focused = false;
hideIfInactive();
}
el.addEventListener('pointerenter', onEnter);
el.addEventListener('pointerleave', onLeave);
el.addEventListener('focus', onFocus);
el.addEventListener('blur', onBlur);
// 初始化时也处理按钮已经获得焦点或处于悬停的情况。
pointerInside = el.matches(':hover');
focused = document.activeElement === el;
if (pointerInside || focused) reveal();
return () => {
el.removeEventListener('pointerenter', onEnter);
el.removeEventListener('pointerleave', onLeave);
el.removeEventListener('focus', onFocus);
el.removeEventListener('blur', onBlur);
};
});
});
</script>
<template>
<button ref="button" type="button" class="fluid-button">
<span
ref="background"
class="fluid-button__background"
aria-hidden="true"
/>
<span ref="label" class="fluid-button__label">
<slot>Click Me</slot>
</span>
</button>
</template>
<style scoped>
.fluid-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 50px;
padding: 0 1.5rem;
overflow: hidden;
border: 2px solid #333;
border-radius: 30px;
background: #176b35;
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.fluid-button__background {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: #fff;
pointer-events: none;
transform-origin: center;
}
.fluid-button__label {
position: relative;
z-index: 1;
pointer-events: none;
}
.fluid-button:focus-visible {
outline: 3px solid #176b35;
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.fluid-button:is(:hover, :focus-visible) {
background: #fff;
}
.fluid-button:is(:hover, :focus-visible) .fluid-button__label {
color: #153620;
}
}
</style>
getBoundingClientRect() 和 clientX、clientY 都使用视口坐标,相减后就能得到鼠标在按钮内的位置。背景圆以这个位置为中心展开,文字层始终位于背景上方。
鼠标离开时,背景沿原路径收回。如果退出动画尚未完成就再次进入,示例会从新的进入位置重新展开,可能出现一次重置。如果更重视快速往返时的连续性,可以只在动画完全收回后更新圆心,其余情况直接调用 timeline.play()。
多个按钮如何结合 Vue 响应式管理?
在页面中直接复用组件:
<script setup lang="ts">
const actions = [
{ id: 'details', label: '查看详情' },
{ id: 'contact', label: '联系我们' },
];
</script>
<template>
<div class="actions">
<FluidButton v-for="action in actions" :key="action.id">
{{ action.label }}
</FluidButton>
</div>
</template>
每个组件都持有自己的 template ref 和 timeline,可以避免全局选择器同时命中多个按钮。列表增删时,组件会在挂载和卸载时完成初始化与清理;稳定的 key 让 Vue 正确识别各个按钮。
Vue 负责按钮文案、列表和禁用状态等业务数据,GSAP 负责背景层的动画属性。尽量避免用 Vue 的 :style 与 GSAP 同时修改同一元素的同一属性,也不要让 CSS transition 同时控制 GSAP 正在处理的属性。
示例每次展开前都会重新读取按钮尺寸,普通文案变更通常不需要额外的 watcher。如果需要在动画进行中改变按钮尺寸后立即保持背景覆盖,可以考虑使用 ResizeObserver 更新背景尺寸,并在卸载时断开观察。
Nuxt 初始化与动画清理要注意什么?
示例在 onMounted 中动态导入 GSAP,确认 DOM 存在后再创建动画。DOM 测量和动画初始化因此发生在客户端,无需为了这个按钮把整个页面放进 <ClientOnly>。
gsap.matchMedia() 会管理其回调中创建的 GSAP 动画。媒体条件失效或调用 media.revert() 时,相关动画会被还原;回调返回的清理函数负责移除原生事件监听器。
组件卸载钩子在同步的 <script setup> 阶段注册,disposed 用来处理动态导入尚未完成就离开页面的情况。键盘用户聚焦按钮时可以触发背景效果;启用减少动态效果偏好的用户会得到直接切换颜色的反馈。
如果需要更明显的液体形变
圆形扩散可以实现平滑填充,但不会自动产生波浪边缘或液体拉伸。如果目标效果包含这些细节,需要先确定变化的是轮廓、位置还是表面纹理,再选择 SVG 路径动画、遮罩或其他绘制方式。
可以先调好背景展开位置、持续时间和 easing,确认交互节奏后再增加轮廓变化。单纯增加按钮的纵向弹跳,无法得到背景流入的效果。
备注:内容仅供参考。
