fix scroll smooth

This commit is contained in:
2026-06-04 14:51:13 +05:30
parent 123092f4b8
commit b2d64bd335
15 changed files with 331 additions and 167 deletions

View File

@@ -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; }