feat: 添加页面加载动画并优化首屏入场效果

- 在页面加载时添加加载进度条动画
- 重构首屏入场动画逻辑,分离加载层和品牌展示层
- 优化滚动控制逻辑,避免 class 和 inline style 冲突
- 添加动画状态管理变量,防止重复触发
This commit is contained in:
2026-04-26 21:55:48 +08:00
parent b374ca198b
commit c1ab766c49
2 changed files with 211 additions and 48 deletions
+3 -1
View File
@@ -10,6 +10,8 @@ import ArcticNewOneHeader from '~/components/layouts/ArcticNewOneHeader.vue';
<ArcticNewOneHeader />
<slot />
<ArcticNewOneFooter />
<ArcticNewOneCursor />
<ClientOnly>
<ArcticNewOneCursor />
</ClientOnly>
</div>
</template>
+208 -47
View File
@@ -50,7 +50,11 @@ const heroGalleryColumns = [
},
] as const
const pageRef = ref<HTMLElement | null>(null)
const loaderRef = ref<HTMLElement | null>(null)
const loaderBarRef = ref<HTMLElement | null>(null)
const introRef = ref<HTMLElement | null>(null)
const introTitleRef = ref<HTMLElement | null>(null)
const introCharRefs = ref<HTMLElement[]>([])
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
@@ -58,9 +62,42 @@ let heroAnimationContext: gsap.Context | null = null
let removeLoadListener: (() => void) | null = null
let restorePageScroll: (() => void) | null = null
let cleanupHeroScroll: (() => void) | null = null
let hasIntroStarted = false
let loaderTween: gsap.core.Tween | null = null
let introTimeline: gsap.core.Timeline | null = null
let introStartTimer: ReturnType<typeof window.setTimeout> | null = null
const setHomeIntroActive = (active: boolean) => {
isIntroActive.value = active
document.documentElement.classList.toggle('is-home-intro-active', active)
}
onMounted(() => {
if (!pageRef.value || !introRef.value || !heroStageRef.value || !heroRef.value || !heroPanelRef.value) {
const pageElement = pageRef.value
const loaderElement = loaderRef.value
const loaderBarElement = loaderBarRef.value
const introElement = introRef.value
const introTitleElement = introTitleRef.value
const heroStageElement = heroStageRef.value
const heroElement = heroRef.value
const heroPanelElement = heroPanelRef.value
if (
!pageElement
|| !loaderElement
|| !loaderBarElement
|| !introElement
|| !introTitleElement
|| !heroStageElement
|| !heroElement
|| !heroPanelElement
) {
return
}
const introCharElements = gsap.utils.toArray('.hero-intro-char', introElement)
if (introCharElements.length === 0) {
return
}
@@ -75,29 +112,29 @@ onMounted(() => {
const setupHeroScroll = () => {
cleanupHeroScroll?.()
gsap.set(heroPanelRef.value, {
gsap.set(heroPanelElement, {
scale: 1,
force3D: true,
transformOrigin: 'center center',
})
let renderedScale = -1
const setHeroPanelScale = (scale: number) => {
if (!heroPanelRef.value) {
if (Math.abs(renderedScale - scale) < 0.001) {
return
}
heroPanelRef.value.style.transform = `scale(${scale})`
renderedScale = scale
gsap.set(heroPanelElement, { scale })
}
const updateHeroPanelScale = () => {
if (!heroStageRef.value) {
return
}
const exitDistance = getExitDistance()
const totalDistance = heroPinDistance + exitDistance
const currentScroll = Math.max(window.scrollY, $lenis?.scroll ?? 0)
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - heroStageRef.value.offsetTop)
// Lenis 存在时只读 Lenis 的平滑滚动值,避免向上滚时 window.scrollY 和 Lenis 值互相打架。
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - heroStageElement.offsetTop)
if (currentDistance <= heroPinDistance) {
setHeroPanelScale(gsap.utils.interpolate(1, pinEndScale, currentDistance / heroPinDistance))
@@ -123,38 +160,85 @@ onMounted(() => {
}
const playIntro = () => {
if (hasIntroStarted) {
return
}
hasIntroStarted = true
removeLoadListener?.()
removeLoadListener = null
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
const introTimeline = gsap.timeline({
introTimeline = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => {
gsap.set(introRef.value, { display: 'none' })
gsap.set(heroRef.value, { clearProps: 'transform' })
gsap.set(introElement, { display: 'none' })
gsap.set(heroElement, { clearProps: 'transform' })
restorePageScroll?.()
restorePageScroll = null
ScrollTrigger.refresh()
introTimeline = null
},
})
introTimeline
.to('.hero-intro-char', {
.to(loaderBarElement, {
scaleX: 1,
duration: 0.25,
ease: 'power2.out',
})
.to(loaderElement, {
yPercent: -100,
duration: 0.75,
ease: 'power4.inOut',
})
.set(loaderElement, {
display: 'none',
})
.set(introElement, {
display: 'grid',
yPercent: 0,
autoAlpha: 1,
})
.set(introTitleElement, {
y: 0,
autoAlpha: 1,
})
.set('.hero-intro-tm', {
autoAlpha: 0,
})
.fromTo(gsap.utils.toArray('.hero-intro-char', introElement), {
yPercent: 110,
y: 0,
}, {
yPercent: 0,
y: 0,
duration: 0.75,
stagger: 0.055,
})
.to('.hero-intro-tm', {
autoAlpha: 1,
duration: 0.4,
ease: 'power2.out',
})
.addLabel('introOut', '+=0.35')
.to('.hero-intro-title', {
.to(introTitleElement, {
autoAlpha: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(introRef.value, {
.to(introElement, {
yPercent: -100,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(heroRef.value, {
.to(heroElement, {
yPercent: 0,
duration: 1.05,
ease: 'power4.inOut',
@@ -169,47 +253,70 @@ onMounted(() => {
}
}
// 首屏入场期间锁住滚动,等 window load 后再开始字母动画,避免资源未就绪时露出 Hero。
const previousHtmlOverflow = document.documentElement.style.overflow
const previousBodyOverflow = document.body.style.overflow
const releasePageScroll = () => {
isIntroActive.value = false
document.documentElement.classList.remove('is-home-intro-active')
document.documentElement.style.overflow = previousHtmlOverflow
document.body.style.overflow = previousBodyOverflow
setHomeIntroActive(false)
$lenis?.start()
}
document.documentElement.classList.add('is-home-intro-active')
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
// 首屏入场期间只通过 html class 锁滚动,避免 class 和 inline overflow 两套状态互相覆盖。
setHomeIntroActive(true)
$lenis?.scrollTo(0, { immediate: true })
$lenis?.stop()
restorePageScroll = releasePageScroll
// 每次播放前都重置首屏状态,避免热更新或页面返回时残留上一次的 inline style
gsap.set(introRef.value, { display: 'grid', yPercent: 0, autoAlpha: 1 })
gsap.set('.hero-intro-title', { y: 0, autoAlpha: 1 })
gsap.set('.hero-intro-char', { yPercent: 110 })
gsap.set(heroRef.value, { yPercent: 100 })
// Loading 层先接管首屏;真正的品牌入场等 window.load 完成后再开始
gsap.set(loaderElement, { display: 'grid', yPercent: 0, autoAlpha: 1 })
gsap.set(loaderBarElement, { scaleX: 0.18, transformOrigin: 'left center' })
gsap.set(introElement, { display: 'grid', yPercent: 0, autoAlpha: 0 })
gsap.set(introTitleElement, { y: 0, autoAlpha: 1 })
// 强制清除 CSS 中的 translateY 像素值,避免被 GSAP 解析为固定 y 像素导致一直不可见
gsap.set(introCharElements, { y: 0, yPercent: 110 })
gsap.set('.hero-intro-tm', { autoAlpha: 0 })
gsap.set(heroElement, { yPercent: 100 })
if (topHeader) {
gsap.set(topHeader, { yPercent: -140, autoAlpha: 0 })
}
setupHeroScroll()
loaderTween = gsap.to(loaderBarElement, {
scaleX: 0.82,
duration: 1.2,
ease: 'power1.inOut',
repeat: -1,
yoyo: true,
})
const loaderStartedAt = window.performance.now()
const minLoaderDuration = 1200
const queueIntro = () => {
const remainingTime = Math.max(0, minLoaderDuration - (window.performance.now() - loaderStartedAt))
introStartTimer = window.setTimeout(() => {
window.requestAnimationFrame(playIntro)
}, remainingTime)
}
if (document.readyState === 'complete') {
requestAnimationFrame(playIntro)
queueIntro()
return
}
window.addEventListener('load', playIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', playIntro)
}, pageRef.value)
window.addEventListener('load', queueIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', queueIntro)
}, pageElement)
})
onBeforeUnmount(() => {
isIntroActive.value = false
setHomeIntroActive(false)
hasIntroStarted = false
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
introTimeline = null
cleanupHeroScroll?.()
cleanupHeroScroll = null
removeLoadListener?.()
@@ -229,15 +336,26 @@ onBeforeUnmount(() => {
:data-demo-user="demoUserName"
:data-demo-user-role="demoUserRole"
>
<div ref="loaderRef" class="page-loader" aria-hidden="true">
<div class="page-loader-content">
<p>Loading</p>
<span class="page-loader-line">
<span ref="loaderBarRef" class="page-loader-line-inner" />
</span>
</div>
</div>
<div ref="introRef" class="hero-intro" aria-hidden="true">
<h1 class="hero-intro-title" :aria-label="introTitle">
<span
v-for="(letter, index) in introLetters"
:key="`${letter}-${index}`"
class="hero-intro-char-wrap"
>
<span class="hero-intro-char">{{ letter }}</span>
<span v-if="index === introLetters.length - 1" class="hero-intro-tm">TM</span>
<h1 ref="introTitleRef" class="hero-intro-title" :aria-label="introTitle">
<span class="hero-intro-word">
<span
v-for="(letter, index) in introLetters"
:key="`${letter}-${index}`"
class="hero-intro-char-wrap"
>
<span ref="introCharRefs" class="hero-intro-char">{{ letter }}</span>
</span>
<span class="hero-intro-tm">TM</span>
</span>
</h1>
</div>
@@ -289,6 +407,45 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.page-loader {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
color: #fff;
background: #000;
will-change: transform;
}
.page-loader-content {
width: min(220px, calc(100vw - 48px));
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.page-loader-content p {
margin: 0 0 14px;
}
.page-loader-line {
display: block;
width: 100%;
height: 1px;
overflow: hidden;
background: rgb(255 255 255 / 20%);
}
.page-loader-line-inner {
display: block;
width: 100%;
height: 100%;
background: #fff;
transform: scaleX(0);
transform-origin: left center;
}
.hero-intro {
position: fixed;
inset: 0;
@@ -304,7 +461,6 @@ onBeforeUnmount(() => {
.hero-intro-title {
display: flex;
margin: 0;
overflow: hidden;
font-size: clamp(36px, 6.2vw, 104px);
font-weight: 700;
line-height: 0.9;
@@ -312,6 +468,11 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.hero-intro-word {
position: relative;
display: inline-flex;
}
.hero-intro-char-wrap,
.hero-intro-char {
display: inline-block;