Tutorial

Professional animation with GSAP

The animation library used by Awwwards winners, Apple, Google, and 11 million other sites. Fluent API, blazing fast, works on anything you can put a selector on.

New to GSAP? Read the visual lesson →
Step 1

What GSAP is (and why it's famous)

GSAP (GreenSock Animation Platform) is a JavaScript library for animating anything — HTML, SVG, canvas, WebGL, React props, even arbitrary JS values. It's famous because it's the fastest, most reliable animation tool on the web, with an API that reads like plain English.

CSS animations vs. GSAP — when to reach for each

Use CSS when

  • Simple hover, focus, or state transitions
  • Animation runs once and doesn't need sequencing
  • You want zero JavaScript overhead
  • The animation is tied to user interaction (hover, focus)

Use GSAP when

  • You need to sequence multiple animations (timeline)
  • Animations depend on scroll position
  • You need to animate SVG morphs, paths, or 3D transforms smoothly
  • You want pause, reverse, restart controls
  • You're staggering lots of elements
  • Performance & consistency across browsers matters
Step 2

Installing GSAP — one line

The CDN is the easiest way to start. Paste one script tag, and gsap is available globally.

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>

<script>
  gsap.to('.box', { x: 200, duration: 1 });
</script>
npm install gsap

// then in your JS
import gsap from 'gsap';

gsap.to('.box', { x: 200, duration: 1 });
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>

<script>
  gsap.registerPlugin(ScrollTrigger);
</script>
License note: GSAP core is 100% free for any project, including commercial. Some bonus plugins (SplitText, MorphSVG, DrawSVG) require a paid Club GreenSock membership, but 95% of what people need is free.
Demo 1

gsap.to() — the one method you'll use most

Click the button to animate the box. Click it again to watch it re-run. gsap.to() means "animate these elements to these values over this duration."

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0; min-height: 100vh;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 24px;
      background: #0a0a0a;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    .demo-stage {
      background: rgba(255,255,255,.03);
      border: 1px solid rgba(255,255,255,.08);
      border-radius: 12px; padding: 28px;
      min-height: 200px; width: 320px;
      display: flex; align-items: center; justify-content: center;
      overflow: hidden;
    }
    .box {
      width: 70px; height: 70px; border-radius: 14px;
      background: linear-gradient(135deg, #10b981, #8b5cf6);
      box-shadow: 0 10px 24px rgba(16,185,129,.3);
    }
    .demo-btn {
      background: #10b981; color: #0a0a0a; font-weight: 700;
      padding: 10px 18px; border-radius: 10px; border: none;
      font-family: inherit; font-size: 0.9rem; cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="demo-stage">
    <div class="box" id="demo1-box"></div>
  </div>
  <button class="demo-btn" onclick="animate()">Run animation</button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script>
    function animate() {
      gsap.killTweensOf('#demo1-box');
      gsap.set('#demo1-box', {
        x: 0, rotation: 0, scale: 1,
        background: 'linear-gradient(135deg, #10b981, #8b5cf6)'
      });
      gsap.to('#demo1-box', {
        x: 180,                // move 180px to the right
        rotation: 360,         // spin once
        scale: 1.4,            // grow 40%
        backgroundColor: '#ec4899',
        duration: 1,           // over 1 second
        ease: 'power2.out',    // smooth deceleration
        yoyo: true,            // come back
        repeat: 1              // once is enough
      });
    }
  </script>
</body>
</html>
// Targets the box and runs it on click. Paste the HTML tab into a
// blank file to see this animate exactly like the demo above.
function animate() {
  // wipe any in-progress run, then reset to the starting look
  gsap.killTweensOf('#demo1-box');
  gsap.set('#demo1-box', {
    x: 0, rotation: 0, scale: 1,
    background: 'linear-gradient(135deg, #10b981, #8b5cf6)'
  });

  gsap.to('#demo1-box', {
    x: 180,                // move 180px to the right
    rotation: 360,         // spin once
    scale: 1.4,            // grow 40%
    backgroundColor: '#ec4899',
    duration: 1,           // over 1 second
    ease: 'power2.out',    // smooth deceleration
    yoyo: true,            // come back
    repeat: 1              // once is enough
  });
}
Demo 2

Stagger — animate lists with one line

stagger tells GSAP to delay each element by a bit, giving you a cascading effect for free. This would be tedious in raw CSS.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0; min-height: 100vh;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 24px;
      background: #0a0a0a;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    .demo-stage {
      background: rgba(255,255,255,.03);
      border: 1px solid rgba(255,255,255,.08);
      border-radius: 12px; padding: 28px;
      min-height: 140px; width: 320px;
      display: flex; align-items: center; justify-content: center;
      overflow: hidden;
    }
    .stagger-grid {
      display: flex; gap: 14px; flex-wrap: wrap; justify-content: center;
    }
    .stagger-grid .box {
      width: 60px; height: 60px; border-radius: 12px;
      opacity: 0; transform: translateY(20px);
    }
    /* six distinct gradients so each box reads as its own color */
    .stagger-grid .box:nth-child(1) { background: linear-gradient(135deg, #ef4444, #f97316); }
    .stagger-grid .box:nth-child(2) { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
    .stagger-grid .box:nth-child(3) { background: linear-gradient(135deg, #10b981, #06b6d4); }
    .stagger-grid .box:nth-child(4) { background: linear-gradient(135deg, #3b82f6, #6366f1); }
    .stagger-grid .box:nth-child(5) { background: linear-gradient(135deg, #8b5cf6, #a855f7); }
    .stagger-grid .box:nth-child(6) { background: linear-gradient(135deg, #ec4899, #f43f5e); }
    .demo-btn {
      background: #10b981; color: #0a0a0a; font-weight: 700;
      padding: 10px 18px; border-radius: 10px; border: none;
      font-family: inherit; font-size: 0.9rem; cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="demo-stage">
    <div class="stagger-grid" id="staggerGrid">
      <div class="box"></div><div class="box"></div><div class="box"></div>
      <div class="box"></div><div class="box"></div><div class="box"></div>
    </div>
  </div>
  <button class="demo-btn" onclick="runStagger()">Run stagger</button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script>
    function runStagger() {
      gsap.killTweensOf('#staggerGrid .box');
      gsap.set('#staggerGrid .box', { y: 20, opacity: 0, scale: 0.8 });
      gsap.to('#staggerGrid .box', {
        y: 0, opacity: 1, scale: 1,
        duration: 0.6,
        ease: 'back.out(1.7)',
        stagger: 0.08         // each box starts 80ms after the previous
      });
    }
    // play it once on load, like the demo
    window.addEventListener('load', runStagger);
  </script>
</body>
</html>
// Matches the demo: reset the six boxes, then cascade them in.
function runStagger() {
  gsap.killTweensOf('#staggerGrid .box');
  gsap.set('#staggerGrid .box', { y: 20, opacity: 0, scale: 0.8 });
  gsap.to('#staggerGrid .box', {
    y: 0, opacity: 1, scale: 1,
    duration: 0.6,
    ease: 'back.out(1.7)',
    stagger: 0.08           // each box starts 80ms after the previous
  });
}
window.addEventListener('load', runStagger);

// Stagger supports more — for a 2D grid you can ripple from the center:
gsap.to('.grid-item', {
  scale: 1,
  stagger: {
    each: 0.05,
    from: 'center',       // 'start' | 'end' | 'center' | 'edges' | 'random'
    grid: [6, 6]          // treat as a 6×6 grid
  }
});
Demo 3

Timelines — choreograph multiple animations

A gsap.timeline() lets you sequence animations one after another, or overlap them, without calculating delays by hand. This is where GSAP really pulls ahead of raw CSS.

Done! 🎉
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0; min-height: 100vh;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 24px;
      background: #0a0a0a;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    .demo-stage {
      background: rgba(255,255,255,.03);
      border: 1px solid rgba(255,255,255,.08);
      border-radius: 12px; padding: 28px;
      min-height: 220px; width: 360px;
      display: flex; align-items: center; justify-content: center;
      overflow: hidden;
    }
    .timeline-stage { width: 100%; position: relative; height: 180px; }
    .tl-box {
      position: absolute; width: 60px; height: 60px; border-radius: 12px;
      top: 60px; left: 30px;
      background: linear-gradient(135deg, #10b981, #8b5cf6);
    }
    .tl-text {
      position: absolute; bottom: 10px; left: 0; right: 0;
      text-align: center; font-size: 1.3rem; font-weight: 700;
      color: #10b981; opacity: 0;
    }
    .demo-btn {
      background: #10b981; color: #0a0a0a; font-weight: 700;
      padding: 10px 18px; border-radius: 10px; border: none;
      font-family: inherit; font-size: 0.9rem; cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="demo-stage">
    <div class="timeline-stage">
      <div class="tl-box" id="tlBox"></div>
      <div class="tl-text" id="tlText">Done! 🎉</div>
    </div>
  </div>
  <button class="demo-btn" onclick="playTimeline()">Play timeline</button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script>
    let tl;
    function playTimeline() {
      if (tl) tl.kill();
      gsap.set('#tlBox', { x: 0, y: 0, rotation: 0 });
      gsap.set('#tlText', { opacity: 0, y: 0 });

      tl = gsap.timeline();
      tl.to('#tlBox',  { x: 250, duration: 0.8, ease: 'power2.inOut' })
        .to('#tlBox',  { rotation: 360, duration: 0.6 })
        .to('#tlBox',  { y: -40, duration: 0.3, yoyo: true, repeat: 1 })
        .to('#tlBox',  { x: 0, duration: 0.6, ease: 'power2.inOut' })
        .to('#tlText', { opacity: 1, y: -10, duration: 0.4 }, '-=0.2');
    }
  </script>
</body>
</html>
// Matches the demo above (ids #tlBox and #tlText).
let tl;
function playTimeline() {
  if (tl) tl.kill();                         // restart cleanly each click
  gsap.set('#tlBox', { x: 0, y: 0, rotation: 0 });
  gsap.set('#tlText', { opacity: 0, y: 0 });

  tl = gsap.timeline();
  tl.to('#tlBox',  { x: 250, duration: 0.8, ease: 'power2.inOut' })
    .to('#tlBox',  { rotation: 360, duration: 0.6 })
    .to('#tlBox',  { y: -40, duration: 0.3, yoyo: true, repeat: 1 })
    .to('#tlBox',  { x: 0, duration: 0.6, ease: 'power2.inOut' })
    .to('#tlText', { opacity: 1, y: -10, duration: 0.4 }, '-=0.2');
}

// The '-=0.2' on the last line overlaps the previous tween by 0.2s
// (it starts 0.2s before the previous animation ends).

// Timelines expose controls you can call anytime:
// tl.pause();
// tl.reverse();
// tl.restart();
// tl.progress(0.5);   // jump to 50%
Demo 4

Animating text, letter by letter

Split a string into characters, then animate each one with stagger. This is how every award-winning hero section works.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0; min-height: 100vh;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 24px;
      background: #0a0a0a;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    .demo-stage {
      background: rgba(255,255,255,.03);
      border: 1px solid rgba(255,255,255,.08);
      border-radius: 12px; padding: 28px;
      min-height: 160px; width: 360px;
      display: flex; align-items: center; justify-content: center;
      overflow: hidden;
    }
    .split-text {
      font-size: clamp(1.6rem, 4vw, 2.6rem); font-weight: 800;
      text-align: center; letter-spacing: -0.02em;
    }
    .split-text .char {
      display: inline-block;
      background: linear-gradient(135deg, #10b981, #8b5cf6);
      -webkit-background-clip: text; background-clip: text; color: transparent;
    }
    .demo-btn {
      background: #10b981; color: #0a0a0a; font-weight: 700;
      padding: 10px 18px; border-radius: 10px; border: none;
      font-family: inherit; font-size: 0.9rem; cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="demo-stage">
    <div class="split-text" id="splitText"></div>
  </div>
  <button class="demo-btn" onclick="animateText()">Animate text</button>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script>
    function animateText() {
      const target = document.getElementById('splitText');
      const text = 'Hello, animation.';

      // Wrap each character in its own span (spaces become &nbsp;)
      target.innerHTML = text
        .split('')
        .map(c => c === ' '
          ? '&nbsp;'
          : `<span class="char">${c}</span>`
        ).join('');

      gsap.from('#splitText .char', {
        y: 40,
        opacity: 0,
        rotation: 12,
        duration: 0.6,
        ease: 'back.out(2)',
        stagger: 0.04
      });
    }
    // run once on load, like the demo
    window.addEventListener('load', animateText);
  </script>
</body>
</html>
// Matches the demo above (target element id="splitText").
function animateText() {
  const target = document.getElementById('splitText');
  const text = 'Hello, animation.';

  // Wrap each character in its own span (spaces become &nbsp;)
  target.innerHTML = text
    .split('')
    .map(c => c === ' '
      ? '&nbsp;'
      : `<span class="char">${c}</span>`
    ).join('');

  gsap.from('#splitText .char', {
    y: 40,
    opacity: 0,
    rotation: 12,
    duration: 0.6,
    ease: 'back.out(2)',
    stagger: 0.04
  });
}
window.addEventListener('load', animateText);
Demo 5

ScrollTrigger — animate as the user scrolls

ScrollTrigger is GSAP's most famous plugin. It fires animations tied to scroll position — fade in on enter, pin elements, progress bars, parallax. Scroll down to see it fire.

This box appears when you scroll to it

When 80% of the viewport passes this element, GSAP fades it in, rotates it, and scales it up. If you scroll back past it, it resets.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      margin: 0;
      background: #0a0a0a; color: #f5f5f7;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    /* tall spacer so there is room to scroll down to the demo */
    .spacer { height: 100vh; display: flex; align-items: center; justify-content: center; color: #555; }
    .scroll-section {
      min-height: 320px; max-width: 720px; margin: 0 auto; padding: 30px 24px;
      display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: center;
    }
    @media (max-width: 680px) { .scroll-section { grid-template-columns: 1fr; } }
    .scroll-box {
      width: 100%; aspect-ratio: 1; max-width: 220px;
      background: linear-gradient(135deg, #10b981, #8b5cf6);
      border-radius: 20px; margin: 0 auto;
    }
    .scroll-text h3 { font-size: 1.3rem; margin-bottom: 8px; color: #f5f5f7; }
    .scroll-text p { color: #a1a1aa; font-size: 0.95rem; line-height: 1.6; }
  </style>
</head>
<body>
  <div class="spacer">Scroll down ↓</div>

  <div class="scroll-section">
    <div class="scroll-box" id="scrollBox"></div>
    <div class="scroll-text" id="scrollText">
      <h3>This box appears when you scroll to it</h3>
      <p>When 80% of the viewport passes this element, GSAP fades it in, rotates it, and scales it up. If you scroll back past it, it resets.</p>
    </div>
  </div>

  <div class="spacer">keep scrolling</div>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
  <script>
    gsap.registerPlugin(ScrollTrigger);

    gsap.from('#scrollBox', {
      scale: 0.2, rotation: -180, opacity: 0,
      duration: 1, ease: 'power3.out',
      scrollTrigger: {
        trigger: '#scrollBox',
        start: 'top 80%',         // fires when top of box hits 80% down viewport
        toggleActions: 'play none none reverse'
        // actions for: onEnter | onLeave | onEnterBack | onLeaveBack
      }
    });

    gsap.from('#scrollText', {
      x: 80, opacity: 0, duration: 1,
      scrollTrigger: {
        trigger: '#scrollText',
        start: 'top 80%',
        toggleActions: 'play none none reverse'
      }
    });
  </script>
</body>
</html>
// Requires both gsap.min.js and ScrollTrigger.min.js (see HTML tab).
// Matches the demo above (ids #scrollBox and #scrollText).
gsap.registerPlugin(ScrollTrigger);

gsap.from('#scrollBox', {
  scale: 0.2,
  rotation: -180,
  opacity: 0,
  duration: 1,
  ease: 'power3.out',
  scrollTrigger: {
    trigger: '#scrollBox',
    start: 'top 80%',         // fires when top of box hits 80% down viewport
    toggleActions: 'play none none reverse'
    // actions for: onEnter | onLeave | onEnterBack | onLeaveBack
  }
});

gsap.from('#scrollText', {
  x: 80,
  opacity: 0,
  duration: 1,
  scrollTrigger: {
    trigger: '#scrollText',
    start: 'top 80%',
    toggleActions: 'play none none reverse'
  }
});
Pro tip: Add scrub: true to make the animation scrub with the scrollbar, not just trigger once. That's how scroll-driven video-like storytelling works.
Step 3

Easing — the soul of an animation

The same animation with a different ease can feel completely different. GSAP ships with dozens of curves — here are the ones you'll actually use:

// Linear — straight line. Rarely looks natural.
ease: 'none'

// Power (most common) — accelerates or decelerates
ease: 'power1.out'       // subtle
ease: 'power2.out'       // moderate (default)
ease: 'power3.out'       // strong
ease: 'power4.out'       // dramatic

// Back — overshoots the target
ease: 'back.out(1.7)'    // classic "bounce past then settle"

// Elastic — wobbles around the target
ease: 'elastic.out(1, 0.3)'

// Bounce — physical ball bounce
ease: 'bounce.out'

// Custom — your own curve
ease: 'cubic-bezier(.17,.67,.83,.67)'

// Browse all at: greensock.com/ease-visualizer
Cheat Sheet

The whole GSAP core in 30 lines

// Animate TO these values
gsap.to('.box', { x: 200, duration: 1 });

// Animate FROM these values (to the CSS default)
gsap.from('.box', { y: -80, opacity: 0, duration: 0.6 });

// Animate FROM and TO (both ends specified)
gsap.fromTo('.box',
  { opacity: 0, y: 20 },
  { opacity: 1, y: 0, duration: 0.8 }
);

// Set values instantly (no tween)
gsap.set('.box', { scale: 1.2 });

// Kill all running animations on an element
gsap.killTweensOf('.box');

// Timeline — sequence animations
const tl = gsap.timeline({ repeat: -1, yoyo: true });
tl.to('.a', { x: 200 })
  .to('.b', { y: 100 }, '<')       // start with previous
  .to('.c', { rotation: 90 }, '+=0.5'); // 0.5s after previous ends

// Stagger across a group
gsap.to('.item', { opacity: 1, stagger: 0.05 });

// ScrollTrigger — tie to scroll
gsap.to('.hero', {
  y: 100,
  scrollTrigger: {
    trigger: '.hero',
    start: 'top top',
    end: 'bottom top',
    scrub: true
  }
});
Go deeper: gsap.com/docs · the cheat sheet: gsap.com/cheatsheet