| 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"
>
+
+
-
-
- {{ letter }}
- TM
+
+
+
+ {{ letter }}
+
+ TM
@@ -289,6 +407,45 @@ onBeforeUnmount(() => {