← Blog

GSAP Timeline Tutorial: Sequence Animations Like a Pro (2026)

Learn how to use gsap.timeline() to sequence animations with precision. Covers the position parameter, defaults, labels, playback control, and real-world examples.

GSAP timeline tutorial showing animation sequencing with gsap.timeline() position parameter

The first time I tried to sequence multiple GSAP animations, I did it with delay. Five tweens, each with a manually calculated delay value. It worked. Until I needed to adjust the timing of step two. Then everything broke.

There's a better way. gsap.timeline() is how GSAP professionals sequence animations. It gives you precise control over every step, makes timing adjustments trivial, and opens up features you simply can't get with chained delays.

In this tutorial you'll learn:

  • How to create a GSAP timeline and add tweens to it
  • The position parameter (the single most powerful concept in GSAP)
  • How to use defaults, labels, and playback control
  • A real-world card reveal example you can use today

The Problem with Using Delay

Here's what animation sequencing looks like without timelines:

script.js
gsap.to('.title', { y: 0, opacity: 1, duration: 0.6 })
gsap.to('.subtitle', { y: 0, opacity: 1, duration: 0.6, delay: 0.7 })
gsap.to('.button', { y: 0, opacity: 1, duration: 0.6, delay: 1.4 })
gsap.to('.image', { scale: 1, opacity: 1, duration: 0.8, delay: 0.3 })

This works at first. But every delay is hardcoded. If you change the title animation to 0.8s, you need to manually update the subtitle delay, the button delay, and anything else downstream. It gets messy fast.

GSAP timelines fix this completely.

Creating Your First Timeline

A timeline is a container for tweens. Everything inside it plays in sequence by default.

script.js
const tl = gsap.timeline()

tl.to('.title', { y: 0, opacity: 1, duration: 0.6 })
  .to('.subtitle', { y: 0, opacity: 1, duration: 0.6 })
  .to('.button', { y: 0, opacity: 1, duration: 0.6 })

Now each tween starts automatically when the previous one ends. Change the title duration and everything adjusts. No manual delay math.

You can also add each tween on its own line (the result is identical):

script.js
const tl = gsap.timeline()
tl.to('.title', { y: 0, opacity: 1, duration: 0.6 })
tl.to('.subtitle', { y: 0, opacity: 1, duration: 0.6 })
tl.to('.button', { y: 0, opacity: 1, duration: 0.6 })

Visual: PLACEHOLDER - Diagram showing three sequential tweens on a timeline bar, each starting when the previous ends

The Position Parameter

This is the part most tutorials skip. The position parameter is the third argument on any timeline tween, and it controls exactly when that tween starts.

script.js
tl.to('.element', { x: 100, duration: 1 }, position)

Here's every form it can take:

Absolute Time

script.js
tl.to('.a', { x: 100, duration: 1 }, 0) // starts at 0 seconds
tl.to('.b', { y: 50, duration: 1 }, 1.5) // starts at 1.5 seconds

Relative to the Previous Tween's End

script.js
tl.to('.a', { x: 100, duration: 1 })
tl.to('.b', { y: 50, duration: 1 }, '+=0.5') // 0.5s after .a ends
tl.to('.c', { opacity: 0, duration: 1 }, '-=0.3') // 0.3s before .b ends (overlap)

The "-=0.3" form is something I use constantly. Overlapping animations by a few frames feels much more natural than strict sequential playback.

Relative to the Previous Tween's Start

script.js
tl.to('.a', { x: 100, duration: 1 })
tl.to('.b', { y: 50, duration: 0.5 }, '<') // same start time as .a
tl.to('.c', { opacity: 0, duration: 1 }, '<0.2') // 0.2s after .a starts

The "<" shorthand means "start when the most recently added tween starts." It's how you run animations in parallel without hardcoding absolute time values.

Visual: PLACEHOLDER - Side-by-side timeline diagrams showing sequential vs overlapping tweens using position parameter

Practical Example of the Position Parameter

