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";
@@ -12,6 +12,23 @@ const StrategyCanvas = dynamic(() => import("./StrategyCanvas"), { ssr: false })
/** Center of each stage's scroll window (0…1). */
const CENTER = (i: number) => i / (N - 1);
/** True only while a card's own opacity window is open (tiny buffer). Keeps
* not-yet-reached stage cards out of the DOM / off the compositor until their
* stage is on screen — no future workflow state is rendered before activation.
* Visually identical: a card outside its window is opacity:0 regardless. */
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,
);
}
/** Persistent top rail: the 5 stages, current one highlighted. */
function StageRail({ active }: { active: number }) {
return (
@@ -45,9 +62,13 @@ function StageCard({
children: React.ReactNode;
}) {
const c = CENTER(i);
const opacity = useTransform(scroll, [c - 0.14, c - 0.06, c + 0.06, c + 0.14], [0, 1, 1, 0]);
const y = useTransform(scroll, [c - 0.14, c - 0.05], [34, 0]);
// Window kept < the 0.25 per-stage span (5 stages) so two adjacent stage cards
// never overlap — one stage's card is fully out before the next fades in.
const opacity = useTransform(scroll, [c - 0.1, c - 0.05, c + 0.05, c + 0.1], [0, 1, 1, 0]);
const y = useTransform(scroll, [c - 0.1, c - 0.05], [34, 0]);
const s = STAGES[i];
// Only mount the card while its stage's cross-fade window is open.
if (!useInWindow(opacity)) return null;
return (
<motion.div
className={`dm-st-card-story is-${side}`}
@@ -123,7 +144,9 @@ export default function StrategySection({ connected = false }: { connected?: boo
trigger: el,
start: "top top",
end: "bottom bottom",
scrub: 0.5,
// Match Workflow 1's responsiveness (0.4) for consistent pacing across all
// three workflows — 0.5 made the dolly feel slower / disconnected here.
scrub: 0.4,
invalidateOnRefresh: true,
onUpdate: (self) => {
const p = self.progress;
@@ -135,7 +158,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
if (na !== lastActive) { lastActive = na; setActive(na); }
},
});
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
return () => { clearTimeout(refresh); st.kill(); };
}, [scroll]);
@@ -154,10 +177,14 @@ export default function StrategySection({ connected = false }: { connected?: boo
<div className="dm-st-card">
{mountScene && (
<div className="dm-st-canvas">
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} stage={active} />
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} stage={active} />
</div>
)}
{/* Overlay mounts only once the section is pinned/activated — its content
can never be seen during the approach ("before"), where the sticky sits
at the top of the tall section near the previous workflow's seam. */}
{pinState !== "before" && (
<div className="dm-st-ui">
{/* Persistent header */}
<motion.div className="dm-st-top" style={{ opacity: headerOpacity }}>
@@ -229,6 +256,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
</div>
</StageCard>
</div>
)}
</div>
</div>
<style>{styles}</style>
@@ -237,8 +265,12 @@ export default function StrategySection({ connected = false }: { connected?: boo
}
const styles = `
.dm-st { position: relative; height: 720vh; background: transparent; }
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
/* Scroll length tuned for pacing: ~100vh per stage (was 144vh) so the 5 stages
complete in noticeably less scrolling and the workflow feels tighter / faster.
Stage cross-fade windows are progress-based (0…1), so they stay aligned. */
.dm-st { position: relative; height: 500vh; background: transparent; }
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
.dm-st.is-pinned .dm-st-sticky { position: fixed; top: 0; left: 0; }
.dm-st.is-after .dm-st-sticky { position: absolute; top: auto; bottom: 0; }
@@ -257,6 +289,9 @@ const styles = `
.dm-st.is-connected .dm-st-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 Strategy 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-st.is-connected .dm-st-card {
@@ -397,7 +432,7 @@ const styles = `
.dm-st-rail__line { width: 9px; }
}
@media (max-width: 767px) {
.dm-st { height: 640vh; }
.dm-st { height: 420vh; }
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
}