6 min read

GSAP xPercent vs x: Centring, Responsive Moves and Horizontal Scroll

`x` moves an element by a number of pixels. `xPercent` moves it by a percentage of its own width. That one difference is why most responsive GSAP bugs I get asked to fix come down to someone using `x` where they meant `xPercent`.

They stack instead of replacing each other

GSAP keeps percentage and pixel translation as two separate values and writes both into the transform. You can set `xPercent: -50` once to centre an element and then animate `x` freely, and the centring holds. With a hand-written CSS transform, a new value would overwrite the old one.

// Centre on a point, regardless of the element's size
gsap.set(".badge", {
  position: "absolute",
  left: "50%",
  top: "50%",
  xPercent: -50,
  yPercent: -50,
});

// Later: nudge it without losing the centring
gsap.to(".badge", { x: 40, duration: 0.6, ease: "power3.out" });

Responsive slides without recalculating

A slider that moves by `x: -slideWidth` needs that width recalculated on every resize, and it breaks the moment the layout changes. Moving by `xPercent: -100 * index` needs nothing recalculated, because the percentage is always relative to the slide's current width.

function goTo(index) {
  gsap.to(".slide", {
    xPercent: -100 * index,
    duration: 0.8,
    ease: "power2.inOut",
  });
}

Horizontal scroll sections

This is the pattern most people are searching for. Panels sit in a row, the section pins, and vertical scrolling moves the row sideways. Because each panel is 100% wide, `xPercent` moves the whole track by exactly the right number of panels:

const panels = gsap.utils.toArray(".panel");

gsap.to(panels, {
  xPercent: -100 * (panels.length - 1),
  ease: "none", // scrub maps scroll to progress; easing here feels laggy
  scrollTrigger: {
    trigger: ".track",
    pin: true,
    scrub: 1,
    snap: 1 / (panels.length - 1),
    end: () => "+=" + document.querySelector(".track").offsetWidth,
    invalidateOnRefresh: true,
  },
});

Two details matter here. `ease: "none"` keeps the scroll-to-movement mapping linear, and `scrub: 1` smooths it. Making `end` a function together with `invalidateOnRefresh` means the pin distance is measured again when the viewport changes. Without that, the section ends too early or too late after a resize.

When x is still the right choice

  • Following the pointer or a drag. Input arrives in pixels, so use pixels.
  • Small fixed nudges such as a 4px hover shift, which should not grow on a wider element.
  • Physics-style motion where the distance comes from a calculation, not from the layout.

In short, use `x` when the distance is fixed and `xPercent` when the distance depends on the element's size. Both only change transform, so neither causes layout work, and both are cheap to animate.

Related

Got a project with these problems?

Available for remote work worldwide.

Start a conversation →