Files
arcticnewone/app/pages/index.vue
T
virtheart 3173bd3cd6 feat: 添加首页入场动画并优化滚动交互
- 在 lenis.client.ts 中提供 lenis 实例供全局使用
- 添加首页入场动画,包含字母动画和过渡效果
- 优化滚动行为,入场期间锁定滚动
- 修复自定义光标初始位置问题
- 调整页面布局样式,隐藏滚动条
- 更新头部组件类名以匹配新设计
2026-04-26 19:26:27 +08:00

375 lines
9.2 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'
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 = [
{
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 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 (!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
const setupHeroScroll = () => {
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',
})
// 第二段:取消 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
})
</script>
<template>
<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
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 ref="nextSectionRef" 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>
.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 {
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>