DEV Community

kira
kira

Posted on Edited on

Link-in-Bio site GSAP page transition ignores configured travel distance in LTR and centers content in RTL (Next.js)

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
villeins profile image
kira • • Edited

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.