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.