feat(landing): slide showcase carousel horizontally

This commit is contained in:
2026-07-10 15:50:02 +08:00
parent 8e611a11e6
commit f1998fb23c
2 changed files with 179 additions and 36 deletions
+98 -13
View File
@@ -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 logoUrl from "./assets/logo-universal.png";
import ShowcaseVisual from "./ShowcaseVisual"; import ShowcaseVisual from "./ShowcaseVisual";
@@ -33,6 +33,13 @@ const slides = [
}, },
]; ];
type SlideDirection = -1 | 1;
type SlideTransition = {
from: number;
direction: SlideDirection;
};
function Logo() { function Logo() {
return ( return (
<div className="brand" aria-label="SnapGo logo"> <div className="brand" aria-label="SnapGo logo">
@@ -49,9 +56,40 @@ function Logo() {
export default function App() { export default function App() {
const [activeSlide, setActiveSlide] = useState(0); const [activeSlide, setActiveSlide] = useState(0);
const [slideTransition, setSlideTransition] = useState<SlideTransition | null>(null);
const dragStartX = useRef<number | null>(null); const dragStartX = useRef<number | null>(null);
const active = slides[activeSlide]; 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<HTMLElement>) {
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( const accentDots = useMemo(
() => () =>
slides.map((slide, index) => ( slides.map((slide, index) => (
@@ -60,14 +98,15 @@ export default function App() {
className={`dot ${index === activeSlide ? "is-active" : ""}`} className={`dot ${index === activeSlide ? "is-active" : ""}`}
type="button" type="button"
aria-label={`查看 ${slide.title}`} aria-label={`查看 ${slide.title}`}
onClick={() => setActiveSlide(index)} onClick={() => selectDotSlide(index)}
/> />
)), )),
[activeSlide], [activeSlide, slideTransition],
); );
function moveSlide(direction: -1 | 1) { function moveSlide(direction: SlideDirection) {
setActiveSlide((current) => (current + direction + slides.length) % slides.length); const nextSlide = (activeSlide + direction + slides.length) % slides.length;
selectSlide(nextSlide, direction);
} }
function handlePointerEnd(clientX: number) { function handlePointerEnd(clientX: number) {
@@ -148,13 +187,44 @@ export default function App() {
</div> </div>
<div className="preview-stage"> <div className="preview-stage">
<ShowcaseVisual key={active.eyebrow} activeIndex={activeSlide} /> {slideTransition && (
<div
key={`visual-${slideTransition.from}`}
className={`carousel-slide is-exiting ${getTransitionClass("exiting")}`}
aria-hidden="true"
>
<ShowcaseVisual activeIndex={slideTransition.from} />
</div>
)}
<div
key={`visual-${activeSlide}`}
className={`carousel-slide ${slideTransition ? `is-entering ${getTransitionClass("entering")}` : ""}`}
onAnimationEnd={finishSlideTransition}
>
<ShowcaseVisual activeIndex={activeSlide} />
</div>
</div> </div>
<div className="slide-panel" key={active.title}> <div className="slide-panel-viewport">
<p>{active.eyebrow}</p> {slideTransition && (
<h2>{active.title}</h2> <div
<span>{active.body}</span> key={`copy-${slideTransition.from}`}
className={`slide-panel is-exiting ${getTransitionClass("exiting")}`}
aria-hidden="true"
>
<p>{slides[slideTransition.from].eyebrow}</p>
<h2>{slides[slideTransition.from].title}</h2>
<span>{slides[slideTransition.from].body}</span>
</div>
)}
<div
key={`copy-${activeSlide}`}
className={`slide-panel ${slideTransition ? `is-entering ${getTransitionClass("entering")}` : ""}`}
>
<p>{active.eyebrow}</p>
<h2>{active.title}</h2>
<span>{active.body}</span>
</div>
</div> </div>
</div> </div>
@@ -164,9 +234,24 @@ export default function App() {
<path d="m12 5-5 5 5 5" /> <path d="m12 5-5 5 5 5" />
</svg> </svg>
</button> </button>
<div className="slide-meta" aria-live="polite"> <div className="slide-meta-viewport" aria-live="polite">
<strong>{active.metric}</strong> {slideTransition && (
<span>{active.feature}</span> <div
key={`meta-${slideTransition.from}`}
className={`slide-meta is-exiting ${getTransitionClass("exiting")}`}
aria-hidden="true"
>
<strong>{slides[slideTransition.from].metric}</strong>
<span>{slides[slideTransition.from].feature}</span>
</div>
)}
<div
key={`meta-${activeSlide}`}
className={`slide-meta ${slideTransition ? `is-entering ${getTransitionClass("entering")}` : ""}`}
>
<strong>{active.metric}</strong>
<span>{active.feature}</span>
</div>
</div> </div>
<div className="dots" aria-label="轮播分页"> <div className="dots" aria-label="轮播分页">
{accentDots} {accentDots}
+81 -23
View File
@@ -339,8 +339,6 @@ h1 {
.preview-stage { .preview-stage {
position: absolute; position: absolute;
inset: 74px clamp(22px, 4vw, 52px) 154px; inset: 74px clamp(22px, 4vw, 52px) 154px;
display: grid;
place-items: center;
overflow: hidden; overflow: hidden;
border-radius: 34px; border-radius: 34px;
background: background:
@@ -350,6 +348,14 @@ h1 {
background-size: 34px 34px, 34px 34px, auto; background-size: 34px 34px, 34px 34px, auto;
} }
.carousel-slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
will-change: transform;
}
.scene-parallax { .scene-parallax {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -361,7 +367,6 @@ h1 {
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
color: #142033; color: #142033;
animation: scene-enter 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
} }
.visual-scene button { .visual-scene button {
@@ -1623,13 +1628,20 @@ h1 {
box-shadow: 0 0 0 1px rgba(38, 52, 73, 0.12); box-shadow: 0 0 0 1px rgba(38, 52, 73, 0.12);
} }
.slide-panel { .slide-panel-viewport {
position: absolute; position: absolute;
right: clamp(22px, 4vw, 52px); right: clamp(22px, 4vw, 52px);
bottom: 34px; bottom: 34px;
left: clamp(22px, 4vw, 52px); left: clamp(22px, 4vw, 52px);
display: grid;
min-height: 92px; 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 { .slide-panel p {
@@ -1687,12 +1699,22 @@ h1 {
background 180ms ease; background 180ms ease;
} }
.slide-meta-viewport {
position: relative;
min-width: 0;
height: 28px;
overflow: hidden;
}
.slide-meta { .slide-meta {
position: absolute;
inset: 0;
display: flex; display: flex;
min-width: 0; min-width: 0;
align-items: baseline; align-items: baseline;
gap: 10px; gap: 10px;
padding-left: 4px; padding-left: 4px;
will-change: transform;
} }
.slide-meta strong { .slide-meta strong {
@@ -1733,15 +1755,59 @@ h1 {
background: #0a84ff; 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 { from {
opacity: 0; transform: translate3d(100%, 0, 0);
transform: translateY(9px) scale(0.985);
} }
to { to {
opacity: 1; transform: translate3d(0, 0, 0);
transform: translateY(0) scale(1); }
}
@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) { @media (max-width: 860px) {
html, html,
body, body,
@@ -2112,7 +2166,7 @@ h1 {
border-radius: 24px; border-radius: 24px;
} }
.slide-panel { .slide-panel-viewport {
right: 22px; right: 22px;
bottom: 22px; bottom: 22px;
left: 22px; left: 22px;
@@ -2535,6 +2589,10 @@ h1 {
gap: 1px; gap: 1px;
} }
.slide-meta-viewport {
height: 38px;
}
.slide-meta strong { .slide-meta strong {
font-size: 16px; font-size: 16px;
} }