PAYATHON 2026

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,确认交互节奏后再增加轮廓变化。单纯增加按钮的纵向弹跳,无法得到背景流入的效果。

备注:内容仅供参考。