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
- GSAP keeps
y(pixels) andyPercent(percent) apart, and the final offset is the sum of the two. Tweening one does not clear the other. - A transform you wrote in CSS is not a neutral starting point. GSAP parses it first and splits it into its own components, and a percent
translateYdoes not automatically land inyPercent. - If the motion is smooth but the element stops in the wrong place, suspect the values, not the timing. A resting point that misses is almost always about a starting value, not about when the tween fired.
gsap.getProperty()is the fastest way to see what GSAP is actually storing for an element. A property holding a number you never set is a big clue.- If an element already carries a transform from CSS, write
y: 0(andx: 0when the motion is horizontal) in both states of thefromTo. It is cheap, and it closes off a whole class of bugs that fail without a sound.