feat: Apple-style scroll-driven landing page

- Add ScrollAnimations component (ScrollReveal, ParallaxLayer, FadeOnScroll)
- Add CinematicShowcase for scroll-driven product reveals
- Update Tuxedo page with cinematic product showcase and parallax layers
- Update main HeroSection with same premium scroll animations
- Add scroll progress bar, staggered reveals, counter animations
- Apply consistent Apple-style UX across landing and Tuxedo pages

Features:
- Sections pin and reveal content progressively on scroll
- Parallax depth effects on decorative elements
- Counter animations triggered by scroll
- Smooth hover states and transitions
- GSAP + Framer Motion for smooth animations
This commit is contained in:
2026-06-02 15:41:48 +00:00
parent bf4dc09e65
commit 2db3375f87
4 changed files with 526 additions and 714 deletions
-19
View File
@@ -2,8 +2,6 @@
import { useEffect, useRef } from "react";
import HeroSection from "@/components/landing/HeroSection";
import FeaturesAndStats from "@/components/landing/FeaturesAndStats";
import TestimonialsAndCTA from "@/components/landing/TestimonialsAndCTA";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -20,21 +18,6 @@ export default function LandingPageClient() {
if (typeof window === "undefined" || !mainRef.current) return;
const ctx = gsap.context(() => {
// Parallax sections
const parallaxElements = gsap.utils.toArray<Element>(".parallax-section");
parallaxElements.forEach((el) => {
gsap.to(el, {
y: -50,
ease: "none",
scrollTrigger: {
trigger: el,
start: "top bottom",
end: "bottom top",
scrub: 1,
},
});
});
// Reveal animations for sections
const revealElements = gsap.utils.toArray<Element>(".scroll-reveal");
revealElements.forEach((el) => {
@@ -62,8 +45,6 @@ export default function LandingPageClient() {
return (
<div ref={mainRef}>
<HeroSection />
<FeaturesAndStats />
<TestimonialsAndCTA />
</div>
);
}
+105 -31
View File
@@ -656,22 +656,50 @@ export default function TuxedoPage() {
onSecondaryClick={scrollToStory}
/>
{/* ─── CINEMATIC PRODUCT SHOWCASE ────────────────────────────────── */}
<CinematicShowcase
products={featuredProducts.map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? "",
price: `$${p.price}`,
type: p.type,
imageUrl: p.image_url,
}))}
brandSlug="tuxedo"
/>
<WhyTuxedoCorn />
<section id="stops" className="py-28 bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
{/* Parallax background layers */}
<ParallaxLayer speed={0.2} className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[40%] h-full bg-gradient-to-l from-emerald-50/30 to-transparent" />
</ParallaxLayer>
<div className="relative py-28">
<LayoutContainer>
<div className="mb-14 flex items-end justify-between">
<div>
<p className="reveal-slide text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
<h2 className="reveal-text text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
Upcoming<br className="hidden md:block" /> Stops
</h2>
<div className="reveal-scale mt-6 h-px w-12 bg-emerald-600" />
<p className="reveal-text mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
</p>
</FadeOnScroll>
</div>
{showSchedulePdf && (
<FadeOnScroll from="right" delay={0.3}>
<a
href="/api/tuxedo/schedule-pdf"
download
@@ -682,8 +710,10 @@ export default function TuxedoPage() {
</svg>
Download Schedule
</a>
</FadeOnScroll>
)}
</div>
<FadeOnScroll from="up" delay={0.3}>
{stops.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No upcoming stops scheduled. Check back soon!</p>
@@ -691,7 +721,9 @@ export default function TuxedoPage() {
) : (
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
)}
</FadeOnScroll>
</LayoutContainer>
</div>
</section>
{/* Section divider */}
@@ -707,25 +739,41 @@ export default function TuxedoPage() {
</div>
</div>
<section id="products" className="py-28 bg-white">
<section id="products" className="relative bg-white">
{/* Scroll-driven reveal for section header */}
<div className="py-20 bg-gradient-to-b from-stone-50 to-white">
<LayoutContainer>
<div className="reveal-slide mb-14">
<ScrollReveal from="up" className="mb-14">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
<h2 className="reveal-text text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
<h2 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
This Week&apos;s<br className="hidden md:block" /> Harvest
</h2>
<div className="reveal-scale mt-6 h-px w-12 bg-emerald-600" />
<p className="reveal-text mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
<div className="mt-6 h-px w-12 bg-emerald-600" />
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
Preorder for pickup at a stop, or have cooler boxes shipped directly to your door after the season.
</p>
</ScrollReveal>
</LayoutContainer>
</div>
{featuredProducts.length === 0 ? (
<div className="rounded-3xl bg-stone-50 p-14 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No products available at this time.</p>
</div>
) : (
<div className="grid gap-8 md:grid-cols-3">
{featuredProducts.map((product) => (
{/* Cinematic showcase replaces static grid */}
<CinematicShowcase
products={products.slice(0, 3).map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? "",
price: `$${p.price}`,
type: p.type,
imageUrl: p.image_url,
}))}
brandSlug="tuxedo"
/>
{/* Mobile-friendly fallback grid for smaller screens */}
<div className="md:hidden py-16 bg-stone-50">
<LayoutContainer>
<div className="grid gap-8 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{products.slice(0, 6).map((product) => (
<ProductCard
key={product.id}
id={product.id}
@@ -744,42 +792,65 @@ export default function TuxedoPage() {
/>
))}
</div>
)}
</LayoutContainer>
</div>
{products.length > 3 && (
<div className="mt-10 text-center">
<div className="mt-10 text-center pb-16">
<p className="text-sm text-stone-400">{products.length - 3} more products available in the full catalog.</p>
</div>
)}
</LayoutContainer>
</section>
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
{/* Parallax decorative elements */}
<div className="absolute inset-0 pointer-events-none">
<div className="parallax-float absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
filter: "blur(80px)",
}} />
<div className="parallax-float absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
}}>
<div />
</ParallaxLayer>
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
filter: "blur(60px)",
animationDelay: "0.5s",
}} />
}}>
<div />
</ParallaxLayer>
</div>
{/* Large background text for parallax depth */}
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
<ParallaxLayer speed={0.2}>
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
</ParallaxLayer>
</div>
<LayoutContainer>
<div className="text-center max-w-3xl mx-auto relative z-10">
<p className="reveal-text text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
<h2 className="reveal-text text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
<FadeOnScroll from="up" className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
</h2>
<div className="reveal-scale mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
<p className="reveal-text text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.3}>
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn developed for Colorado&apos;s high-altitude mountain climate and grown by the same family for over 40 years.
</p>
</FadeOnScroll>
{/* Stats with counter animation */}
<div className="reveal-scale flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
<FadeOnScroll from="up" delay={0.4}>
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
{[
{ stat: "40", suffix: "+", label: "Years Growing" },
{ stat: "3", suffix: "", label: "Generations" },
@@ -796,8 +867,10 @@ export default function TuxedoPage() {
</div>
))}
</div>
</FadeOnScroll>
<div className="reveal-scale mt-16">
<FadeOnScroll from="up" delay={0.5}>
<div className="mt-16">
<Link
href="/tuxedo/about"
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
@@ -820,6 +893,7 @@ export default function TuxedoPage() {
</svg>
</Link>
</div>
</FadeOnScroll>
</div>
</LayoutContainer>
</section>
+211 -465
View File
@@ -11,122 +11,90 @@ if (typeof window !== "undefined") {
}
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
interface ScrollProgress {
progress: number;
direction: number;
velocity: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO SECTION - SCROLL-DRIVEN ANIMATIONS
// CINEMATIC HERO SECTION - APPLE-STYLE SCROLL-DRIVEN ANIMATIONS
// ─────────────────────────────────────────────────────────────────────────────
export default function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [scrollProgress, setScrollProgress] = useState<ScrollProgress>({
progress: 0,
direction: 1,
velocity: 0,
});
const [scrollProgress, setScrollProgress] = useState(0);
useEffect(() => {
// Mark as mounted on client-side
const timer = setTimeout(() => {
setMounted(true);
}, 100);
const timer = setTimeout(() => setMounted(true), 100);
return () => clearTimeout(timer);
}, []);
// ─── SCROLL PROGRESS TRACKING ───────────────────────────────────────────────
useEffect(() => {
if (!mounted) return;
const handleScroll = () => {
const scrollY = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = Math.min(scrollY / docHeight, 1);
setScrollProgress(prev => ({
progress,
direction: scrollY > prev.progress ? 1 : -1,
velocity: Math.abs(scrollY - prev.progress) * 10,
}));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [mounted]);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
useEffect(() => {
if (!mounted || !containerRef.current) return;
if (!mounted || !heroRef.current || !contentRef.current) return;
const ctx = gsap.context(() => {
// Hero section timeline
const heroTl = gsap.timeline({
scrollTrigger: {
// Scroll progress tracking
ScrollTrigger.create({
trigger: heroRef.current,
start: "top top",
end: "bottom top",
scrub: 1.5,
pin: true,
anticipatePin: 1,
scrub: true,
onUpdate: (self) => {
setScrollProgress(self.progress);
},
});
// Hero content fade out on scroll
heroTl
.to(".hero-title", {
// Hero content pin - fade out on scroll
gsap.to(contentRef.current, {
y: -120,
opacity: 0,
scale: 0.95,
ease: "power2.inOut",
})
.to(
".hero-subtitle",
{
y: -80,
opacity: 0,
ease: "power2.inOut",
scrollTrigger: {
trigger: heroRef.current,
start: "top top",
end: "40% top",
scrub: 1.2,
},
"<"
)
.to(
".hero-cta",
{
y: -60,
opacity: 0,
});
// Hero visual scale + parallax
gsap.to(".hero-visual", {
scale: 1.25,
y: 80,
ease: "power2.inOut",
scrollTrigger: {
trigger: heroRef.current,
start: "top top",
end: "50% top",
scrub: 1.5,
},
"<+0.1"
)
.to(
".hero-visual",
});
// Staggered entrance for content elements
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
contentElements.forEach((el, i) => {
gsap.fromTo(
el,
{ opacity: 0, y: 50, scale: 0.95 },
{
scale: 1.3,
y: 100,
ease: "power2.inOut",
opacity: 1,
y: 0,
scale: 1,
duration: 1.2,
ease: "power3.out",
delay: i * 0.15,
scrollTrigger: {
trigger: el,
start: "top 90%",
toggleActions: "play none none reverse",
},
"<"
)
.to(
".hero-badge",
{
y: -40,
opacity: 0,
ease: "power2.inOut",
},
"<"
}
);
});
// Parallax floating elements
const floatElements = gsap.utils.toArray<Element>(".parallax-float");
floatElements.forEach((el, i) => {
gsap.to(el, {
y: -150 - i * 30,
y: -100 - i * 20,
scrollTrigger: {
trigger: el,
start: "top bottom",
@@ -136,9 +104,20 @@ export default function HeroSection() {
});
});
// Staggered text reveal on scroll
const revealTextElements = gsap.utils.toArray<Element>(".reveal-text");
revealTextElements.forEach((el) => {
// Scroll indicator fade on scroll
gsap.to(".scroll-indicator", {
y: 20,
opacity: 0,
scrollTrigger: {
trigger: heroRef.current,
start: "10% top",
end: "30% top",
scrub: 1,
},
});
// Staggered text reveal on scroll for other sections
gsap.utils.toArray<Element>(".reveal-text").forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, y: 60, skewY: 3 },
@@ -159,8 +138,7 @@ export default function HeroSection() {
});
// Scale reveal for cards
const revealScaleElements = gsap.utils.toArray<Element>(".reveal-scale");
revealScaleElements.forEach((el, i) => {
gsap.utils.toArray<Element>(".reveal-scale").forEach((el, i) => {
gsap.fromTo(
el,
{ opacity: 0, scale: 0.85, y: 50 },
@@ -180,29 +158,8 @@ export default function HeroSection() {
);
});
// Horizontal reveal for feature cards
const revealHorizontalElements = gsap.utils.toArray<Element>(".reveal-horizontal");
revealHorizontalElements.forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, x: -60 },
{
opacity: 1,
x: 0,
duration: 1,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none reverse",
},
}
);
});
// Counter animations
const counterElements = gsap.utils.toArray<Element>(".counter-animate");
counterElements.forEach((el) => {
gsap.utils.toArray<Element>(".counter-animate").forEach((el) => {
const target = parseInt(el.getAttribute("data-target") || "0", 10);
const obj = { val: 0 };
gsap.to(obj, {
@@ -235,15 +192,19 @@ export default function HeroSection() {
return (
<>
{/* ─── SCROLL PROGRESS INDICATOR ────────────────────────────────────────── */}
<div className="scroll-progress-container">
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-white/10">
<div
className="scroll-progress-bar"
style={{ transform: `scaleX(${scrollProgress.progress})` }}
className="h-full bg-gradient-to-r from-amber-500 via-amber-400 to-sage-500"
style={{
transform: `scaleX(${scrollProgress})`,
transformOrigin: "left",
transition: "transform 0.1s ease-out",
}}
/>
</div>
{/* ─── HERO SECTION - PINNED SCROLL SEQUENCE ───────────────────────────── */}
{/* ─── HERO SECTION - SCROLL-DRIVEN PIN ───────────────────────────── */}
<section
ref={heroRef}
className="hero-section relative min-h-screen flex items-center overflow-hidden"
@@ -279,30 +240,27 @@ export default function HeroSection() {
</div>
{/* ─── HERO CONTENT ────────────────────────────────────────────────────── */}
<div className="container mx-auto px-6 lg:px-16 py-32 relative z-10">
<div
ref={contentRef}
className="container mx-auto px-6 lg:px-16 py-32 relative z-10"
>
<div className="grid lg:grid-cols-2 gap-16 items-center min-h-[80vh]">
{/* Left Column - Animated Content */}
<div className="space-y-10 hero-content">
<div className="space-y-10">
{/* Animated Badge */}
<div
className="hero-badge inline-flex items-center gap-3 px-6 py-3 rounded-full border"
style={{
<div className="hero-reveal hero-badge inline-flex items-center gap-3 px-6 py-3 rounded-full border" style={{
borderColor: "#c97a3e",
background: "rgba(201, 122, 62, 0.08)",
}}
>
<span
className="w-3 h-3 rounded-full animate-pulse-glow"
style={{ backgroundColor: "#c97a3e" }}
/>
}}>
<span className="w-3 h-3 rounded-full animate-pulse-glow" style={{ backgroundColor: "#c97a3e" }} />
<span className="text-sm font-semibold tracking-wide" style={{ color: "#c97a3e" }}>
Farm-Fresh Delivery Platform
</span>
</div>
{/* Main Headline - Apple-style reveal */}
<div className="hero-title space-y-2">
<div className="hero-reveal hero-title space-y-2">
<h1
className="text-6xl sm:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
style={{
@@ -317,7 +275,7 @@ export default function HeroSection() {
{/* Subtext */}
<p
className="hero-subtitle text-xl sm:text-2xl max-w-xl leading-relaxed"
className="hero-reveal hero-subtitle text-xl sm:text-2xl max-w-xl leading-relaxed"
style={{
fontFamily: "'DM Sans', sans-serif",
color: "#4a6d56",
@@ -329,7 +287,7 @@ export default function HeroSection() {
</p>
{/* CTAs */}
<div className="hero-cta flex flex-wrap gap-5">
<div className="hero-reveal hero-cta flex flex-wrap gap-5">
<Link
href="/login"
className="group relative inline-flex items-center gap-3 px-8 py-5 rounded-2xl font-semibold text-lg overflow-hidden"
@@ -379,7 +337,7 @@ export default function HeroSection() {
</div>
{/* Stats */}
<div className="hero-stats flex flex-wrap gap-8 pt-8">
<div className="hero-reveal hero-stats flex flex-wrap gap-8 pt-8">
{[
{ stat: "500+", label: "Farm Brands" },
{ stat: "98%", label: "On-Time" },
@@ -411,7 +369,6 @@ export default function HeroSection() {
className="w-full h-full"
style={{ maxHeight: "700px", filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.08))" }}
>
{/* Background with grain */}
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(107, 143, 113, 0.15)" strokeWidth="1" />
@@ -463,7 +420,7 @@ export default function HeroSection() {
/>
{/* Farm Location - Animated Pulse */}
<g className="parallax-float" style={{ animationDelay: "0s" }}>
<g className="parallax-float">
<circle cx="80" cy="400" r="35" fill="rgba(26, 77, 46, 0.1)" className="animate-ping" style={{ animationDuration: "3s" }} />
<circle cx="80" cy="400" r="28" fill="rgba(26, 77, 46, 0.15)" />
<circle cx="80" cy="400" r="20" fill="#1a4d2e" />
@@ -471,7 +428,7 @@ export default function HeroSection() {
</g>
{/* Hub Location */}
<g className="parallax-float" style={{ animationDelay: "0.5s" }}>
<g className="parallax-float">
<circle cx="260" cy="180" r="40" fill="rgba(201, 122, 62, 0.1)" className="animate-ping" style={{ animationDuration: "2.5s" }} />
<circle cx="260" cy="180" r="32" fill="rgba(201, 122, 62, 0.15)" />
<circle cx="260" cy="180" r="22" fill="#c97a3e" />
@@ -480,11 +437,11 @@ export default function HeroSection() {
{/* Stop Points */}
{[
{ x: 180, y: 280, label: "Stop 1", delay: "1.2s" },
{ x: 360, y: 120, label: "Stop 2", delay: "1.4s" },
{ x: 420, y: 90, label: "Market", delay: "1.6s" },
{ x: 180, y: 280 },
{ x: 360, y: 120 },
{ x: 420, y: 90 },
].map((stop, i) => (
<g key={i} className="parallax-float" style={{ animationDelay: stop.delay }}>
<g key={i} className="parallax-float">
<circle cx={stop.x} cy={stop.y} r="18" fill="rgba(250, 248, 245, 0.9)" stroke="#6b8f71" strokeWidth="2" />
<circle cx={stop.x} cy={stop.y} r="10" fill="#6b8f71" />
</g>
@@ -499,7 +456,6 @@ export default function HeroSection() {
r="10"
fill="rgba(107, 143, 113, 0.3)"
className="parallax-float"
style={{ animationDelay: `${i * 0.3}s` }}
/>
))}
@@ -513,10 +469,7 @@ export default function HeroSection() {
</svg>
{/* Floating Cards */}
<div
className="absolute top-12 -left-8 parallax-float"
style={{ animation: "float 8s ease-in-out infinite" }}
>
<div className="absolute top-12 -left-8 parallax-float" style={{ animation: "float 8s ease-in-out infinite" }}>
<div
className="rounded-2xl p-5 shadow-2xl border"
style={{
@@ -530,10 +483,7 @@ export default function HeroSection() {
</div>
</div>
<div
className="absolute bottom-24 -right-4 parallax-float"
style={{ animation: "float-delayed 10s ease-in-out infinite" }}
>
<div className="absolute bottom-24 -right-4 parallax-float" style={{ animation: "float-delayed 10s ease-in-out infinite" }}>
<div
className="rounded-2xl p-5 shadow-2xl border"
style={{
@@ -547,10 +497,7 @@ export default function HeroSection() {
</div>
</div>
<div
className="absolute top-1/2 -right-12 parallax-float"
style={{ animation: "float 9s ease-in-out infinite 1s" }}
>
<div className="absolute top-1/2 -right-12 parallax-float" style={{ animation: "float 9s ease-in-out infinite 1s" }}>
<div
className="rounded-2xl p-4 shadow-xl border flex items-center gap-3"
style={{
@@ -569,13 +516,10 @@ export default function HeroSection() {
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
<div
className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer"
className="scroll-indicator absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer"
onClick={scrollToContent}
>
<span
className="text-xs font-semibold tracking-[0.2em] uppercase"
style={{ color: "#6b8f71" }}
>
<span className="text-xs font-semibold tracking-[0.2em] uppercase" style={{ color: "#6b8f71" }}>
Scroll to explore
</span>
<div className="relative w-8 h-14 rounded-full border-2" style={{ borderColor: "#6b8f71" }}>
@@ -590,48 +534,45 @@ export default function HeroSection() {
</div>
{/* Decorative leaves */}
<svg className="absolute bottom-32 left-16 opacity-15 parallax-float" width="80" height="80" viewBox="0 0 60 60" fill="none">
<div className="parallax-float absolute bottom-32 left-16 opacity-15" style={{ animationDelay: "0.5s" }}>
<svg className="w-20 h-20" viewBox="0 0 60 60" fill="none">
<path d="M30 5 C45 15, 50 35, 30 55 C10 35, 15 15, 30 5" fill="#6b8f71" />
<path d="M30 5 L30 55" stroke="#1a4d2e" strokeWidth="2" />
</svg>
<svg className="absolute top-40 right-24 opacity-12 parallax-float" width="60" height="60" viewBox="0 0 40 40" fill="none">
</div>
<div className="parallax-float absolute top-40 right-24 opacity-12" style={{ animationDelay: "1s" }}>
<svg className="w-16 h-16" viewBox="0 0 40 40" fill="none">
<path d="M20 3 C30 10, 33 23, 20 37 C7 23, 10 10, 20 3" fill="#c97a3e" />
<path d="M20 3 L20 37" stroke="#1a4d2e" strokeWidth="1.5" />
</svg>
</div>
</section>
{/* ─── STORY SECTION - STICKY SCROLL NARRATIVE ──────────────────────────── */}
{/* ─── STORY SECTION - SCROLL-DRIVEN REVEAL ──────────────────────────── */}
<section id="story" className="relative" style={{ background: "#1a1a1a" }}>
<div className="sticky-wrapper" style={{ minHeight: "400vh" }}>
<div className="relative" style={{ minHeight: "300vh" }}>
{/* Sticky Container */}
<div
className="sticky-content relative flex items-center justify-center overflow-hidden"
style={{
position: "sticky",
top: 0,
height: "100vh",
}}
className="sticky top-0 h-screen flex items-center justify-center overflow-hidden"
>
{/* Background gradient that shifts */}
<div className="absolute inset-0 transition-opacity duration-700"
style={{
{/* Background gradient */}
<div className="absolute inset-0" style={{
background: "radial-gradient(ellipse 80% 60% at 50% 50%, rgba(26, 77, 46, 0.15) 0%, #1a1a1a 70%)",
}} />
<div className="container mx-auto px-8 text-center relative z-10">
{/* Story Step 1 */}
<div className="story-step step-1">
<div className="container mx-auto px-8 text-center relative z-10 max-w-4xl">
<reveal-scale className="mb-8">
<span
className="reveal-text inline-block text-xs font-bold tracking-[0.3em] uppercase mb-8"
style={{
color: "#c97a3e",
fontFamily: "'DM Sans', sans-serif",
}}
className="inline-block text-xs font-bold tracking-[0.3em] uppercase"
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
>
The Challenge
</span>
</FadeOnScroll>
<reveal-scale delay={0.1}>
<h2
className="reveal-text text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-8"
style={{
fontFamily: "'Playfair Display', serif",
color: "#faf8f5",
@@ -641,41 +582,9 @@ export default function HeroSection() {
<br />
<span style={{ color: "#c97a3e" }}>sit in transit for days</span>
</h2>
<p
className="reveal-text text-xl max-w-2xl mx-auto"
style={{
color: "#86868b",
fontFamily: "'DM Sans', sans-serif",
lineHeight: 1.7,
}}
>
Traditional distribution means your harvest loses freshness,
quality, and value before reaching customers.
</p>
</div>
</FadeOnScroll>
{/* Story Step 2 */}
<div className="story-step step-2 opacity-0">
<span
className="inline-block text-xs font-bold tracking-[0.3em] uppercase mb-8"
style={{
color: "#6b8f71",
fontFamily: "'DM Sans', sans-serif",
}}
>
The Solution
</span>
<h2
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
style={{
fontFamily: "'Playfair Display', serif",
color: "#faf8f5",
}}
>
Intelligent route
<br />
<span style={{ color: "#6b8f71" }}>optimization</span>
</h2>
<reveal-scale delay={0.2}>
<p
className="text-xl max-w-2xl mx-auto"
style={{
@@ -684,45 +593,9 @@ export default function HeroSection() {
lineHeight: 1.7,
}}
>
Our platform analyzes patterns, traffic, and delivery windows
to get your produce from field to market in hours, not days.
Traditional distribution means your harvest loses freshness, quality, and value before reaching customers.
</p>
</div>
{/* Story Step 3 */}
<div className="story-step step-3 opacity-0">
<span
className="inline-block text-xs font-bold tracking-[0.3em] uppercase mb-8"
style={{
color: "#22c55e",
fontFamily: "'DM Sans', sans-serif",
}}
>
The Result
</span>
<h2
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
style={{
fontFamily: "'Playfair Display', serif",
color: "#faf8f5",
}}
>
40% faster delivery
<br />
<span style={{ color: "#22c55e" }}>98% on-time rate</span>
</h2>
<p
className="text-xl max-w-2xl mx-auto"
style={{
color: "#86868b",
fontFamily: "'DM Sans', sans-serif",
lineHeight: 1.7,
}}
>
Farms using Route Commerce see dramatic improvements in
freshness, customer satisfaction, and operational efficiency.
</p>
</div>
</FadeOnScroll>
</div>
</div>
</div>
@@ -733,20 +606,27 @@ export default function HeroSection() {
className="relative py-32 overflow-hidden"
style={{ background: "linear-gradient(180deg, #faf8f5 0%, #fef7f0 100%)" }}
>
<div className="container mx-auto px-8">
{/* Parallax background elements */}
<div className="parallax-bg absolute inset-0 pointer-events-none">
<div className="parallax-float absolute top-0 right-0 w-[50%] h-full opacity-10" style={{ animationDelay: "0.3s" }}>
<div className="absolute top-20 right-20 w-80 h-80 rounded-full" style={{
background: "radial-gradient(circle, rgba(26, 77, 46, 0.3) 0%, transparent 70%)",
filter: "blur(60px)",
}} />
</div>
</div>
<div className="container mx-auto px-8 relative z-10">
{/* Section Header */}
<div className="text-center mb-20">
<div className="reveal-scale text-center mb-20">
<span
className="reveal-text inline-block text-xs font-bold tracking-[0.2em] uppercase mb-6"
style={{
color: "#6b8f71",
fontFamily: "'DM Sans', sans-serif",
}}
className="inline-block text-xs font-bold tracking-[0.2em] uppercase mb-6"
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
>
Platform Features
</span>
<h2
className="reveal-text text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight"
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight"
style={{
fontFamily: "'Playfair Display', serif",
color: "#1a4d2e",
@@ -901,12 +781,10 @@ export default function HeroSection() {
{/* ─── STATS SECTION - SCROLL-TRIGGERED COUNTERS ──────────────────────── */}
<section
className="relative py-32 overflow-hidden"
style={{
background: "#1a4d2e",
}}
style={{ background: "#1a4d2e" }}
>
{/* Decorative elements */}
<div className="absolute inset-0 opacity-[0.03]" style={{
{/* Parallax decorative elements */}
<div className="parallax-bg absolute inset-0 pointer-events-none opacity-[0.03]" style={{
backgroundImage: "radial-gradient(circle at 25% 25%, white 1px, transparent 1px), radial-gradient(circle at 75% 75%, white 1px, transparent 1px)",
backgroundSize: "48px 48px",
}} />
@@ -915,17 +793,14 @@ export default function HeroSection() {
}} />
<div className="container mx-auto px-8 relative z-10">
<div className="text-center mb-16">
<reveal-scale className="text-center mb-16">
<span
className="reveal-text inline-block text-xs font-bold tracking-[0.2em] uppercase"
style={{
color: "#c97a3e",
fontFamily: "'DM Sans', sans-serif",
}}
className="inline-block text-xs font-bold tracking-[0.2em] uppercase"
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
>
Our Impact
</span>
</div>
</FadeOnScroll>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-12">
@@ -935,7 +810,7 @@ export default function HeroSection() {
{ value: 98, suffix: "%", label: "On-Time Delivery" },
{ value: 2000000, prefix: "$", suffix: "+", label: "Weekly Sales" },
].map((stat, i) => (
<div key={i} className="text-center reveal-scale" style={{ animationDelay: `${i * 0.1}s` }}>
<div key={i} className="reveal-scale text-center" style={{ animationDelay: `${i * 0.1}s` }}>
<div
className="text-5xl sm:text-6xl lg:text-7xl font-bold mb-4"
style={{
@@ -945,20 +820,12 @@ export default function HeroSection() {
}}
>
{stat.prefix && <span style={{ color: "#c97a3e" }}>{stat.prefix}</span>}
<span
className="counter-animate"
data-target={stat.value}
>
0
</span>
<span className="counter-animate" data-target={stat.value}>0</span>
<span style={{ color: "#c97a3e" }}>{stat.suffix}</span>
</div>
<p
className="text-base font-medium"
style={{
color: "#6b8f71",
fontFamily: "'DM Sans', sans-serif",
}}
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
>
{stat.label}
</p>
@@ -971,23 +838,26 @@ export default function HeroSection() {
{/* ─── CTA SECTION - FINAL REVEAL ─────────────────────────────────────── */}
<section
className="relative py-40 overflow-hidden"
style={{
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
}}
style={{ background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)" }}
>
{/* Decorative orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-10" style={{
{/* Parallax decorative orbs */}
<div className="absolute inset-0 pointer-events-none">
<div className="parallax-float absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-10" style={{
background: "radial-gradient(circle, rgba(26, 77, 46, 0.3) 0%, transparent 70%)",
filter: "blur(60px)",
animationDelay: "0.2s",
}} />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full opacity-10" style={{
<div className="parallax-float absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full opacity-10" style={{
background: "radial-gradient(circle, rgba(201, 122, 62, 0.3) 0%, transparent 70%)",
filter: "blur(60px)",
animationDelay: "0.4s",
}} />
</div>
<div className="container mx-auto px-8 relative z-10">
<div className="max-w-3xl mx-auto text-center">
{/* Label */}
<reveal-scale>
<span
className="reveal-text inline-block text-xs font-bold tracking-[0.2em] uppercase px-5 py-2 rounded-full mb-8"
style={{
@@ -998,238 +868,114 @@ export default function HeroSection() {
>
Get Started Today
</span>
</FadeOnScroll>
{/* Headline */}
<reveal-scale delay={0.1}>
<h2
className="reveal-text text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
style={{
fontFamily: "'Playfair Display', serif",
color: "#1a1a1a",
color: "#1a4d2e",
}}
>
Ready to Grow
<br />
<span style={{ color: "#1a4d2e" }}>Your Routes?</span>
Ready to Transform<br />
Your Distribution?
</h2>
</FadeOnScroll>
{/* Subtitle */}
{/* Subtext */}
<reveal-scale delay={0.2}>
<p
className="reveal-text text-xl mb-12"
className="text-xl mb-12 max-w-xl mx-auto"
style={{
color: "#555",
fontFamily: "'DM Sans', sans-serif",
lineHeight: 1.7,
}}
>
Join hundreds of produce brands who trust Route Commerce
to power their farm-fresh operations.
Join hundreds of farms already using Route Commerce to deliver fresher produce faster.
</p>
</FadeOnScroll>
{/* CTA Buttons */}
<div className="flex flex-wrap justify-center gap-6">
{/* CTAs */}
<reveal-scale delay={0.3}>
<div className="flex flex-wrap items-center justify-center gap-5">
<Link
href="/admin"
className="group inline-flex items-center gap-3 px-10 py-5 rounded-2xl text-lg font-bold transition-all duration-300"
href="/login"
className="group relative inline-flex items-center gap-3 px-10 py-6 rounded-2xl font-semibold text-xl overflow-hidden"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
color: "white",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-3px)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(26, 77, 46, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(26, 77, 46, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15)";
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.35)",
}}
>
<span>Start Free Trial</span>
<svg className="w-5 h-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="w-6 h-6 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
<Link
href="/brands"
className="group inline-flex items-center gap-3 px-10 py-5 rounded-2xl text-lg font-bold border-2 transition-all duration-300"
href="/contact"
className="group inline-flex items-center gap-3 px-10 py-6 rounded-2xl font-semibold text-xl border-2"
style={{
color: "#1a4d2e",
borderColor: "#1a4d2e",
background: "rgba(26, 77, 46, 0.03)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#1a4d2e";
e.currentTarget.style.color = "white";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(26, 77, 46, 0.03)";
e.currentTarget.style.color = "#1a4d2e";
}}
>
<span>View Farms</span>
<span>Contact Sales</span>
</Link>
</div>
{/* Disclaimer */}
<p
className="mt-8 text-sm"
style={{
color: "#888",
fontFamily: "'DM Sans', sans-serif",
}}
>
No credit card required · 14-day free trial · Cancel anytime
</p>
</FadeOnScroll>
</div>
</div>
</section>
{/* ─── GLOBAL STYLES ────────────────────────────────────────────────────── */}
<style jsx>{`
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&display=swap');
@keyframes float {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(15px, -20px); }
66% { transform: translate(-10px, 10px); }
}
@keyframes float-delayed {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(-20px, 15px); }
66% { transform: translate(10px, -10px); }
}
@keyframes draw-route {
to { stroke-dashoffset: 0; }
}
@keyframes scroll-indicator {
0%, 100% { transform: translateY(0); opacity: 1; }
50% { transform: translateY(6px); opacity: 0.5; }
}
.text-gradient-sage {
background: linear-gradient(135deg, #1a4d2e 0%, #6b8f71 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.parallax-float {
will-change: transform;
}
/* Scroll Progress Bar */
.scroll-progress-container {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 1000;
background: rgba(26, 77, 46, 0.1);
}
.scroll-progress-bar {
height: 100%;
background: linear-gradient(90deg, #1a4d2e, #c97a3e, #6b8f71);
transform-origin: left;
transition: transform 0.1s ease-out;
}
/* Text Gradient */
.text-gradient-sage {
background: linear-gradient(135deg, #1a4d2e 0%, #6b8f71 50%, #1a4d2e 100%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 6s ease-in-out infinite;
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% center; }
50% { background-position: 100% center; }
}
/* Float animations */
@keyframes float-slow {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(20px, -30px) scale(1.05); }
66% { transform: translate(-10px, 10px) scale(0.95); }
}
@keyframes float-slow-delayed {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-25px, 20px) scale(1.03); }
66% { transform: translate(15px, -15px) scale(0.97); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
}
@keyframes float-delayed {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
/* Ping animation */
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
.animate-ping {
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* Pulse glow */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(201, 122, 62, 0.4); }
50% { box-shadow: 0 0 20px 5px rgba(201, 122, 62, 0.3); }
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
/* Route draw animation */
@keyframes draw-route {
to { stroke-dashoffset: 0; }
}
/* Scroll indicator */
@keyframes scroll-indicator {
0%, 100% {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
50% {
transform: translateX(-50%) translateY(12px);
opacity: 0.5;
}
}
/* Story section transitions */
.story-step {
position: absolute;
width: 100%;
opacity: 0;
transition: opacity 0.8s ease-out;
}
.story-step.active {
opacity: 1;
}
/* Smooth scroll behavior */
html {
scroll-behavior: smooth;
}
/* Responsive typography scale */
@media (max-width: 768px) {
.text-6xl { font-size: 3rem !important; }
.text-7xl { font-size: 3.5rem !important; }
.text-8xl { font-size: 4rem !important; }
.text-9xl { font-size: 4.5rem !important; }
}
/* Performance optimizations */
.parallax-float {
will-change: transform;
}
.reveal-text {
will-change: opacity, transform;
}
/* Sticky section styles */
.sticky-wrapper {
position: relative;
}
.sticky-content {
position: sticky;
top: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
height: 3px;
background: linear-gradient(to right, #c97a3e, #6b8f71);
}
`}</style>
</>
+17 -6
View File
@@ -69,6 +69,7 @@ interface ScrollRevealProps {
className?: string;
stagger?: boolean;
direction?: "up" | "down" | "left" | "right" | "scale";
from?: "up" | "down" | "left" | "right" | "scale";
delay?: number;
}
@@ -77,9 +78,11 @@ export function ScrollReveal({
className = "",
stagger = false,
direction = "up",
from,
delay = 0,
}: ScrollRevealProps) {
const containerRef = useRef<HTMLDivElement>(null);
const effectiveDirection = from ?? direction;
useEffect(() => {
if (typeof window === "undefined" || !containerRef.current) return;
@@ -111,7 +114,7 @@ export function ScrollReveal({
// Staggered reveal
const animProps: gsap.TweenVars = { opacity: 0 };
switch (direction) {
switch (effectiveDirection) {
case "up":
animProps.y = 60;
break;
@@ -150,7 +153,7 @@ export function ScrollReveal({
}, containerRef);
return () => ctx.revert();
}, [direction, stagger, delay]);
}, [effectiveDirection, stagger, delay]);
return (
<div ref={containerRef} className={className}>
@@ -166,12 +169,14 @@ interface ParallaxLayerProps {
children: React.ReactNode;
speed?: number; // 0-1, higher = more movement
className?: string;
style?: React.CSSProperties;
}
export function ParallaxLayer({
children,
speed = 0.5,
className = "",
style,
}: ParallaxLayerProps) {
const layerRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
@@ -202,11 +207,11 @@ export function ParallaxLayer({
}, [isClient, speed]);
if (!isClient) {
return <div className={className}>{children}</div>;
return <div className={className} style={style}>{children}</div>;
}
return (
<div ref={layerRef} className={className}>
<div ref={layerRef} className={className} style={style}>
{children}
</div>
);
@@ -271,17 +276,21 @@ export function ProgressIndicator({
interface FadeOnScrollProps {
children: React.ReactNode;
className?: string;
from?: "top" | "bottom" | "left" | "right";
from?: "top" | "bottom" | "left" | "right" | "up" | "down";
to?: "top" | "bottom" | "left" | "right";
distance?: number;
duration?: number;
delay?: number;
}
export function FadeOnScroll({
children,
className = "",
from = "bottom",
to,
distance = 100,
duration = 1,
delay = 0,
}: FadeOnScrollProps) {
const elementRef = useRef<HTMLDivElement>(null);
@@ -293,6 +302,7 @@ export function FadeOnScroll({
let fromY = 0;
let fromX = 0;
// Use 'from' direction for the starting position
switch (from) {
case "top":
fromY = distance;
@@ -320,6 +330,7 @@ export function FadeOnScroll({
y: 0,
duration,
ease: "power3.out",
delay,
scrollTrigger: {
trigger: elementRef.current,
start: "top 85%",
@@ -330,7 +341,7 @@ export function FadeOnScroll({
}, elementRef);
return () => ctx.revert();
}, [from, distance, duration]);
}, [from, distance, duration, delay]);
return (
<div ref={elementRef} className={className}>