Under the Hood
Gsap

What a tween really is: the interpolation core

Strip GSAP of its plugins, its ticker, and its timelines and what's left at the very bottom is a single small idea — an object that remembers a start value, an end value, and how far along it is, and on every tick writes start plus delta times an eased progress back onto its target. This lesson builds that core from scratch so the rest of the library stops looking like magic.

What a tween really is: the interpolation core

GSAP has a reputation for being able to animate anything — DOM nodes, SVG, canvas objects, plain JavaScript variables, camera positions in a 3D scene. That reputation makes it sound like there's some large, clever machine inside. There isn't, or rather, the cleverness is all in the layers around a core that is almost embarrassingly small. If you understand that core — a single object type called a tween — everything else in the library (the ticker, timelines, plugins, ScrollTrigger) turns into a variation on it. So we start at the bottom and build up.

The one sentence to hold onto for this whole lesson: a tween is an object that remembers where a value started, where it should end, and how much time has passed, and on demand computes the in-between value by interpolation. Everything below is unpacking that sentence.

The atomic operation: interpolation

Say you want a value to go from 0 to 200 over 1 second. At any moment during that second, the "right" value is a blend of the two endpoints weighted by how far through you are:

value = start + (end - start) * progress

where progress is a number from 0 (just started) to 1 (finished). At progress = 0 you get start; at progress = 1 you get end; at progress = 0.5 you get exactly halfway. That's it — that's linear interpolation, usually shortened to lerp, and it is the single arithmetic operation at the heart of every animation library ever written, GSAP included. (end - start) is the total distance to travel, often called the delta; progress is how much of that delta to apply right now.

If you've read the animation module, this is exactly what the browser does internally for a CSS transition. The difference — the entire reason GSAP exists — is who drives it. A CSS transition hands that lerp to the browser, which advances progress on its own schedule and won't let you interrupt, inspect, or reroute it. GSAP keeps the lerp in JavaScript, where you own it. To own it, GSAP needs to store the pieces.

A tween is just the state that lerp needs

