Transforms and the matrix: how GSAP handles x, y, rotation, and scale
CSS transform is a single property holding an ordered list of operations, which makes animating translation and rotation independently a genuine headache in raw CSS — you have to restate the whole list every time and the order changes the result — and GSAP's fix is to treat x, y, rotation, and scale as separate animatable numbers that it composes into one matrix every tick, reading the current matrix back apart when it needs to know where a transform currently stands.
Transforms and the matrix: how GSAP handles x, y, rotation, and scale
Try this in plain CSS: animate an element's x position and its rotation at the same time, independently, where the rotation keeps changing after the translation has already finished. You reach for transform: translateX(200px) rotate(0deg), and immediately hit the fact that transform is not four properties wearing a trenchcoat — it is one property, holding one string, and that string is an ordered list of functions. To change just the rotation half, you cannot say "update the rotate part and leave the rest alone." You have to write out the entire string again, translate included, every single time, and you have to get the order of the functions right or the element moves along a completely different path than you intended.
gsap.to(box, { x: 200, rotation: 45, scale: 1.5 }) doesn't have this problem — you list three completely independent numbers and GSAP handles it. This lesson is about the machinery that makes that possible: GSAP does not touch transform directly as a string at all. It keeps each component as its own tracked value and only assembles them into the one transform string the browser actually wants at the last possible moment, once per tick.
Why one CSS property holding a list is awkward for animation
A transform value like translate(200px, 0px) rotate(45deg) scale(1.5) is parsed by the browser as a sequence of matrix operations applied in order, left to right. That "in order" detail is not decoration — transform operations are non-commutative, meaning rotate-then-translate produces a different final position than translate-then-rotate, for the same two operations with the same numbers.
Picture rotating an element 90 degrees around its own center, then translating it 100 pixels along its local x-axis, versus translating it first and rotating second. In the first case, the translation happens along whatever direction "local x" points after the rotation — the element slides off at a right angle to where it would have gone if it hadn't rotated yet. In the second case, the element moves along the page's plain horizontal axis first, and then spins in place around its new location. Same two numbers, same two operations, two different final positions, purely because of order.
That non-commutativity is precisely why treating transform as "just restate the whole string" is so error-prone the moment you want independent control. If you're animating rotation with one tween and translation with a second, unrelated tween, both writing to the same transform property, whichever one runs last on a given frame has to know the entire current state — not just its own piece — and has to write it back in the one true order, or the element's position silently corrupts itself the instant both animations are active on the same frame.
GSAP's fix: components stay independent, all the way down
GSAP's CSSPlugin doesn't animate transform as a string. It exposes each piece of a transform as its own separate, ordinary animatable property: x, y, z (for 3D), rotation (around the z-axis), rotationX, rotationY (for 3D tilts), scaleX, scaleY (and the scale shorthand for both at once), skewX, skewY, and transformOrigin. Each one is, to the core tween machinery from the first lesson in this module, just a plain number with a start value, an end value, and a progress — exactly the same Tween shape as animating a plain JavaScript variable. There's no string parsing happening inside the tween itself; rotation is a number like 45, full stop.
gsap.to(box, {
x: 200,
rotation: 45,
scale: 1.5,
duration: 1,
});Three completely independent tweened numbers, each advancing on its own progress, with none of them needing to know or care about the others' current values. You can add a fourth tween on y later, or start a separate tween that only touches rotation while the x tween from above is still mid-flight, and neither one has to reconstruct the other's state to write correctly — because neither one is writing transform directly at all.
Composing components into one matrix, every tick
Somebody still has to turn x: 137, rotation: 22.4, scale: 1.5 into the one string the browser's rendering engine actually consumes: matrix(...) (or matrix3d(...) the moment any 3D property is involved). That's the CSSPlugin's job, and it does it fresh, from scratch, on every single tick — not once at tween creation. Each frame, for each element with active transform properties, GSAP reads the current value of every component that element has, in a fixed, canonical order it always uses (translation, then rotation, then skew, then scale is the conceptual order GSAP composes in), multiplies the corresponding matrices together, and writes the single resulting matrix() string onto the element's transform property.
This is the entire trick, stated plainly: GSAP fixes the composition order once, internally, so you never have to think about it. Because the order is always the same — always translate, rotate, scale in the same relative arrangement — two components animating simultaneously never fight over which order they get combined in. rotation always means "rotate around the element's own origin, in the same place in the pipeline, regardless of what x happens to be doing this frame." That determinism is exactly what the non-commutativity callout above says raw CSS can't give you for free — GSAP gives it to you by picking the order once and never deviating.
Knowing where a transform currently is: matrix decomposition
There's a second problem hiding underneath the first one. gsap.to(box, { rotation: "+=90" }) — rotate 90 degrees relative to wherever the element currently is — requires GSAP to know the element's current rotation before it can compute where "current plus 90" lands. If nothing GSAP-authored has touched this element yet — say it arrived with a transform: rotate(15deg) scale(1.2) written directly in a stylesheet — GSAP has no tween history to consult. The only source of truth is the browser itself, via getComputedStyle(el).transform, which hands back a single matrix(...) (or matrix3d(...)) string — the already-composed result, with no labeled "this part was the rotation" left in it.
GSAP has to reverse the composition: parse that matrix's six (or sixteen, for 3D) numbers back apart into the individual translate, rotate, scale, and skew components that could have produced it. This is matrix decomposition — the same mathematical operation graphics and game engines use to recover human-meaningful transform components from an opaque transformation matrix. It's genuinely solvable (a 2D matrix(a, b, c, d, e, f) decomposes deterministically into translation, rotation, scale, and skew given the six values), and it's exactly how a fresh gsap.to(el, { rotation: "+=90" }) on an element GSAP has never touched before knows what "current rotation" even means: it reads the matrix, decomposes it once, seeds its internal rotation value from that decomposition, and from then on it's back to the fixed-order composition described above.
// El arrived with a transform written in CSS, not GSAP:
// transform: rotate(15deg) scale(1.2);
gsap.to(el, {
rotation: "+=90", // GSAP decomposes the current matrix to find "15deg" first
duration: 1,
});
// Ends at rotation: 105deg — decomposition found the start, "+=" added the delta.Still landing on transform — and still compositor-friendly
None of this changes where the value ends up. GSAP composes x, rotation, and scale into a matrix specifically so it can write that matrix into the ordinary transform CSS property — the exact property the compositor thread lesson identified as one of the two properties (transform and opacity) whose animation can be handed entirely to the compositor thread, skipping style, layout, and paint every frame. A gsap.to(box, { x, rotation, scale }) tween is, from the browser's point of view, indistinguishable from any other transform-only animation: it's still just a value written onto transform every frame, and if nothing else about that element requires layout or paint, the browser can still promote it to its own compositor layer and composite it independently of whatever the main thread is doing.
The one place GSAP gives you a direct lever over that promotion is force3D. By default, GSAP escalates to matrix3d() (the 3D form) the moment any 3D-only property is used — z, rotationX, rotationY, or an explicit force3D: true — because a matrix3d() transform is one of the strongest signals a browser uses to promote an element onto its own compositor layer even for animations that are logically 2D. Setting force3D: true on a purely 2D tween is a way of nudging the browser to promote early; force3D: false suppresses that and keeps the plain 2D matrix() form, which is occasionally what you want on very simple elements where paying for an extra layer isn't worth it. Either way, GSAP is still just choosing which flavor of the same transform string to write — the compositor eligibility rules from that earlier lesson apply exactly as written.
Units and transformOrigin
x and y default to pixels but accept any CSS length GSAP understands ("50%", "10rem"), and GSAP resolves percentage-based x/y against the element's own dimensions the same way translate() would. rotation is in degrees by default (rotation: 45), though GSAP also accepts a "45rad"-style string if you specifically want radians. transformOrigin is the point every rotation and scale in the composed matrix pivots around — GSAP exposes it as its own settable property (transformOrigin: "left top", or a pixel pair) precisely because it changes what "the same rotation number" visually does: rotating 45 degrees around an element's center looks nothing like rotating 45 degrees around its top-left corner, even though the underlying rotation value is identical in both cases. Because transformOrigin participates in the same composed matrix as everything else, changing it doesn't require touching rotation or scale at all — it's one more independent input to the same per-tick composition.
Where this goes next
You now have the mechanism behind every gsap.to(el, { x, rotation, scale }) call: independent tracked numbers, composed into one matrix in a fixed order every tick, decomposed from the element's existing computed matrix the first time GSAP needs to know where it currently stands, and ultimately still just a transform string the compositor thread can take off the main thread's hands. The next lesson, ScrollTrigger under the hood, takes the normalized-progress idea from the very first lesson in this module and drives it from an entirely different clock — not time, but scroll position — to scrub exactly this kind of transform-based animation as the page scrolls.
Go deeper
- GSAP docs — CSS transforms — The real CSSPlugin's full list of transform-related properties (x, y, z, rotation, rotationX/Y, scale, skew, transformOrigin) this lesson covers, with the exact defaults and unit handling.
- MDN — transform-function: matrix() — The precise six-value 2D matrix format GSAP composes into and decomposes out of, including how translate/rotate/scale/skew map onto its a–f values.
- MDN — will-change — The layer-promotion mechanism force3D is nudging, covered in full by the compositor thread lesson this article ties back to.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why is a single CSS transform string awkward for animating translation and rotation independently, in a way that two separate CSS properties wouldn't be?
- Give a concrete example of transform non-commutativity: two operations, same numbers, different order, different visual result.
- What does GSAP actually store internally for a property like rotation — a fragment of a transform string, or something else? What shape is it?
- Describe what happens on a single tick for an element being animated on both x and rotation: what does the CSSPlugin do, and in what order?
- What problem does matrix decomposition solve, and why is it specifically needed for a relative tween like rotation: '+=90' on an element GSAP hasn't touched before?
- Explain why a GSAP transform tween is still compositor-friendly, tying the answer back to what property ultimately gets written to the DOM.
- What does force3D actually change, mechanically, and why does escalating to matrix3d() affect whether an element gets promoted to its own compositor layer?