Files
arcticnewone/app/pages/index.vue
T
virtheart c1ab766c49 feat: 添加页面加载动画并优化首屏入场效果
- 在页面加载时添加加载进度条动画
- 重构首屏入场动画逻辑,分离加载层和品牌展示层
- 优化滚动控制逻辑,避免 class 和 inline style 冲突
- 添加动画状态管理变量,防止重复触发
2026-04-26 21:55:48 +08:00

583 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useUserStore } from '~/stores/user'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const introTitle = 'ArcticNewOne'
const introLetters = introTitle.split('')
const heroPinDistance = 1200
const isIntroActive = ref(true)
const userStore = useUserStore()
const demoUserName = computed(() => userStore.displayName)
const demoUserRole = computed(() => userStore.role)
if (!userStore.isLoggedIn) {
userStore.loadDemoUser()
}
// 让首屏入场的锁滚动在 SSR/首帧就生效,避免等 onMounted 才隐藏滚动条。
useHead({
htmlAttrs: {
class: computed(() => (isIntroActive.value ? 'is-home-intro-active' : undefined)),
},
})
// 背景图是装饰层,左右两列共用同一套模板,只通过 side 区分位置和速度。
const heroGalleryColumns = [
{
side: 'left',
images: [
'https://picsum.photos/id/1018/900/1200',
'https://picsum.photos/id/1015/900/1200',
'https://picsum.photos/id/1024/900/1200',
'https://picsum.photos/id/1039/900/1200',
],
},
{
side: 'right',
images: [
'https://picsum.photos/id/1043/900/1200',
'https://picsum.photos/id/1050/900/1200',
'https://picsum.photos/id/1067/900/1200',
'https://picsum.photos/id/1074/900/1200',
],
},
] 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)
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(() => {
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
}
const { $lenis } = useNuxtApp() as ReturnType<typeof useNuxtApp> & { $lenis?: Lenis }
const topHeader = document.querySelector<HTMLElement>('.site-header-top')
heroAnimationContext = gsap.context(() => {
const pinEndScale = 0.65
const finalScale = 0.18
const getExitDistance = () => window.innerHeight
const setupHeroScroll = () => {
cleanupHeroScroll?.()
gsap.set(heroPanelElement, {
scale: 1,
force3D: true,
transformOrigin: 'center center',
})
let renderedScale = -1
const setHeroPanelScale = (scale: number) => {
if (Math.abs(renderedScale - scale) < 0.001) {
return
}
renderedScale = scale
gsap.set(heroPanelElement, { scale })
}
const updateHeroPanelScale = () => {
const exitDistance = getExitDistance()
const totalDistance = heroPinDistance + exitDistance
// 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))
return
}
setHeroPanelScale(
gsap.utils.interpolate(
pinEndScale,
finalScale,
(currentDistance - heroPinDistance) / exitDistance,
),
)
}
// CSS sticky 负责固定;ticker 每帧读取当前滚动距离,避免依赖 scroll/ScrollTrigger 事件链。
updateHeroPanelScale()
gsap.ticker.add(updateHeroPanelScale)
cleanupHeroScroll = () => {
gsap.ticker.remove(updateHeroPanelScale)
}
}
const playIntro = () => {
if (hasIntroStarted) {
return
}
hasIntroStarted = true
removeLoadListener?.()
removeLoadListener = null
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
introTimeline = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => {
gsap.set(introElement, { display: 'none' })
gsap.set(heroElement, { clearProps: 'transform' })
restorePageScroll?.()
restorePageScroll = null
ScrollTrigger.refresh()
introTimeline = null
},
})
introTimeline
.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(introTitleElement, {
autoAlpha: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(introElement, {
yPercent: -100,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(heroElement, {
yPercent: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
if (topHeader) {
introTimeline.to(topHeader, {
yPercent: 0,
autoAlpha: 1,
duration: 0.55,
ease: 'power3.out',
}, 'introOut+=0.45')
}
}
const releasePageScroll = () => {
setHomeIntroActive(false)
$lenis?.start()
}
// 首屏入场期间只通过 html class 锁滚动,避免 class 和 inline overflow 两套状态互相覆盖。
setHomeIntroActive(true)
$lenis?.scrollTo(0, { immediate: true })
$lenis?.stop()
restorePageScroll = releasePageScroll
// 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') {
queueIntro()
return
}
window.addEventListener('load', queueIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', queueIntro)
}, pageElement)
})
onBeforeUnmount(() => {
setHomeIntroActive(false)
hasIntroStarted = false
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
introTimeline = null
cleanupHeroScroll?.()
cleanupHeroScroll = null
removeLoadListener?.()
removeLoadListener = null
restorePageScroll?.()
restorePageScroll = null
heroAnimationContext?.revert()
heroAnimationContext = null
})
</script>
<template>
<main
ref="pageRef"
id="top"
: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 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>
<section
ref="heroStageRef"
class="hero-stage"
:style="{ '--hero-pin-distance': `${heroPinDistance}px` }"
>
<section ref="heroRef" class="hero-section">
<div class="hero-backdrop" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="hero-gallery"
:class="`hero-gallery-${column.side}`"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="hero-gallery-group">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="hero-gallery-image"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="hero-panel">
<p>Hero Section</p>
</div>
</section>
</section>
<section class="next-section">
<h2>Next Section</h2>
</section>
<section id="work" class="work-section" data-cursor-label="Read more">
<p>{{ t('home.welcome') }}</p>
</section>
</main>
</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;
z-index: 80;
display: grid;
place-items: center;
overflow: hidden;
color: #fff;
background: #000;
will-change: transform;
}
.hero-intro-title {
display: flex;
margin: 0;
font-size: clamp(36px, 6.2vw, 104px);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.07em;
white-space: nowrap;
}
.hero-intro-word {
position: relative;
display: inline-flex;
}
.hero-intro-char-wrap,
.hero-intro-char {
display: inline-block;
}
.hero-intro-char-wrap {
position: relative;
overflow: hidden;
}
.hero-intro-char {
will-change: transform;
}
.hero-intro-tm {
position: absolute;
left: calc(100% + 0.12em);
top: 0.08em;
font-size: 0.13em;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
}
.hero-stage {
position: relative;
height: calc(100svh + var(--hero-pin-distance));
background: #000;
}
.hero-section {
position: sticky;
top: 0;
height: 100svh;
overflow: hidden;
background: #111;
}
.hero-backdrop {
position: absolute;
inset: 0;
overflow: hidden;
background: #000;
}
.hero-gallery {
position: absolute;
top: -40px;
bottom: -40px;
width: 49vw;
overflow: hidden;
}
.hero-gallery-left {
left: 0;
}
.hero-gallery-right {
right: 0;
}
.hero-gallery-track {
will-change: transform;
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
.hero-gallery-group {
display: grid;
gap: 36px;
padding-bottom: 36px;
}
.hero-gallery-image {
display: block;
width: 100%;
height: clamp(260px, 42vh, 430px);
border-radius: 0;
object-fit: cover;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
.hero-panel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #fff;
font-size: 48px;
will-change: transform;
}
.next-section,
.work-section {
min-height: 100svh;
display: grid;
place-items: center;
}
</style>