D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

GSAP Layer Lands in the Wrong Place After a `yPercent` Tween? Your CSS `translateY(102%)` Was Parsed as a Separate Pixel `y`

· · 6 min read
GSAP Layer Lands in the Wrong Place After a `yPercent` Tween? Your CSS `translateY(102%)` Was Parsed as a Separate Pixel `y`

On a client one-pager driven entirely by scroll, there was a backdrop layer sitting behind the hero. A large panel with a simple job: rise from below the fold and stop exactly covering the viewport once that section came into view. I parked it in CSS with transform: translateY(102%) so that it was already off screen before any JavaScript ran, with no flash on first paint. The number was 102 rather than 100 because I wanted a little slack for subpixel rounding, so no stray pixel or two would peek out below the fold. The rest I handed to GSAP: one fromTo on yPercent, from 100 to 0.

On screen the story went differently. The panel did move and the motion was smooth, but it started much lower than I expected, as if it had been sent off from twice the distance I asked for. The ending was worse: when the tween finished, the panel never actually settled at zero. It came to rest one element height below where it belonged and just sat there. From the audience seat it looked exactly like a layer that had gotten stuck.

The confusing part was that GSAP never complained. No warning, no error, the tween ran to completion like nothing was wrong.

Tracing it

My first guess was timing: maybe the trigger fired at the wrong point, or another tween was touching the same element. I checked both and found nothing. And there was one fact that should have killed the timing theory immediately: the final position was wrong. If this were only about when the animation ran, the resting point at the end would still have to be correct. It was the resting point that was off, so the problem lived in the values, not in the clock.

So I stopped guessing and started measuring. The fastest way to see what GSAP believes about an element is to ask it directly:

console.log(gsap.getProperty(backdrop, "yPercent"));
console.log(gsap.getProperty(backdrop, "y"));

The first one made sense; it matched the tween in flight. The second one made me stop: y came back as a pixel number roughly the height of the element itself. I had never written y anywhere. Not in the fromTo, not in a gsap.set, not in any other tween. Something had filled it in without me asking, and there was exactly one position value I had written by hand for this element: translateY(102%) in the stylesheet.

The root cause

GSAP does not keep a transform as a single string. It splits it into separate components that can each be tweened on their own: x and y in pixels, xPercent and yPercent in percent, and then it reassembles them into one transform value on every frame. The important word there is reassembles. An element's final vertical offset is not y alone or yPercent alone, it is the sum of both.

When GSAP first touches an element, it reads whatever transform is already there and parses it into those slots. And that is where the trap sits: the translateY(102%) I wrote in CSS does not land in yPercent. It is read as a y value, in pixels, as a genuinely separate component from the yPercent I was animating.

Once that clicked, two symptoms that had looked independent collapsed into one. At the start of the tween, y contributed roughly one element height and yPercent: 100 contributed another one, so the panel set off from about twice the distance I intended. At the end of the tween, yPercent was indeed zero, but y never went to zero because nothing ever touched it. The pixel leftover from CSS stayed in place, and the panel stopped one element height below the target. Not two bugs, one bug with two faces.

It failed silently because from GSAP's point of view nothing is wrong. This is not a conflict worth warning about. Two different properties, both holding legitimate values, and summing them is the correct behavior. The only thing that was wrong was my assumption that a transform written in CSS is a neutral zero for a percent tween.

The fix

The fix is small and needs no change to the CSS at all. Pin y to zero explicitly, and do it in both states of the fromTo:

// broken: the y parsed out of CSS leaks into the total transform
gsap.fromTo(
  backdrop,
  { yPercent: 100 },
  { yPercent: 0 }
);
 
// correct: y is pinned to zero in the start state and the end state
gsap.fromTo(
  backdrop,
  { yPercent: 100, y: 0 },
  { yPercent: 0, y: 0 }
);

With y: 0 in the start state, the stray pixel value is thrown out the moment the tween begins, so the panel departs from a single element height, which is what I meant all along. With y: 0 in the end state too, there is no state GSAP writes for this element that leaves y open to being filled from outside. That matters when the tween can be rebuilt, reversed, or have its start values recomputed after a layout change, because in those moments GSAP reads the element's condition again and the pixel value from CSS gets another chance to slip back in. Writing it twice costs nothing and closes that door for good.

There is another route that also solves it: do not park the element with a percent transform in CSS at all, and let GSAP own the transform from the beginning through gsap.set. But that leaves a window between the page rendering and the animation script running, and in that window the panel sits at zero, visible. Avoiding exactly that was why I wrote translateY(102%) in CSS in the first place. So I kept the CSS parking and pinned y in JavaScript instead.

The takeaway