Under the Hood
Gsap

Timelines: sequencing as a data structure

A GSAP Timeline isn't a scheduler that fires tweens on a stopwatch — it's a container holding child tweens at fixed positions on its own local time axis, with its own playhead, so sequencing an entire choreographed animation becomes one small, testable idea rather than a pile of hand-computed delays.

Timelines: sequencing as a data structure

The first lesson built a tween down to fifteen lines: a target, a start, an end, an elapsed time, and a tick(dt) that lerps between the endpoints. That's enough to animate one property smoothly. It says nothing about animating several things in order — the box moves, then the label fades in, then the panel slides up — which is most of what real UI choreography actually is. The naive way to do that in GSAP is delay, and the naive way breaks the moment you touch it.

The problem with delay-based sequencing

Say you want three tweens to run one after another: a box moves for 0.5s, then a label fades in for 0.3s, then a panel slides for 0.4s. With plain delays, you compute each start time by hand:

gsap.to(box, { x: 200, duration: 0.5, delay: 0 });
gsap.to(label, { opacity: 1, duration: 0.3, delay: 0.5 });
gsap.to(panel, { y: 0, duration: 0.4, delay: 0.8 });

That works, right up until a designer asks you to make the box move over 0.7s instead of 0.5s. Now the label's delay: 0.5 is wrong — it should be 0.7 — and the panel's delay: 0.8 is wrong too — it should be 1.0. Every delay after the changed tween is a hand-computed sum of everything before it, and nothing in the code enforces that the sum stays correct. Change one duration and every later delay quietly goes stale, and there is no error, no warning — the animation just starts overlapping or gapping in ways nobody intended. The bug is structural: delays encode a relationship between tweens ("start after this one finishes") as an absolute number, and absolute numbers don't update themselves when the thing they were computed from changes.

A timeline is a container with its own time axis

A GSAP Timeline fixes this by not asking you to compute absolute delays at all. Structurally, a timeline is:

  • a container holding an ordered collection of children (tweens, or other timelines),
  • where each child has a recorded start position — a point on the timeline's own local time axis, not the global clock,
  • and the timeline itself has a playhead, a single number representing "how far into this timeline are we," exactly the same kind of state a tween's elapsed is.

That third point is the one worth sitting with: a timeline doesn't scan through its children asking "should you be running yet?" on some ad-hoc schedule. It maintains one playhead value and, each time that playhead moves, works out where each child's local time must be as a consequence — the same recompute-on-demand relationship lesson 1 built for a single tween, just one level up.

const tl = gsap.timeline();
tl.to(box, { x: 200, duration: 0.5 });      // starts at tl-time 0
tl.to(label, { opacity: 1, duration: 0.3 }); // starts when the previous one ends
tl.to(panel, { y: 0, duration: 0.4 });       // starts when *that* one ends

Nobody typed a delay anywhere in that snippet. Each .to() call appends a child and, by default, records its start position as "wherever the previously-added child ends." Change the box's duration to 0.7s and the label's start position isn't a stored number that goes stale — it's computed from the box's end, so it moves automatically. The timeline turned a brittle chain of hand-maintained sums into a self-consistent data structure.

The position parameter: placing children without doing the math

Sequential-by-default is the common case, but you'll often want overlap, or a specific placement, or several tweens to start together. GSAP exposes this as the position parameter — an optional third argument to .to(), .from(), .set(), and friends that says where on the timeline's local axis this child's start position should be:

const tl = gsap.timeline();
tl.to(box, { x: 200, duration: 0.5 })
  .to(label, { opacity: 1, duration: 0.3 }, "-=0.2")  // start 0.2s before the previous child ends
  .to(panel, { y: 0, duration: 0.4 }, "+=0.1")        // start 0.1s after the previous child ends
  .to(shadow, { opacity: 0.4, duration: 0.3 }, 0);     // start at absolute tl-time 0 — with the very first tween

Read the position parameter as three distinct kinds of value, all landing in the same slot:

  • an absolute number (0, 1.2) — an exact position in seconds on the timeline's own axis, regardless of what else is happening there;
  • a relative string ("+=0.5", "-=0.3") — computed relative to the end of the previously inserted child, letting you specify a gap or an overlap without knowing what absolute second that previous child happens to land on;
  • a label ("reveal") — a named point on the timeline, set with tl.addLabel("reveal") or by using the label string as a position itself, so several children can anchor to the same semantic moment ("start when the reveal begins") instead of a magic number that means nothing on its own.

All three are really the same mechanism: they resolve, at the moment the child is added, to a start position stored on that child, on the timeline's local axis. The overlap you get from "-=0.2" is exactly why timelines can produce the tightly-choreographed, slightly-overlapping motion that seams together nicely — starting the next tween before the previous one has fully let go — a pattern that's fragile to keep in sync as delays but is one string argument as a position parameter.

Driving children from the parent playhead