Here's a card entrance with a staggered but overlapping feel:

script.js
const tl = gsap.timeline({ paused: true })

// Image scales up
tl.from('.card-image', { scale: 0.9, opacity: 0, duration: 0.8, ease: 'power3.out' })

// Title starts 0.2s before image finishes (feels connected)
tl.from('.card-title', { y: 20, opacity: 0, duration: 0.6, ease: 'power3.out' }, '-=0.2')

// Body text starts when title starts (parallel)
tl.from('.card-body', { y: 20, opacity: 0, duration: 0.6, ease: 'power3.out' }, '<0.1')

// Button slides in last
tl.from('.card-button', { y: 10, opacity: 0, duration: 0.5, ease: 'power3.out' }, '-=0.2')

Compare this to what the same thing looks like with raw delays. The timeline version is cleaner and trivial to adjust.

Timeline Defaults

If all your tweens share the same duration and easing, pass them as defaults on the timeline:

script.js
const tl = gsap.timeline({
  defaults: {
    duration: 0.6,
    ease: 'power3.out',
  },
})

// Each tween inherits duration and ease
tl.from('.title', { y: 30, opacity: 0 })
  .from('.subtitle', { y: 20, opacity: 0 })
  .from('.button', { y: 15, opacity: 0 })

Individual tweens can still override defaults. A tween with duration: 1 will use 1s, not the default 0.6s. Defaults just set the fallback.

This is a small thing that makes a real difference when you have 10+ tweens in a timeline.

Labels

Labels mark specific points in a timeline by name. They make complex sequences readable and let you seek to specific moments.

script.js
const tl = gsap.timeline()

tl.addLabel('intro', 0)
tl.from('.hero-text', { y: 40, opacity: 0, duration: 0.8 }, 'intro')
tl.from('.hero-image', { scale: 0.95, opacity: 0, duration: 1 }, 'intro+=0.2')

tl.addLabel('details', '+=0.5')
tl.from('.detail-1', { x: -20, opacity: 0, duration: 0.5 }, 'details')
tl.from('.detail-2', { x: -20, opacity: 0, duration: 0.5 }, 'details+=0.15')
tl.from('.detail-3', { x: -20, opacity: 0, duration: 0.5 }, 'details+=0.3')

Now instead of magic numbers, the sequence reads like a script. You can also play from a label:

script.js
tl.play('details') // jump to the "details" section and play

This is useful for multi-step UI flows where you need to navigate to specific states.

Playback Control

Every timeline gives you full control over playback:

script.js
const tl = gsap.timeline({ paused: true }) // create paused

// Trigger on user interaction
button.addEventListener('click', () => tl.play())

// Other controls
tl.pause()
tl.reverse()
tl.restart()
tl.progress(0.5) // jump to 50%
tl.time(1.5) // jump to 1.5 seconds
tl.kill() // destroy it

A common pattern: create the timeline with paused: true, then trigger it on hover, click, or scroll. This is how most interactive animations work.

script.js
const card = document.querySelector('.card')
const tl = gsap.timeline({ paused: true })

tl.to('.card-overlay', { opacity: 1, duration: 0.4 }).to(
  '.card-label',
  { y: 0, opacity: 1, duration: 0.3 },
  '-=0.1'
)

card.addEventListener('mouseenter', () => tl.play())
card.addEventListener('mouseleave', () => tl.reverse())

The tl.reverse() call on mouse leave runs the entire animation backwards. GSAP handles all the easing correctly in reverse. No separate "out" animation needed.

Visual: PLACEHOLDER - Code example showing hover animation with play/reverse using timeline

Timeline Options

A few constructor options worth knowing:

script.js
const tl = gsap.timeline({
  paused: true, // start paused
  repeat: -1, // loop forever (-1 = infinite)
  yoyo: true, // alternate direction on each repeat
  delay: 0.5, // wait 0.5s before starting
  defaults: { duration: 0.5, ease: 'expo.out' },
  onComplete: () => console.log('done'),
  onStart: () => console.log('started'),
})

