feat: 首页轮播图动画

This commit is contained in:
2026-05-14 17:14:37 +08:00
parent d47d9535ee
commit 9733a0dcd6
3 changed files with 421 additions and 104 deletions

23
pnpm-lock.yaml generated
View File

@@ -14,9 +14,18 @@ importers:
'@unhead/vue':
specifier: ^2.1.15
version: 2.1.15(vue@3.5.34(typescript@6.0.3))
gsap:
specifier: ^3.15.0
version: 3.15.0
less:
specifier: ^4.6.4
version: 4.6.4
unhead:
specifier: 2.1.15
version: 2.1.15
vite-ssg:
specifier: ^28.3.0
version: 28.3.0(unhead@3.1.0(vite@8.0.12(@types/node@24.12.4)(terser@5.47.1)))(vite@8.0.12(@types/node@24.12.4)(terser@5.47.1))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
version: 28.3.0(unhead@2.1.15)(vite@8.0.12(@types/node@24.12.4)(less@4.6.4)(terser@5.47.1))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
vue:
specifier: ^3.5.34
version: 3.5.34(typescript@6.0.3)
@@ -203,42 +212,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
@@ -562,28 +565,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1556,7 +1555,7 @@ snapshots:
vite-ssg-sitemap@0.10.0: {}
vite-ssg@28.3.0(unhead@3.1.0(vite@8.0.12(@types/node@24.12.4)(terser@5.47.1)))(vite@8.0.12(@types/node@24.12.4)(terser@5.47.1))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)):
vite-ssg@28.3.0(unhead@2.1.15)(vite@8.0.12(@types/node@24.12.4)(less@4.6.4)(terser@5.47.1))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@unhead/dom': 2.1.15(unhead@2.1.15)
'@unhead/vue': 2.1.15(vue@3.5.34(typescript@6.0.3))

View File

