fix scroll smooth
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
@@ -24,6 +24,24 @@ function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||
return <span ref={ref}>{Math.round(mv.get())}</span>;
|
||||
}
|
||||
|
||||
/** True only while a card's own opacity window is open (with a tiny buffer).
|
||||
* Lets us keep future/past story cards out of the DOM — and off the compositor
|
||||
* (each has `will-change`) — until their beat is actually on screen, so no
|
||||
* workflow state is rendered before activation. Visually identical, since a
|
||||
* card outside its window is opacity:0 anyway. */
|
||||
function useInWindow(mv: MotionValue<number>, threshold = 0.01): boolean {
|
||||
// `mv` is an external mutable store (a MotionValue). useTransform `.set()`s its
|
||||
// output synchronously while the PARENT renders, so a plain `.on("change") -> setState`
|
||||
// updates this component during the parent's render (React warns). useSyncExternalStore
|
||||
// is built for exactly this: it reads a snapshot and reconciles store-changes-during-
|
||||
// render safely. The snapshot is a primitive boolean, so it never re-renders needlessly.
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => mv.on("change", onStoreChange),
|
||||
() => mv.get() > threshold,
|
||||
() => mv.get() > threshold,
|
||||
);
|
||||
}
|
||||
|
||||
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||
function stepFromProgress(p: number): number {
|
||||
let s = -1;
|
||||
@@ -67,6 +85,8 @@ function StoryCard({
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
// Don't mount this beat's card until its cross-fade window opens.
|
||||
if (!useInWindow(opacity)) return null;
|
||||
return (
|
||||
<motion.div className="dm-lb-card-story" style={{ opacity, y }}>
|
||||
<div className="dm-lb-card-story__head">
|
||||
@@ -139,7 +159,9 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.5,
|
||||
// Match Workflow 1's responsiveness (0.4) so the camera + overlay track the
|
||||
// scroll with the same snappy feel — 0.5 made this section lag noticeably.
|
||||
scrub: 0.4,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
@@ -151,7 +173,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
@@ -185,9 +207,14 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
<div className="dm-lb-card">
|
||||
{mountScene && (
|
||||
<div className="dm-lb-canvas">
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay is mounted only once the section is pinned/activated, so its
|
||||
content (intro hint, header, story cards) can never be seen during the
|
||||
approach ("before"), where the sticky sits at the top of the tall
|
||||
section just off the previous workflow's seam. */}
|
||||
{pinState !== "before" && (
|
||||
<div className="dm-lb-ui">
|
||||
{/* Persistent header: what this is + where we are in the workflow */}
|
||||
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
|
||||
@@ -281,6 +308,7 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{styles}</style>
|
||||
@@ -289,8 +317,13 @@ export default function LogisticsBrainSection({ connected = false }: { connected
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.dm-lb { position: relative; height: 640vh; background: transparent; }
|
||||
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
|
||||
/* Scroll length tuned for pacing: ~77vh per engine step (was 107vh) so the 6
|
||||
beats complete in noticeably less scrolling — closer to Workflow 1's cadence
|
||||
and with far less perceived empty space between workflows. Beat windows are
|
||||
progress-based (0…1), so they stay correctly aligned at any height. */
|
||||
.dm-lb { position: relative; height: 460vh; background: transparent; }
|
||||
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
|
||||
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
|
||||
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
@@ -310,6 +343,9 @@ const styles = `
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
top: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
/* Flush against the Innovation card below — drop the heavy downward shadow so it
|
||||
doesn't cast a dark band onto that card's top edge (the two read as one container). */
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
@@ -331,23 +367,23 @@ const styles = `
|
||||
.dm-lb-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; overflow: visible; }
|
||||
.dm-lb-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35; letter-spacing: 0.28em; text-transform: uppercase;
|
||||
color: #F2667A; padding: 9px 18px; border-radius: 999px; background: rgba(192,18,39,0.10);
|
||||
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 13px; line-height: 1.35; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase;
|
||||
color: #ffffff; padding: 9px 20px; border-radius: 999px; background: rgba(192,18,39,0.16);
|
||||
border: 1px solid rgba(226,53,66,0.45); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
|
||||
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
|
||||
|
||||
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
|
||||
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
|
||||
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: nowrap; max-width: min(1160px, 96vw); }
|
||||
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; flex-shrink: 0;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
|
||||
.dm-lb-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 800; color: rgba(234,242,255,0.6); background: rgba(255,255,255,0.08); }
|
||||
.dm-lb-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; color: rgba(234,242,255,0.55); white-space: nowrap; }
|
||||
.dm-lb-rail__num { width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.12); }
|
||||
.dm-lb-rail__title { font-size: clamp(12.5px, 1.05vw, 14px); font-weight: 700; letter-spacing: 0.04em; color: rgba(255,255,255,0.95); white-space: nowrap; }
|
||||
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(234,242,255,0.78); }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(255,255,255,0.92); }
|
||||
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
|
||||
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
|
||||
|
||||
@@ -439,7 +475,7 @@ const styles = `
|
||||
.dm-lb-rail__line { width: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb { height: 540vh; }
|
||||
.dm-lb { height: 400vh; }
|
||||
.dm-lb-kpis { gap: 12px; }
|
||||
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
|
||||
.dm-lb-card-story { left: 0; right: 0; margin: 0 auto; width: calc(100% - 28px); bottom: clamp(20px, 5vh, 44px); padding: 14px 16px; }
|
||||
|
||||
Reference in New Issue
Block a user