From f1998fb23c20f5c2265c0ff28bf092b3d7e854ae Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Fri, 10 Jul 2026 15:50:02 +0800 Subject: [PATCH] feat(landing): slide showcase carousel horizontally --- promo/landing/src/App.tsx | 111 +++++++++++++++++++++++++++++++----- promo/landing/src/index.css | 104 +++++++++++++++++++++++++-------- 2 files changed, 179 insertions(+), 36 deletions(-) diff --git a/promo/landing/src/App.tsx b/promo/landing/src/App.tsx index 97fccd8..cfcbdbb 100644 --- a/promo/landing/src/App.tsx +++ b/promo/landing/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, type AnimationEvent } from "react"; import logoUrl from "./assets/logo-universal.png"; import ShowcaseVisual from "./ShowcaseVisual"; @@ -33,6 +33,13 @@ const slides = [ }, ]; +type SlideDirection = -1 | 1; + +type SlideTransition = { + from: number; + direction: SlideDirection; +}; + function Logo() { return (
@@ -49,9 +56,40 @@ function Logo() { export default function App() { const [activeSlide, setActiveSlide] = useState(0); + const [slideTransition, setSlideTransition] = useState(null); const dragStartX = useRef(null); const active = slides[activeSlide]; + function getTransitionClass(role: "entering" | "exiting") { + if (!slideTransition) { + return ""; + } + + const edge = slideTransition.direction === 1 ? "right" : "left"; + return `is-${role}-from-${edge}`; + } + + function finishSlideTransition(event: AnimationEvent) { + if (event.target === event.currentTarget) { + setSlideTransition(null); + } + } + + function selectSlide(nextSlide: number, direction: SlideDirection) { + if (slideTransition || nextSlide === activeSlide) { + return; + } + + setSlideTransition({ from: activeSlide, direction }); + setActiveSlide(nextSlide); + } + + function selectDotSlide(nextSlide: number) { + const forwardDistance = (nextSlide - activeSlide + slides.length) % slides.length; + const backwardDistance = (activeSlide - nextSlide + slides.length) % slides.length; + selectSlide(nextSlide, forwardDistance <= backwardDistance ? 1 : -1); + } + const accentDots = useMemo( () => slides.map((slide, index) => ( @@ -60,14 +98,15 @@ export default function App() { className={`dot ${index === activeSlide ? "is-active" : ""}`} type="button" aria-label={`查看 ${slide.title}`} - onClick={() => setActiveSlide(index)} + onClick={() => selectDotSlide(index)} /> )), - [activeSlide], + [activeSlide, slideTransition], ); - function moveSlide(direction: -1 | 1) { - setActiveSlide((current) => (current + direction + slides.length) % slides.length); + function moveSlide(direction: SlideDirection) { + const nextSlide = (activeSlide + direction + slides.length) % slides.length; + selectSlide(nextSlide, direction); } function handlePointerEnd(clientX: number) { @@ -148,13 +187,44 @@ export default function App() {
- + {slideTransition && ( + + )} +
+ +
-
-

{active.eyebrow}

-

{active.title}

- {active.body} +
+ {slideTransition && ( + + )} +
+

{active.eyebrow}

+

{active.title}

+ {active.body} +
@@ -164,9 +234,24 @@ export default function App() { -
- {active.metric} - {active.feature} +
+ {slideTransition && ( + + )} +
+ {active.metric} + {active.feature} +
{accentDots} diff --git a/promo/landing/src/index.css b/promo/landing/src/index.css index e495c4a..e9fa129 100644 --- a/promo/landing/src/index.css +++ b/promo/landing/src/index.css @@ -339,8 +339,6 @@ h1 { .preview-stage { position: absolute; inset: 74px clamp(22px, 4vw, 52px) 154px; - display: grid; - place-items: center; overflow: hidden; border-radius: 34px; background: @@ -350,6 +348,14 @@ h1 { background-size: 34px 34px, 34px 34px, auto; } +.carousel-slide { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + will-change: transform; +} + .scene-parallax { position: relative; width: 100%; @@ -361,7 +367,6 @@ h1 { inset: 0; overflow: hidden; color: #142033; - animation: scene-enter 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; } .visual-scene button { @@ -1623,13 +1628,20 @@ h1 { box-shadow: 0 0 0 1px rgba(38, 52, 73, 0.12); } -.slide-panel { +.slide-panel-viewport { position: absolute; right: clamp(22px, 4vw, 52px); bottom: 34px; left: clamp(22px, 4vw, 52px); + display: grid; min-height: 92px; - animation: fade-slide 260ms ease both; + overflow: hidden; +} + +.slide-panel { + position: relative; + grid-area: 1 / 1; + will-change: transform; } .slide-panel p { @@ -1687,12 +1699,22 @@ h1 { background 180ms ease; } +.slide-meta-viewport { + position: relative; + min-width: 0; + height: 28px; + overflow: hidden; +} + .slide-meta { + position: absolute; + inset: 0; display: flex; min-width: 0; align-items: baseline; gap: 10px; padding-left: 4px; + will-change: transform; } .slide-meta strong { @@ -1733,15 +1755,59 @@ h1 { background: #0a84ff; } -@keyframes scene-enter { +.is-entering-from-right { + animation: carousel-enter-from-right 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.is-exiting-from-right { + animation: carousel-exit-to-left 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.is-entering-from-left { + animation: carousel-enter-from-left 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.is-exiting-from-left { + animation: carousel-exit-to-right 420ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes carousel-enter-from-right { from { - opacity: 0; - transform: translateY(9px) scale(0.985); + transform: translate3d(100%, 0, 0); } to { - opacity: 1; - transform: translateY(0) scale(1); + transform: translate3d(0, 0, 0); + } +} + +@keyframes carousel-exit-to-left { + from { + transform: translate3d(0, 0, 0); + } + + to { + transform: translate3d(-100%, 0, 0); + } +} + +@keyframes carousel-enter-from-left { + from { + transform: translate3d(-100%, 0, 0); + } + + to { + transform: translate3d(0, 0, 0); + } +} + +@keyframes carousel-exit-to-right { + from { + transform: translate3d(0, 0, 0); + } + + to { + transform: translate3d(100%, 0, 0); } } @@ -2020,18 +2086,6 @@ h1 { } } -@keyframes fade-slide { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - @media (max-width: 860px) { html, body, @@ -2112,7 +2166,7 @@ h1 { border-radius: 24px; } - .slide-panel { + .slide-panel-viewport { right: 22px; bottom: 22px; left: 22px; @@ -2535,6 +2589,10 @@ h1 { gap: 1px; } + .slide-meta-viewport { + height: 38px; + } + .slide-meta strong { font-size: 16px; }