Skip to content

Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues

FixDevs ·

Quick Answer

How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.

The Problem

A GSAP tween is created but the element doesn’t animate:

import gsap from 'gsap';

gsap.to('.box', { x: 200, duration: 1 });
// Element doesn't move — no error

Or ScrollTrigger animations don’t fire on scroll:

import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);
gsap.to('.hero', {
  scrollTrigger: '.hero',
  y: -100,
  opacity: 0,
});
// Scrolling past .hero — nothing happens

Or React re-renders cause animation glitches:

Animation plays once, then on state change the element jumps or the animation replays

Why This Happens

GSAP animates DOM elements directly. In frameworks like React where the DOM is managed by a virtual DOM, timing and cleanup matter:

  • The element must exist when the tween is createdgsap.to('.box', ...) queries the DOM at call time. If the element hasn’t mounted yet (e.g., tween in module scope or before useEffect), it targets nothing.
  • ScrollTrigger needs the page to be scrollable — if the content doesn’t exceed the viewport height, there’s nothing to scroll and triggers never fire. Also, ScrollTrigger must be registered before use.
  • React re-renders create duplicate animations — without cleanup, every render creates a new tween on the same element. GSAP tweens stack, causing flickering. Use gsap.context() or useGSAP for proper cleanup.
  • GSAP plugins must be registeredScrollTrigger, SplitText, DrawSVG, and other plugins must be registered with gsap.registerPlugin() before use. Forgetting this causes silent failures.

Fix 1: React Integration with useGSAP

npm install gsap @gsap/react
'use client';

import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { useRef } from 'react';

gsap.registerPlugin(useGSAP);

function AnimatedBox() {
  const containerRef = useRef<HTMLDivElement>(null);

  // useGSAP handles cleanup automatically on unmount/re-render
  useGSAP(() => {
    // All animations in this callback are scoped to containerRef
    gsap.to('.box', {
      x: 200,
      rotation: 360,
      duration: 1,
      ease: 'power2.inOut',
    });

    gsap.from('.title', {
      opacity: 0,
      y: 50,
      duration: 0.8,
      ease: 'power3.out',
    });
  }, { scope: containerRef });  // Scope selectors to this container

  return (
    <div ref={containerRef}>
      <h1 className="title">Hello GSAP</h1>
      <div className="box" style={{ width: 100, height: 100, background: 'blue' }} />
    </div>
  );
}

// With dependencies — re-run animation when state changes
function DynamicAnimation({ count }: { count: number }) {
  const ref = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    gsap.to('.counter', {
      scale: 1.2,
      duration: 0.3,
      yoyo: true,
      repeat: 1,
    });
  }, { scope: ref, dependencies: [count] });

  return (
    <div ref={ref}>
      <span className="counter">{count}</span>
    </div>
  );
}

Fix 2: Timelines

'use client';

import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { useRef } from 'react';

function HeroAnimation() {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

    tl.from('.hero-title', { opacity: 0, y: 80, duration: 1 })
      .from('.hero-subtitle', { opacity: 0, y: 40, duration: 0.8 }, '-=0.5')
      .from('.hero-cta', { opacity: 0, scale: 0.8, duration: 0.6 }, '-=0.3')
      .from('.hero-image', {
        opacity: 0,
        x: 100,
        duration: 1,
        ease: 'power2.out',
      }, '-=0.8');
  }, { scope: containerRef });

  return (
    <div ref={containerRef}>
      <h1 className="hero-title">Build Better Apps</h1>
      <p className="hero-subtitle">The modern development platform</p>
      <button className="hero-cta">Get Started</button>
      <img className="hero-image" src="/hero.png" alt="Hero" />
    </div>
  );
}

// Stagger animation — animate a list of items
function StaggerList() {
  const ref = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    gsap.from('.list-item', {
      opacity: 0,
      y: 30,
      stagger: 0.1,  // 100ms between each item
      duration: 0.6,
      ease: 'power2.out',
    });
  }, { scope: ref });

  return (
    <div ref={ref}>
      {items.map(item => (
        <div key={item.id} className="list-item">{item.name}</div>
      ))}
    </div>
  );
}

