feat: 添加首页入场动画并优化滚动交互

- 在 lenis.client.ts 中提供 lenis 实例供全局使用
- 添加首页入场动画,包含字母动画和过渡效果
- 优化滚动行为,入场期间锁定滚动
- 修复自定义光标初始位置问题
- 调整页面布局样式,隐藏滚动条
- 更新头部组件类名以匹配新设计
This commit is contained in:
2026-04-26 19:26:27 +08:00
parent fc52afda99
commit 3173bd3cd6
5 changed files with 248 additions and 43 deletions
+18
View File
@@ -9,6 +9,24 @@
html,
body {
max-width: 100%;
overflow-x: clip;
background: #fff;
}
html {
scrollbar-width: none;
}
html::-webkit-scrollbar {
width: 0;
height: 0;
}
/* 首页入场动画期间首帧就隐藏滚动条,避免黑色遮罩边缘露出白色滚动槽。 */
html.is-home-intro-active,
html.is-home-intro-active body {
overflow: hidden;
background: #000;
}
}
+22 -3
View File
@@ -31,6 +31,7 @@ let cursorX = 0
let cursorY = 0
let pillWidth = 100
let isPillVisible = false
let hasCursorPosition = false
// 查找最近的 `data-cursor-label` 元素,用来判断是否需要显示胶囊态。
const getCursorTarget = (target: EventTarget | null) => {
@@ -94,6 +95,10 @@ onMounted(() => {
}
const showCursor = () => {
if (!hasCursorPosition) {
return
}
gsap.to([horizontalLineRef.value, verticalLineRef.value], {
autoAlpha: 1,
duration: 0.15,
@@ -112,6 +117,7 @@ onMounted(() => {
const hideCursor = () => {
isPillVisible = false
hasCursorPosition = false
gsap.to(allTargets, {
autoAlpha: 0,
@@ -187,6 +193,7 @@ onMounted(() => {
const moveCursor = (event: MouseEvent) => {
cursorX = event.clientX
cursorY = event.clientY
hasCursorPosition = true
showCursor()
gsap.set(dotRef.value, {
@@ -205,6 +212,10 @@ onMounted(() => {
return
}
if (!hasCursorPosition) {
moveCursor(event)
}
showPill(target.dataset.cursorLabel || 'Read more')
}
@@ -221,14 +232,14 @@ onMounted(() => {
}
window.addEventListener('mousemove', moveCursor)
window.addEventListener('mouseenter', showCursor)
window.addEventListener('mouseenter', moveCursor)
window.addEventListener('mouseleave', hideCursor)
document.addEventListener('mouseover', handleMouseOver)
document.addEventListener('mouseout', handleMouseOut)
cleanupCursor = () => {
window.removeEventListener('mousemove', moveCursor)
window.removeEventListener('mouseenter', showCursor)
window.removeEventListener('mouseenter', moveCursor)
window.removeEventListener('mouseleave', hideCursor)
document.removeEventListener('mouseover', handleMouseOver)
document.removeEventListener('mouseout', handleMouseOut)
@@ -257,6 +268,14 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.custom-cursor-dot,
.custom-cursor-line,
.custom-cursor-pill,
.custom-cursor-pill-text {
opacity: 0;
visibility: hidden;
}
.custom-cursor-dot,
.custom-cursor-line {
position: fixed;
@@ -280,7 +299,7 @@ onBeforeUnmount(() => {
}
.custom-cursor-line-x {
width: 100vw;
right: 0;
height: 0.5px;
}
@@ -83,7 +83,7 @@ onBeforeUnmount(() => {
<template>
<header
ref="topHeaderRef"
class="fixed left-0 right-0 top-0 z-50 mx-auto h-[70px] w-page-container max-w-[calc(100%-2rem)] bg-red-200 px-4 py-5"
class="site-header-top fixed left-0 right-0 top-0 z-50 mx-auto h-[70px] w-page-container max-w-[calc(100%-2rem)] bg-red-200 px-4 py-5"
>
<nav>Top Navigation</nav>
</header>
+201 -39
View File
@@ -1,12 +1,23 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
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'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const introTitle = 'ArcticNewOne'
const introLetters = introTitle.split('')
const isIntroActive = ref(true)
// 让首屏入场的锁滚动在 SSR/首帧就生效,避免等 onMounted 才隐藏滚动条。
useHead({
htmlAttrs: {
class: computed(() => (isIntroActive.value ? 'is-home-intro-active' : undefined)),
},
})
// 背景图是装饰层,左右两列共用同一套模板,只通过 side 区分位置和速度。
const heroGalleryColumns = [
@@ -29,62 +40,152 @@ const heroGalleryColumns = [
],
},
] as const
const pageRef = ref<HTMLElement | null>(null)
const introRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
const nextSectionRef = ref<HTMLElement | null>(null)
let heroAnimationContext: gsap.Context | null = null
let removeLoadListener: (() => void) | null = null
let restorePageScroll: (() => void) | null = null
onMounted(() => {
if (!heroRef.value || !nextSectionRef.value) {
if (!pageRef.value || !introRef.value || !heroRef.value || !nextSectionRef.value) {
return
}
const { $lenis } = useNuxtApp() as ReturnType<typeof useNuxtApp> & { $lenis?: Lenis }
const topHeader = document.querySelector<HTMLElement>('.site-header-top')
heroAnimationContext = gsap.context(() => {
// pinDistance 控制 Hero 固定阶段的滚动长度;scrollScrub 控制滚动追随的柔和程度。
const pinDistance = 1200
const scrollScrub = 0.65
gsap.set(heroPanelRef.value, {
scale: 1,
transformOrigin: 'center center',
})
// 第一段:Hero 停在屏幕中,前景白色面板先缩小,露出背后的图片列。
gsap.timeline({
scrollTrigger: {
trigger: heroRef.value,
start: 'top top',
end: `+=${pinDistance}`,
scrub: scrollScrub,
pin: true,
anticipatePin: 1,
},
})
.to(heroPanelRef.value, {
scale: 0.65,
ease: 'none',
const setupHeroScroll = () => {
gsap.set(heroPanelRef.value, {
scale: 1,
transformOrigin: 'center center',
})
// 第段:取消 pin 后,下一个板块开始进入,白色面板继续缩小
gsap.fromTo(heroPanelRef.value, {
scale: 0.65,
}, {
scale: 0.18,
ease: 'none',
immediateRender: false,
scrollTrigger: {
trigger: nextSectionRef.value,
start: 'top bottom',
end: 'top top',
scrub: scrollScrub,
},
})
}, heroRef.value)
// 第段:Hero 停在屏幕中,前景白色面板先缩小,露出背后的图片列
gsap.timeline({
scrollTrigger: {
trigger: heroRef.value,
start: 'top top',
end: `+=${pinDistance}`,
scrub: scrollScrub,
pin: true,
anticipatePin: 1,
},
})
.to(heroPanelRef.value, {
scale: 0.65,
ease: 'none',
})
ScrollTrigger.refresh()
// 第二段:取消 pin 后,下一个板块开始进入,白色面板继续缩小。
gsap.fromTo(heroPanelRef.value, {
scale: 0.65,
}, {
scale: 0.18,
ease: 'none',
immediateRender: false,
scrollTrigger: {
trigger: nextSectionRef.value,
start: 'top bottom',
end: 'top top',
scrub: scrollScrub,
},
})
}
const playIntro = () => {
removeLoadListener?.()
removeLoadListener = null
const introTimeline = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => {
gsap.set(heroRef.value, { clearProps: 'transform' })
gsap.set(introRef.value, { display: 'none' })
setupHeroScroll()
ScrollTrigger.refresh()
isIntroActive.value = false
restorePageScroll?.()
restorePageScroll = null
},
})
introTimeline
.to('.hero-intro-char', {
yPercent: 0,
duration: 0.75,
stagger: 0.055,
})
.to('.hero-intro-title', {
y: -28,
autoAlpha: 0,
duration: 0.45,
ease: 'power2.in',
}, '+=0.35')
.addLabel('introOut')
.to(introRef.value, {
yPercent: -100,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(heroRef.value, {
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')
}
}
// 首屏入场期间锁住滚动,等 window load 后再开始字母动画,避免资源未就绪时露出 Hero。
const previousHtmlOverflow = document.documentElement.style.overflow
const previousBodyOverflow = document.body.style.overflow
$lenis?.scrollTo(0, { immediate: true })
$lenis?.stop()
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
restorePageScroll = () => {
document.documentElement.style.overflow = previousHtmlOverflow
document.body.style.overflow = previousBodyOverflow
$lenis?.start()
}
gsap.set(heroRef.value, { yPercent: 100 })
gsap.set('.hero-intro-char', { yPercent: 110 })
if (topHeader) {
gsap.set(topHeader, { yPercent: -140, autoAlpha: 0 })
}
if (document.readyState === 'complete') {
requestAnimationFrame(playIntro)
return
}
window.addEventListener('load', playIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', playIntro)
}, pageRef.value)
})
onBeforeUnmount(() => {
isIntroActive.value = false
removeLoadListener?.()
removeLoadListener = null
restorePageScroll?.()
restorePageScroll = null
heroAnimationContext?.revert()
heroAnimationContext = null
})
@@ -92,7 +193,20 @@ onBeforeUnmount(() => {
</script>
<template>
<main id="top">
<main ref="pageRef" id="top">
<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>
</span>
</h1>
</div>
<section ref="heroRef" class="hero-section">
<div class="hero-backdrop" aria-hidden="true">
<div
@@ -114,7 +228,7 @@ onBeforeUnmount(() => {
:src="image"
alt=""
decoding="async"
loading="lazy"
loading="eager"
/>
</div>
</div>
@@ -134,11 +248,59 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.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;
overflow: hidden;
font-size: clamp(36px, 6.2vw, 104px);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.07em;
white-space: nowrap;
}
.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-section {
position: relative;
height: 100svh;
overflow: hidden;
background: #111;
will-change: transform;
}
.hero-backdrop {
+6
View File
@@ -27,4 +27,10 @@ export default defineNuxtPlugin((nuxtApp) => {
gsap.ticker.remove(updateLenis)
lenis.destroy()
})
return {
provide: {
lenis,
},
}
})