Here's the mechanism that makes any of the above actually animate. When something advances the timeline's playhead to time T (the ticker does this each frame, exactly like it does for a standalone tween), the timeline doesn't re-run your .to() calls — it was done constructing children a while ago. Instead, for every child whose start position is <= T, it computes:

child's local time = T - child's start position

and renders that child at that local time, clamped to the child's own duration — the exact same clamped-progress math from lesson 1, just handed a T - start instead of a raw elapsed time. A child whose start position is 0.8 and whose own duration is 0.4 gets rendered at local time 0.2 when the parent playhead is at T = 1.0, which is 50% progress for that child specifically. A child whose start position hasn't been reached yet (start > T) simply isn't rendered — as far as it's concerned nothing has happened yet. This is the whole trick: the parent doesn't manage delays or fire callbacks to kick children off, it maps one number — its own playhead — onto every child's private time axis by subtraction, every single tick.

Scrubbing and reversing the whole sequence

Now here's the payoff, and it's the same idea lesson 1 ended on: because the timeline holds its own state as a normalized-ish playhead over a known total duration, everything that worked on a single tween's progress works on the whole sequence at once. tl.progress(0.5) jumps every child to wherever it should be at the halfway point of the entire choreography — box partway or done, label partway or not started, panel wherever T = 0.5 - panel.start puts it — computed fresh from that one assignment, not from replaying frames. tl.reverse() runs the parent playhead backward, and every child, having no idea the parent is going backward, just gets handed a shrinking T and renders whatever T - start says, which naturally plays the whole sequence in reverse, panel-then-label-then-box, without you writing a separate reverse animation. tl.timeScale(2) doubles how fast the parent's time advances, which speeds up every child in lockstep since they only ever see time filtered through the parent's playhead.

This is the concrete version of the sentence lesson 1 ended on: a timeline is itself tween-like — its "value" is a playhead position — so scrubbing, seeking, and reversing an entire multi-step animation is not a special feature bolted onto timelines, it's the exact same normalized-progress trick from a single tween, just applied to a container instead of a property. It's also exactly the mechanism ScrollTrigger leans on later: scroll position gets mapped onto tl.progress() (or tl.time()), and because the timeline already knows how to render "wherever this playhead is" for every child, ScrollTrigger doesn't need any special-case logic for "a timeline vs. a tween" — it drives whatever it's given the same way.

A short lead-in before the interactive piece: try dragging the playhead below across a timeline built from staggered child tweens, and watch each child's own progress update as a function of where the parent scrubber sits.

A
0.00
B
0.00
C
0.00

One playhead, three children: the timeline maps its own time t onto each child's local progress via (t − start) / duration, clamped to 0..1. Sequencing becomes a data structure instead of a pile of setTimeout calls — change B's duration and everything scheduled after it shifts automatically, and because the whole thing is driven by one number, you can scrub or reverse the entire sequence as a single unit.

Nesting: the same mechanism, one level deeper

Timelines can contain timelines. parentTl.add(childTl, "+=0.2") is legal, and it works for exactly the reason you'd hope: a nested timeline is, from the outer timeline's point of view, just another child with a start position and a way to be rendered at a given local time — it doesn't matter to the parent that the "child" happens to itself be a container full of further children. The parent computes T - childTl.start and hands that number to the child timeline as its playhead position, and the child timeline does exactly what it always does with an incoming playhead value: maps it onto its own children by subtraction, recursively, as deep as the nesting goes.

Where this goes next

Everything above assumed a child's own duration and progress were already well-defined — that mapping T - start onto a child produces a sensible 0 to 1 progress for it. What we glossed over is how that progress gets reshaped before it's used to compute a value: GSAP doesn't just lerp raw progress, and a back or elastic ease is doing meaningfully more than a CSS cubic-bezier() ever could. The next lesson picks that thread back up.

Go deeper

  • GSAP docs — the Timeline instance The real Timeline API — add(), progress(), seek(), reverse(), timeScale() — the exact operations this lesson derived from 'a timeline is a playhead over positioned children.'
  • GSAP — the position parameter, explained GSAP's own reference for absolute, relative, and label forms of the position parameter used to place children without hand-computed delays.
  • GSAP docs — gsap.timeline() Construction options (defaults, repeat, yoyo, onComplete) for the timeline container itself.

Check yourself

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

  1. Explain mechanically why changing one tween's duration silently breaks every later delay in a hand-sequenced animation, but doesn't break a GSAP timeline built the same way.
  2. What three things does a timeline need to hold for each child it contains?
  3. Given a child with start position 0.8 and duration 0.4, what local time does it render at when the parent playhead is at T = 1.0? What is its progress at that moment?
  4. Describe the three forms a position parameter can take and what each one resolves to.
  5. Why does tl.reverse() correctly reverse an entire multi-tween sequence without any child tween being told it's playing backward?
  6. How does nesting one timeline inside another work, given that the parent only knows how to hand a child 'T minus start'?
  7. In what sense is a timeline itself 'tween-like'? Tie your answer back to the normalized-progress idea from lesson 1.