In my link-in-bio site, each page has a configured transition direction (LTR or RTL) and travel distance.
Expected behavior:
- LTR transitions should respect the configured travel distance from the left edge.
- RTL transitions should respect the configured travel distance from the right edge.
- Each page should animate independently without unwanted positioning changes.
Actual behavior:
- LTR ignores the configured travel distance.
- RTL shifts toward the center instead of respecting the configured distance from the right edge.
Question: What could cause these inconsistent GSAP transforms, and how can I fix the positioning so both directions respect their configured travel distances?
The affected implementation is in "profile-view.tsx".
I've attached both full script and a reference video to the problem at the very bottom of this post.
Relevant code snippet #1:
useEffect(() => {
if (!pages.length || sealed) return;
const nodes = pageCardRefs.current.slice(0, pages.length);
const contentNodes = pageContentRefs.current.slice(0, pages.length);
const scrollRoot = preview
? pagesRef.current?.closest<HTMLElement>(".fx-root") ?? null
: null;
if (preview && !scrollRoot) return;
gsap.registerPlugin(ScrollTrigger);
const tweens: gsap.core.Tween[] = [];
const triggers: ScrollTrigger[] = [];
nodes.forEach((node, index) => {
if (!node) return;
const content = contentNodes[index];
if (!content) return;
const page = pages[index];
const reveal = page.reveal ?? "fade";
const distance = Math.max(0, Math.min(160, page.movement ?? 48));
const viewportWidth = node.clientWidth;
const contentWidth = content.offsetWidth;
const travel = viewportWidth + contentWidth + distance;
const fromX =
reveal === "left"
? -travel
: reveal === "right"
? travel
: 0;
/*
* Reset everything before creating the trigger.
*/
gsap.killTweensOf(content);
gsap.set(node, {
clearProps: "transform",
autoAlpha: 1,
visibility: "visible",
});
if (reveal === "fade") {
gsap.set(content, {
x: 0,
autoAlpha: 0,
force3D: true,
});
} else {
gsap.set(content, {
x: fromX,
autoAlpha: 1,
force3D: true,
});
}
const tween = gsap.to(content, {
x: 0,
autoAlpha: 1,
duration: page.duration ?? 0.8,
ease: "power3.out",
paused: true,
overwrite: "auto",
});
tweens.push(tween);
const trigger = ScrollTrigger.create({
trigger: node,
scroller: scrollRoot ?? undefined,
start: "top 85%",
end: "bottom 15%",
onEnter: () => {
tween.restart();
},
onEnterBack: () => {
tween.restart();
},
onLeaveBack: () => {
tween.pause(0, true);
gsap.set(content, {
x: reveal === "fade" ? 0 : fromX,
autoAlpha: reveal === "fade" ? 0 : 1,
});
},
onLeave: () => {
tween.pause();
gsap.set(content, {
x: 0,
autoAlpha: 1,
});
},
});
triggers.push(trigger);
});
requestAnimationFrame(() => {
ScrollTrigger.refresh();
});
return () => {
triggers.forEach((trigger) => trigger.kill());
tweens.forEach((tween) => tween.kill());
};
}, [preview, pages, sealed]);
Relevant code snippet #2:
useEffect(() => {
if (preview || !p.portfolioLayoutScroll) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({
duration: 1.15,
lerp: 0.08,
smoothWheel: true,
wheelMultiplier: 1,
syncTouch: true,
touchMultiplier: 1.05,
prevent: (node) =>
node.closest(
"input, textarea, select, button, a, iframe, [data-lenis-prevent]"
) !== null,
autoRaf: false,
});
/*
* Keep GSAP/ScrollTrigger synchronized with Lenis.
*/
lenis.on("scroll", ScrollTrigger.update);
let rafId = 0;
const raf = (time: number) => {
lenis.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
return () => {
cancelAnimationFrame(rafId);
lenis.off("scroll", ScrollTrigger.update);
lenis.destroy();
};
}, [preview, p.portfolioLayoutScroll]);
/* ---------------- sound ---------------- */
const sfxRef = useRef<Sfx | null>(null);
const [muted, setMuted] = useState(true);
const soundOn = !preview && p.sfxPack !== "none" && p.sfxVolume > 0;
useEffect(() => {
if (!soundOn) return;
// Visitors decide, and their choice sticks per browser.
let saved: string | null = null;
try { saved = localStorage.getItem("fd_sfx"); } catch {}
setMuted(saved === "off");
}, [soundOn]);
useEffect(() => {
if (!soundOn || muted) {
sfxRef.current?.dispose();
sfxRef.current = null;
return;
}
sfxRef.current = createSfx(p.sfxPack, p.sfxVolume);
return () => {
sfxRef.current?.dispose();
sfxRef.current = null;
};
}, [soundOn, muted, p.sfxPack, p.sfxVolume]);
const playTap = useCallback(() => sfxRef.current?.play("tap"), []);
const playHover = useCallback(() => sfxRef.current?.play("hover"), []);
const toggleSound = useCallback(() => {
setMuted((m) => {
const next = !m;
try { localStorage.setItem("fd_sfx", next ? "off" : "on"); } catch {}
if (!next) createSfx(p.sfxPack, p.sfxVolume).play("enter");
return next;
});
}, [p.sfxPack, p.sfxVolume]);
/* ---------------- click effects ---------------- */
const onPointerDown = useCallback((e: React.PointerEvent) => {
playTap();
if (!p.clickText && !p.clickRipple) return;
const id = ++clickId.current;
setClicks((c) => [...c.slice(-8), { id, x: e.clientX, y: e.clientY }]);
setTimeout(() => setClicks((c) => c.filter((x) => x.id !== id)), 900);
}, [p.clickText, p.clickRipple, playTap]);
Reference Full Script:
profile-view.tsx
Reference Video To The Problem:
problem.mp4
NOTE: I leveraged the use of LLMs afterwards trying to fix the problem, please do not mind.
Top comments (1)
i really need a fix for this since but I've been busy on other projects as well and most of them got an early deadline.