Fix 3: ScrollTrigger

# ScrollTrigger is included in gsap package
'use client';

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
import { useRef } from 'react';

gsap.registerPlugin(ScrollTrigger);

function ScrollAnimations() {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // Fade in on scroll
    gsap.from('.fade-section', {
      scrollTrigger: {
        trigger: '.fade-section',
        start: 'top 80%',    // When top of element hits 80% of viewport
        end: 'top 20%',
        toggleActions: 'play none none reverse',
        // play, pause, resume, reverse, restart, reset, complete, none
      },
      opacity: 0,
      y: 60,
      duration: 1,
    });

    // Pin section while scrolling
    gsap.to('.pinned-content', {
      scrollTrigger: {
        trigger: '.pinned-section',
        start: 'top top',
        end: '+=500',        // Pin for 500px of scroll
        pin: true,
        scrub: 1,            // Smooth 1-second scrub
      },
      x: 300,
      rotation: 360,
    });

    // Parallax effect
    gsap.to('.parallax-bg', {
      scrollTrigger: {
        trigger: '.parallax-section',
        start: 'top bottom',
        end: 'bottom top',
        scrub: true,
      },
      y: -200,  // Move slower than scroll
    });

    // Progress indicator
    gsap.to('.progress-bar', {
      scrollTrigger: {
        trigger: 'body',
        start: 'top top',
        end: 'bottom bottom',
        scrub: 0.3,
      },
      scaleX: 1,
      transformOrigin: 'left center',
    });
  }, { scope: containerRef });

  return (
    <div ref={containerRef}>
      <div className="progress-bar" style={{
        position: 'fixed', top: 0, left: 0, right: 0,
        height: 3, background: 'blue', scaleX: 0, zIndex: 50,
      }} />

      <section className="fade-section" style={{ minHeight: '100vh', padding: '4rem' }}>
        <h2>This fades in on scroll</h2>
      </section>

      <section className="pinned-section" style={{ minHeight: '100vh' }}>
        <div className="pinned-content">Pinned while scrolling</div>
      </section>

      <section className="parallax-section" style={{ minHeight: '100vh', position: 'relative', overflow: 'hidden' }}>
        <div className="parallax-bg" style={{
          position: 'absolute', inset: '-200px 0',
          background: 'url(/bg.jpg) center/cover',
        }} />
      </section>
    </div>
  );
}

Fix 4: Interactive Animations (Hover, Click)

'use client';

import gsap from 'gsap';
import { useRef } from 'react';

function MagneticButton({ children }: { children: React.ReactNode }) {
  const buttonRef = useRef<HTMLButtonElement>(null);

  function handleMouseMove(e: React.MouseEvent) {
    const btn = buttonRef.current;
    if (!btn) return;

    const rect = btn.getBoundingClientRect();
    const x = e.clientX - rect.left - rect.width / 2;
    const y = e.clientY - rect.top - rect.height / 2;

    gsap.to(btn, {
      x: x * 0.3,
      y: y * 0.3,
      duration: 0.3,
      ease: 'power2.out',
    });
  }

  function handleMouseLeave() {
    gsap.to(buttonRef.current, {
      x: 0,
      y: 0,
      duration: 0.5,
      ease: 'elastic.out(1, 0.3)',
    });
  }

  return (
    <button
      ref={buttonRef}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      className="px-8 py-4 bg-blue-500 text-white rounded-lg text-lg font-bold"
    >
      {children}
    </button>
  );
}