Look at that formula again and ask: what does a tween have to remember to be able to compute value at any moment? Exactly four things:

  • the target (the object whose property we're changing),
  • the start value,
  • the end value,
  • and the elapsed time so far (from which progress is derived, given the duration).

So a minimal tween is nothing more than a bag holding those, plus the arithmetic to turn them into a written-back value. Here is essentially the entire idea, in about fifteen lines:

class Tween {
  constructor(target, prop, endValue, duration) {
    this.target = target;
    this.prop = prop;
    this.start = target[prop];        // snapshot where it is *now*
    this.end = endValue;
    this.duration = duration;         // seconds
    this.elapsed = 0;
  }

  // Advance by `dt` seconds and write the new value onto the target.
  tick(dt) {
    this.elapsed += dt;
    const progress = Math.min(this.elapsed / this.duration, 1); // clamp at 1
    this.target[this.prop] = this.start + (this.end - this.start) * progress;
    return progress >= 1; // true once finished
  }
}

That is a real, working tween. gsap.to(box, { x: 200, duration: 1 }) is, at its core, constructing an object like this one and arranging for tick to be called with the right time deltas until progress reaches 1. Everything GSAP adds — eases, plugins, timelines — hangs off this skeleton without changing its shape.

value = 0 + (100 − 0) × 0.00 = 0.0

The box's position is never stored as a keyframe — at every instant it is recomputed from start + (end − start) × progress. That's the whole tween core. Because the value is derived on demand rather than played back, scrubbing the slider, reversing direction, or jumping straight to any point in time is trivial: just plug in a different progress and read out a new value.

Scrub the playhead above and watch the written value: it's not stored anywhere as a keyframe list: at each position it's recomputed from start + (end - start) * progress. That recomputation-on-demand is the property that makes the rest of GSAP possible, and it's worth seeing directly before we add anything to it.

Why GSAP snapshots the start value lazily

Notice the constructor did this.start = target[prop] — it read the current value at creation time. GSAP does something subtly smarter, and the reason is worth understanding because it explains a class of real bugs.

GSAP doesn't necessarily lock in the start value the instant you call gsap.to(). It records it when the tween actually renders its first frame. The gap matters: you might create several tweens in a row, or create a tween with a delay, and the target's value could change in between. If GSAP had frozen the start value at call-time, a tween that begins playing 500ms later would interpolate from a stale, no-longer-true starting point, producing a visible jump at the moment it kicks in. By reading the start value at first render instead, the tween always begins from wherever the target genuinely is at that moment — which is also the behavior you want when a second tween interrupts a first one mid-flight (a theme the interruptible animations lesson develops).

This is the mechanical reason behind a rule GSAP users repeat without always knowing why: from and to values are resolved at render time, not definition time. It's not a special case — it falls directly out of "a tween is state, and the start value is part of that state, captured when the tween first runs."

Normalized progress is the pivot everything else hangs on

There's a design decision hiding in that progress variable that pays off enormously later. progress is normalized — it always runs 0 to 1, regardless of whether the tween lasts 200 milliseconds or 10 seconds. The actual duration only appears in one place: converting elapsed time into that 0→1 fraction (elapsed / duration).

Keeping progress normalized means every operation that isn't "advance time" can be expressed purely in terms of that 0→1 number, independent of real seconds:

  • Easing is a function that takes normalized progress and returns a reshaped normalized progress (0→1 in, 0→1-ish out). We'll add it next lesson, and it slots in as a single line: const p = ease(progress) before the lerp.
  • Reversing is just running progress from 1 back down to 0.
  • Seeking / scrubbing — jumping to "40% done" — is setting progress = 0.4 directly, which is exactly what the playground's slider does and, as you'll see, exactly what ScrollTrigger does when it maps scroll position onto a tween.
  • Nesting a tween inside a timeline works because the parent can drive the child by handing it a progress value, without either needing to agree on absolute seconds.

None of those would compose cleanly if the tween's internal clock were denominated in raw milliseconds. By normalizing to 0→1, GSAP makes time and everything else separable. That separation is the single most important structural idea in the library, and it's why we spent a whole lesson on the humble lerp before touching anything that looks like a feature.

What we deliberately left out (and where it comes back)

The core above is honest but bare. Four things are conspicuously missing, each the subject of a lesson ahead:

  1. Who calls tick, and with what dt? Our tween can advance itself but nothing is driving it. GSAP has a single global heartbeat — the ticker — that advances every active tween each frame with a real time delta.
  2. How does progress get reshaped into non-linear motion? That's easing, which drops in as one function call on the normalized progress.
  3. How does writing target[prop] = number become "set the CSS transform" or "set an SVG attribute"? Our core writes a plain number to a plain property. Real targets need translation — units, transforms, colors — and that's the job of plugins and the property model.
  4. How do many tweens get sequenced and controlled as one? That's timelines, which are, satisfyingly, themselves a kind of tween whose "value" is a playhead position.

Every one of those is an addition around the interpolation core, not a change to it. If you keep the fifteen-line Tween class in your head as the thing all of them decorate, the rest of this module reads as a series of small, motivated extensions rather than a pile of API surface.

Where this goes next

The most glaring omission is the first one: our tween has a tick(dt) method but nothing ever calls it. A naive fix would be to give each tween its own requestAnimationFrame loop — and that turns out to be exactly the wrong move, for reasons of synchronization, performance, and correctness that The ticker: one rAF loop to rule them all takes apart. GSAP runs one loop for the entire library, and understanding why is the next step up from the core you just built.

Go deeper

  • GSAP docs — gsap.to() The public API that constructs the tween object this lesson reverse-engineered, with the full set of options that seed its state.
  • GSAP docs — the Tween instance The real Tween's methods — progress(), time(), seek(), reversed() — which are exactly the operations that fall out of normalized progress.
  • Linear interpolation (lerp) The one arithmetic operation at the center of every tween, stated in its general form.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Write the interpolation formula from memory and label which part is the 'delta' and which is 'progress'. What value does it produce at progress 0, 0.5, and 1?
  2. In what sense is a tween 'just state'? Name the four things it must remember to compute its value at any moment.
  3. GSAP records a tween's start value at first render rather than at the moment gsap.to() is called. Give a concrete scenario where that difference prevents a visible jump.
  4. Explain how gsap.to, gsap.from, and gsap.fromTo are the same underlying tween object seeded three different ways.
  5. Why is it significant that progress is normalized to 0→1 rather than measured in milliseconds? Name two operations (besides plain playback) that become trivial because of it.
  6. The core tween has a tick(dt) method but the lesson calls it 'bare.' What is missing, and which later lesson supplies it?
  7. A scrubber that jumps a tween to '40% complete' and a ScrollTrigger that maps scroll position onto an animation are doing the same underlying operation. What is it, in terms of the tween's state?