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>