ToolKitToolKit工具箱
Loading animation…
查看源码 / Source点击展开
<!--
  GsapDemo.vue — 展示 GSAP 的核心能力:
    - timeline 时间轴编排(label / position offset)
    - SplitText 风格的逐字符 stagger 进场
    - 缓动函数库(back.out / elastic / power)

  迁移说明(Vite→Nuxt): 无顶层 window/document 访问, DOM 操作均在 play()/onMounted 中,
  父级用 <ClientOnly> 包裹即可。
-->
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import gsap from 'gsap'

const { t: tr } = useI18n({ useScope: 'local' })

const stageRef = useTemplateRef<HTMLDivElement>('stage')
const titleRef = useTemplateRef<HTMLHeadingElement>('title')
const cardsRef = useTemplateRef<HTMLDivElement>('cards')
const subtitleRef = useTemplateRef<HTMLParagraphElement>('subtitle')

const TITLE_TEXT = 'GSAP TIMELINE'
const titleChars = ref<string[]>(TITLE_TEXT.split(''))

let tl: gsap.core.Timeline | null = null

function play() {
  // 杀掉上一个,避免叠加
  tl?.kill()
  const charEls = titleRef.value?.querySelectorAll('.char') ?? []
  const cardEls = cardsRef.value?.querySelectorAll('.card') ?? []

  // 重置初态(否则 timeline `.from` 在第二次播放时起点就错了)
  gsap.set(charEls, { yPercent: 100, opacity: 0, rotateX: -90 })
  gsap.set(subtitleRef.value, { opacity: 0, y: 20 })
  gsap.set(cardEls, { y: 60, opacity: 0, scale: 0.6 })

  tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
    .to(charEls, {
      yPercent: 0,
      opacity: 1,
      rotateX: 0,
      duration: 0.8,
      stagger: 0.04,
    })
    .to(subtitleRef.value, {
      opacity: 1,
      y: 0,
      duration: 0.5,
    }, '-=0.4')
    .addLabel('cards')
    .to(cardEls, {
      y: 0,
      opacity: 1,
      scale: 1,
      duration: 0.6,
      ease: 'back.out(1.8)',
      stagger: { each: 0.08, from: 'center' },
    }, 'cards')
}

onMounted(() => play())
onBeforeUnmount(() => tl?.kill())
</script>

<template>
  <div ref="stage" class="stage">
    <button class="replay" @click="play">↻ {{ tr('replay') }}</button>
    <div class="content">
      <h1 ref="title" class="title">
        <span v-for="(c, i) in titleChars" :key="i" class="char-wrap">
          <span class="char">{{ c === ' ' ? ' ' : c }}</span>
        </span>
      </h1>
      <p ref="subtitle" class="subtitle">{{ tr('subtitle') }}</p>
      <div ref="cards" class="cards">
        <div v-for="i in 5" :key="i" class="card">{{ i }}</div>
      </div>
    </div>
  </div>
</template>

<i18n lang="json">
{
  "zh": { "replay": "重播", "subtitle": "timeline · stagger · back.out 缓动" },
  "en": { "replay": "Replay", "subtitle": "timeline · stagger · back.out easing" }
}
</i18n>

<style scoped>
.stage {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.replay {
  position: absolute;
  top: 16px;
  right: 16px;
  background: rgba(255,255,255,0.06);
  border: 1px solid var(--border);
  color: var(--text);
  padding: 6px 14px;
  border-radius: 999px;
  font-size: 12px;
  font-family: 'Consolas', monospace;
  letter-spacing: 1px;
  cursor: pointer;
  transition: all 0.2s;
  z-index: 5;
}
.replay:hover { background: rgba(0,229,199,0.15); border-color: var(--accent); }
.content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
}
.title {
  font-size: 56px;
  font-weight: 800;
  letter-spacing: 2px;
  color: var(--text);
  margin: 0;
  perspective: 600px;
}
.char-wrap {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
}
.char {
  display: inline-block;
  transform-origin: 50% 50% -20px;
}
.subtitle {
  font-family: 'Consolas', monospace;
  font-size: 12px;
  letter-spacing: 4px;
  color: var(--text-mute);
  margin: 0;
}
.cards {
  display: flex;
  gap: 14px;
}
.card {
  width: 64px;
  height: 80px;
  border-radius: 10px;
  background: linear-gradient(135deg, #00E5C7 0%, #4FC3F7 100%);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 22px;
  color: #04060c;
  box-shadow: 0 8px 24px rgba(0, 229, 199, 0.25);
}
@media (max-width: 700px) {
  .title { font-size: 36px; }
}
</style>