repeat and yoyo on a timeline apply to the entire sequence. The whole thing repeats, not individual tweens.

Nesting Timelines

Timelines can contain other timelines. This is useful for building reusable animation modules.

script.js
function cardEntrance(card) {
  const tl = gsap.timeline()
  tl.from(card.querySelector('.image'), { scale: 0.9, opacity: 0, duration: 0.7 })
  tl.from(card.querySelector('.title'), { y: 20, opacity: 0, duration: 0.5 }, '-=0.2')
  return tl
}

const master = gsap.timeline()
master.add(cardEntrance(card1), 0)
master.add(cardEntrance(card2), 0.3)
master.add(cardEntrance(card3), 0.6)

Each card gets its own encapsulated animation, and the master timeline controls when each one starts. The position parameter still works the same way when adding child timelines.

A Real-World Example: Page Section Reveal

Here's how I'd animate a typical marketing section with a heading, subtext, and a row of feature cards:

script.js
function animateSection(section) {
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: section,
      start: 'top 75%',
      once: true,
    },
    defaults: {
      ease: 'power3.out',
      duration: 0.7,
    },
  })

  tl.from(section.querySelector('.section-label'), {
    y: 10,
    opacity: 0,
  })

  tl.from(
    section.querySelector('.section-heading'),
    {
      y: 30,
      opacity: 0,
      duration: 0.9,
    },
    '-=0.3'
  )

  tl.from(
    section.querySelector('.section-body'),
    {
      y: 20,
      opacity: 0,
    },
    '-=0.4'
  )

  tl.from(
    section.querySelectorAll('.feature-card'),
    {
      y: 30,
      opacity: 0,
      stagger: 0.1,
    },
    '-=0.3'
  )

  return tl
}

A few things to notice here:

  • once: true on ScrollTrigger means the animation fires once and stays. No replay on scroll up.
  • defaults keeps all the tweens consistent without repeating the same values.
  • The overlapping positions ("-=0.3", "-=0.4") create flow. Nothing feels abrupt.
  • stagger: 0.1 on the cards makes them cascade without needing individual tweens.

If you're not familiar with ScrollTrigger yet, I wrote a full guide: GSAP ScrollTrigger Examples: 10 Patterns for Real Projects.

Common Mistakes

Using delay instead of the position parameter. Once you understand position, delay on individual tweens inside a timeline is almost never the right choice.

Forgetting paused: true on interactive timelines. If you create a hover timeline without pausing it, it will autoplay immediately on page load.

Putting ScrollTrigger on tweens inside a timeline. ScrollTrigger should go on the timeline itself, not on child tweens. Putting it inside breaks the sequencing.

script.js
// Wrong
const tl = gsap.timeline()
tl.to('.box', { x: 100, scrollTrigger: { trigger: '.box' } }) // don't do this

// Right
const tl = gsap.timeline({
  scrollTrigger: { trigger: '.container', start: 'top 80%' },
})
tl.to('.box', { x: 100 })

Skipping defaults when all tweens share the same ease/duration. It's not a bug, just unnecessary repetition.

Key Takeaways

  • Use gsap.timeline() to sequence animations. Delays are fragile. Timelines aren't.
  • The position parameter ("-=0.2", "<", 0) is the most powerful feature. Learn it first.
  • Pass defaults to the timeline to avoid repeating duration and ease on every tween.
  • Create timelines with paused: true for interactive animations and trigger them with play() and reverse().
  • ScrollTrigger goes on the timeline, not on tweens inside it.

What's Next

If you're building scroll animations, the timeline + ScrollTrigger combination is where things get interesting. I covered 10 production-ready patterns in GSAP ScrollTrigger Examples.

For text animations specifically, GSAP SplitText Guide covers character and line-level reveals that work well inside timelines.

If you want to skip the setup and grab ready-to-use animations built with exactly these patterns, browse the Annnimate library. Every animation includes the full timeline code.