// Click to animate
function ExpandCard() {
  const cardRef = useRef<HTMLDivElement>(null);
  const [expanded, setExpanded] = useState(false);

  function toggle() {
    setExpanded(!expanded);

    if (!expanded) {
      gsap.to(cardRef.current, { height: 300, duration: 0.5, ease: 'power2.inOut' });
      gsap.to('.card-content', { opacity: 1, y: 0, duration: 0.3, delay: 0.2 });
    } else {
      gsap.to('.card-content', { opacity: 0, y: 20, duration: 0.2 });
      gsap.to(cardRef.current, { height: 80, duration: 0.5, ease: 'power2.inOut', delay: 0.1 });
    }
  }

  return (
    <div ref={cardRef} onClick={toggle} style={{ height: 80, overflow: 'hidden', cursor: 'pointer' }}>
      <h3>Click to expand</h3>
      <div className="card-content" style={{ opacity: 0 }}>
        <p>Hidden content revealed with animation</p>
      </div>
    </div>
  );
}

Fix 5: Text Animations

'use client';

import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
import { useRef } from 'react';

gsap.registerPlugin(SplitText);

// Note: SplitText requires a GSAP Club membership
// For free alternative, split text manually:

function TypewriterText({ text }: { text: string }) {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    // Manual text splitting (free)
    const chars = containerRef.current?.querySelectorAll('.char');
    if (!chars) return;

    gsap.from(chars, {
      opacity: 0,
      y: 20,
      stagger: 0.03,
      duration: 0.5,
      ease: 'power2.out',
    });
  }, { scope: containerRef });

  return (
    <div ref={containerRef}>
      <h1>
        {text.split('').map((char, i) => (
          <span key={i} className="char" style={{ display: 'inline-block' }}>
            {char === ' ' ? '\u00A0' : char}
          </span>
        ))}
      </h1>
    </div>
  );
}

// Word-by-word reveal
function WordReveal({ text }: { text: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    gsap.from('.word', {
      opacity: 0,
      y: 40,
      stagger: 0.08,
      duration: 0.6,
      ease: 'power3.out',
      scrollTrigger: {
        trigger: ref.current,
        start: 'top 80%',
      },
    });
  }, { scope: ref });

  return (
    <p ref={ref}>
      {text.split(' ').map((word, i) => (
        <span key={i} className="word" style={{ display: 'inline-block', marginRight: '0.3em' }}>
          {word}
        </span>
      ))}
    </p>
  );
}

Fix 6: Next.js App Router Considerations

// GSAP is client-only — always use 'use client'
'use client';

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

// Register plugins once at module level
gsap.registerPlugin(ScrollTrigger);

// Clean up ScrollTrigger on route changes
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';

function GSAPCleanup() {
  const pathname = usePathname();

  useEffect(() => {
    // Refresh ScrollTrigger when route changes
    ScrollTrigger.refresh();

    return () => {
      // Kill all ScrollTriggers on route change
      ScrollTrigger.getAll().forEach(t => t.kill());
    };
  }, [pathname]);

  return null;
}

// Add to layout
// <GSAPCleanup />

Still Not Working?

Animation targets nothinggsap.to('.box', ...) queries the DOM when called. In React, wrap animations in useGSAP (or useLayoutEffect) so the DOM exists. Also set scope to a container ref so selectors are scoped, avoiding conflicts with other components.

ScrollTrigger doesn’t fire — the page must be scrollable (content must exceed viewport height). Check start and end values — 'top 80%' means “when the trigger’s top reaches 80% of the viewport.” Use ScrollTrigger.refresh() after dynamic content loads. Add markers: true during development to visualize trigger positions.

Animations replay or glitch on re-render — without cleanup, each render creates duplicate tweens. Use useGSAP from @gsap/react which automatically cleans up on unmount. If using raw useEffect, return a cleanup function: return () => { ctx.revert(); }.

GSAP works locally but not in production — check that all GSAP plugins are registered before use. Tree-shaking can remove unused plugins if they’re not imported. Import and register explicitly: import { ScrollTrigger } from 'gsap/ScrollTrigger'; gsap.registerPlugin(ScrollTrigger);.

For related animation issues, see Fix: Framer Motion Not Working and Fix: React Three Fiber Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles