feat(landing): slide showcase carousel horizontally
This commit is contained in:
+98
-13
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user