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 && (
+
+
{slides[slideTransition.from].eyebrow}
+
{slides[slideTransition.from].title}
+
{slides[slideTransition.from].body}
+
+ )}
+
+
{active.eyebrow}
+
{active.title}
+
{active.body}
+
@@ -164,9 +234,24 @@ export default function App() {
-
-
{active.metric}
-
{active.feature}
+
+ {slideTransition && (
+
+ {slides[slideTransition.from].metric}
+ {slides[slideTransition.from].feature}
+
+ )}
+
+ {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;
}