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 ShowcaseVisual from "./ShowcaseVisual";
@@ -33,6 +33,13 @@ const slides = [
},
];
type SlideDirection = -1 | 1;
type SlideTransition = {
from: number;
direction: SlideDirection;
};
function Logo() {
return (
<div className="brand" aria-label="SnapGo logo">
@@ -49,9 +56,40 @@ function Logo() {
export default function App() {
const [activeSlide, setActiveSlide] = useState(0);
const [slideTransition, setSlideTransition] = useState<SlideTransition | null>(null);
const dragStartX = useRef<number | null>(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<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(
() =>
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() {
</div>
<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 className="slide-panel" key={active.title}>
<p>{active.eyebrow}</p>
<h2>{active.title}</h2>
<span>{active.body}</span>
<div className="slide-panel-viewport">
{slideTransition && (
<div
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>
@@ -164,9 +234,24 @@ export default function App() {
<path d="m12 5-5 5 5 5" />
</svg>
</button>
<div className="slide-meta" aria-live="polite">
<strong>{active.metric}</strong>
<span>{active.feature}</span>
<div className="slide-meta-viewport" aria-live="polite">
{slideTransition && (
<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 className="dots" aria-label="轮播分页">
{accentDots}
+81 -23
View File
@@ -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;
}