@@ -1,13 +1,53 @@
<template>
<section class="carousel-container" aria-label="Featured content">
<section
class="carousel-container"
aria-label="Featured content"
@mouseenter="pauseAutoplay"
@mouseleave="resumeAutoplay"
>
<KagolCarousel
v-model="activePage"
class="home-carousel"
:autoplay="isAutoplayEnabled"
:interval="5000"
:autoplay="false"
:interval="1000"
>
<article v-for="slide in slides" :key="slide.id" class="carousel-slide">
<img class="carousel-image" :src="slide.image" :alt="slide.alt" />
<article
v-for="slide in slides"
:key="slide.id"
ref="slideEls"
class="carousel-slide"
>
<div class="mask"></div>
<div class="banner-title" v-if="slide.title">{{ slide.title }}</div>
<img
v-if="!slide.video"
class="carousel-banner image"
:src="slide.image"
:alt="slide.alt"
/>
<video
v-else
class="carousel-banner video"
:alt="slide.alt"
:controls="false"
autoplay
muted
loop
>
<source :src="slide.video" type="video/mp4" />
</video>
<div class="desc flex flex-center" v-if="slide.description">
<span class="desc-fill" aria-hidden="true"></span>
<span class="desc-index-group">
<span class="desc-line" aria-hidden="true"></span>
<span class="desc-index-frame">
<span class="desc-index">{{ slide.number }}</span>
<span class="desc-index-cover" aria-hidden="true"></span>
</span>
</span>
<p class="desc-copy">{{ slide.description }}</p>
</div>
</article>
<template #pagination="{ prevPage, nextPage }">
@@ -43,123 +83,355 @@
<script setup lang="ts">
import { Carousel as KagolCarousel } from '@kagol/vue-carousel'
import '@kagol/vue-carousel/dist/style.css'
import { onMounted, shallowRef } from 'vue'
import { gsap } from 'gsap'
import {
nextTick,
onBeforeUnmount,
onMounted,
shallowRef,
useTemplateRef,
watch
} from 'vue'
import mainBanner01 from '../../../assets/images/home/mainbanner01.jpg'
import mainBanner02 from '../../../assets/images/home/mainbanner02.jpg'
import Video from '@/assets/images/home/hero-desktop.mp4'
type HomeSlide = {
id: string
image: string
video: string
alt: string
title?: string
number?: string
description?: string
}
const activePage = shallowRef(1)
const isAutoplayEnabled = shallowRef(false)
const slides = [
const slideEls = useTemplateRef<HTMLElement[]>('slideEls')
const slides: readonly HomeSlide[] = [
{
id: 'aida',
image: mainBanner01,
alt: 'AiDA product banner'
video: '',
alt: 'Code Create product banner',
title: 'Shaping the future\nof fashion design',
number: '01',
description:
"World's first and only designer-led AI system that streamlines ideation from hours to seconds"
},
{
id: 'mixi',
image: mainBanner02,
alt: 'Mixi product banner'
video: '',
alt: 'Code Create product banner',
title: 'Be the game changer,\n subscribe now!',
number: '02',
description: 'Make the first move to streamline and facilitate your inspiration process'
},
{
id: 'video',
image: '',
video: Video,
alt: 'Code Create product video banner'
}
] as const
]
const descAnimationDelay = 1
let activeSlideIndex: number | null = null
let descAnimationFrame = 0
let descTimeline: ReturnType<typeof gsap.timeline> | null = null
function getActiveSlideIndex() {
const slideCount = slides.length
return ((activePage.value - 1) % slideCount + slideCount) % slideCount
}
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
}
function playDescAnimation(slideIndex: number) {
const activeSlide = slideEls.value?.[slideIndex]
const desc = activeSlide?.querySelector<HTMLElement>('.desc')
const fill = desc?.querySelector<HTMLElement>('.desc-fill')
const line = desc?.querySelector<HTMLElement>('.desc-line')
const index = desc?.querySelector<HTMLElement>('.desc-index')
const cover = desc?.querySelector<HTMLElement>('.desc-index-cover')
const copy = desc?.querySelector<HTMLElement>('.desc-copy')
descTimeline?.kill()
descTimeline = null
if (!fill || !line || !index || !cover || !copy) {
return
}
gsap.killTweensOf([fill, line, index, cover, copy])
if (prefersReducedMotion()) {
gsap.set(fill, { width: '100%' })
gsap.set(line, { autoAlpha: 1, width: 1, x: 0 })
gsap.set(index, { autoAlpha: 1, x: 0 })
gsap.set(cover, { autoAlpha: 0, xPercent: 110 })
gsap.set(copy, { autoAlpha: 1, x: 0 })
return
}
gsap.set(fill, { width: 0 })
gsap.set(line, { autoAlpha: 0, width: 5, x: 18 })
gsap.set(index, { autoAlpha: 0, x: -34 })
gsap.set(cover, { autoAlpha: 0, xPercent: 0 })
gsap.set(copy, { autoAlpha: 0, x: 22 })
descTimeline = gsap
.timeline({
delay: descAnimationDelay,
defaults: {
ease: 'power3.out'
}
})
.addLabel('panel', 0)
.to(fill, { duration: 1.05, ease: 'power2.out', width: '100%' }, 'panel')
.to(line, { autoAlpha: 1, duration: 0.9, width: 1, x: 0 }, 'panel+=0.12')
.addLabel('number', 1.18)
.set(index, { autoAlpha: 1 }, 'number')
.set(cover, { autoAlpha: 1 }, 'number')
.to(index, { duration: 0.7, x: 0 }, 'number')
.to(cover, { duration: 0.72, ease: 'power2.inOut', xPercent: 110 }, 'number+=0.08')
.to(copy, { autoAlpha: 1, duration: 0.72, x: 0 }, '>')
}
function queueDescAnimation() {
const slideIndex = getActiveSlideIndex()
if (slideIndex === activeSlideIndex) {
return
}
activeSlideIndex = slideIndex
nextTick(() => {
if (descAnimationFrame) {
window.cancelAnimationFrame(descAnimationFrame)
}
descAnimationFrame = window.requestAnimationFrame(() => {
descAnimationFrame = 0
playDescAnimation(slideIndex)
})
})
}
function pauseAutoplay() {
isAutoplayEnabled.value = false
}
function resumeAutoplay() {
isAutoplayEnabled.value = true
}
onMounted(() => {
isAutoplayEnabled.value = true
resumeAutoplay()
queueDescAnimation()
})
onBeforeUnmount(() => {
if (descAnimationFrame) {
window.cancelAnimationFrame(descAnimationFrame)
}
descTimeline?.kill()
})
watch(activePage, queueDescAnimation)
</script>
<style scoped>
<style scoped lang="less">
.carousel-container {
width: 100%;
overflow: hidden;
background: #070b14;
}
.home-carousel {
width: 100%;
}
.carousel-slide {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
height: 960px;
overflow: hidden;
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.35);
z-index: 2;
}
.carousel-banner {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
}
.banner-title {
position: absolute;
right: 50%;
top: 50%;
white-space: pre-line;
color: #fff;
font-size: 64px;
font-family: 'Poppins';
transform: translateY(-70%);
z-index: 3;
}
.desc {
position: absolute;
left: 0;
bottom: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 24px;
height: 140px;
width: 656px;
color: #ffffff;
background: transparent;
isolation: isolate;
}
.desc-fill {
position: absolute;
inset: 0 auto 0 0;
z-index: 0;
display: block;
width: 0;
background: #a51f24;
pointer-events: none;
}
.desc-index-group {
position: relative;
z-index: 1;
flex: 0 0 auto;
}
.desc-line {
position: absolute;
top: -160%;
left: 50%;
display: block;
width: 1px;
height: 65px;
background: rgba(255, 255, 255, 0.72);
opacity: 0;
transform: translateX(-50%);
will-change: transform, width, opacity;
}
.desc-index-frame {
position: relative;
display: block;
overflow: hidden;
}
.desc-index {
flex: 0 0 auto;
position: relative;
z-index: 1;
display: block;
font-family: 'Poppins';
font-size: 52px;
font-weight: 700;
line-height: 1;
opacity: 0;
will-change: transform, opacity;
}
.desc-index-cover {
position: absolute;
inset: 0;
z-index: 2;
display: block;
background: #ffffff;
opacity: 0;
pointer-events: none;
will-change: transform;
}
.desc-copy {
position: relative;
z-index: 1;
max-width: 440px;
margin: 0;
font-family: 'Poppins';
font-size: 14px;
font-weight: 600;
line-height: 1.45;
opacity: 0;
will-change: transform, opacity;
}
}
.carousel-pagination {
position: absolute;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-direction: column;
width: 70px;
height: 140px;
transform: translateX(100%);
transition: transform 0.28s ease;
will-change: transform;
}
.carousel-pagination-button {
position: relative;
display: flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
border: 0;
color: #ffffff;
background: #65090c;
cursor: pointer;
appearance: none;
transition: background-color 0.2s ease;
}
.carousel-slide {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
min-height: 360px;
max-height: 680px;
overflow: hidden;
background: #070b14;
}
.carousel-pagination-button:hover {
background: rgba(255, 255, 255, 0.75);
}
.carousel-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.carousel-pagination-button:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #ffffff;
outline-offset: -6px;
}
.carousel-pagination {
position: absolute;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-direction: column;
width: 70px;
height: 140px;
transform: translateX(100%);
transition: transform 0.28s ease;
will-change: transform;
.carousel-pagination-arrow {
position: absolute;
top: 50%;
left: 50%;
display: block;
box-sizing: border-box;
width: 20px;
height: 20px;
border-top: 2.5px solid currentColor;
border-right: 2.5px solid currentColor;
}
.carousel-pagination-arrow-prev {
transform: translate(-50%, -50%) rotate(225deg);
}
.carousel-pagination-arrow-next {
transform: translate(-50%, -50%) rotate(45deg);
}
}
.carousel-container:hover .carousel-pagination {
transform: translateX(0);
}
.carousel-pagination-button {
position: relative;
display: flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
border: 0;
color: #ffffff;
background: #65090c;
cursor: pointer;
appearance: none;
transition: background-color 0.2s ease;
}
.carousel-pagination-button:hover {
background: rgba(255, 255, 255, 0.75);
}
.carousel-pagination-button:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #ffffff;
outline-offset: -6px;
}
.carousel-pagination-arrow {
position: absolute;
top: 50%;
left: 50%;
display: block;
box-sizing: border-box;
width: 20px;
height: 20px;
border-top: 2.5px solid currentColor;
border-right: 2.5px solid currentColor;
}
.carousel-pagination-arrow-prev {
transform: translate(-50%, -50%) rotate(225deg);
}
.carousel-pagination-arrow-next {
transform: translate(-50%, -50%) rotate(45deg);
}
:deep(.xui-carousel) {
width: 100%;
}
@@ -173,6 +445,29 @@
min-height: 320px;
}
.home-carousel {
.carousel-slide {
.desc {
width: 100%;
min-width: 0;
height: auto;
min-height: 92px;
padding: 16px 20px;
}
.desc-line {
top: -28px;
height: 42px;
}
.desc-index {
font-size: 40px;
}
.desc-copy {
max-width: none;
font-size: 12px;
}
}
}
.carousel-pagination-arrow {
width: 18px;
height: 18px;

View File

@@ -1,11 +1,34 @@
html,body{
margin: 0;
padding: 0;
html,
body {
margin: 0;
padding: 0;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-1 {
flex: 1;
}
.space-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.align-center {
align-items: center;
}