Each panel shows a broken animation and its fix. Press "Buggy" to see the problem, "Fixed" to see it corrected.
Element set invisible in CSS, GSAP tries to show it — but it stays hidden because of inline style conflicts.
// opacity:0 set in HTML style=""
// GSAP tries to animate from opacity:0
gsap.from('#box1', {
opacity: 0, // ← conflicts with
y: 20, // inline style
duration: 0.5
});
// Use autoAlpha: GSAP manages
// both opacity AND visibility
gsap.from('#box1', {
autoAlpha: 0, // ← handles lifecycle
y: 20,
duration: 0.5
});
Why it happens: autoAlpha sets visibility: hidden at start and removes the inline style on completion. Plain opacity leaves the inline style behind and fights with CSS.
start: 'top top' only fires when the element's top edge reaches the very top of the viewport — often too late or never.
gsap.from('#box2', {
scrollTrigger: {
trigger: '#box2',
start: 'top top', // ← fires too late
},
x: -60,
opacity: 0,
duration: 0.6,
});
gsap.from('#box2', {
scrollTrigger: {
trigger: '#box2',
start: 'top 80%', // ← fires early
}, // enough
x: -60,
opacity: 0,
duration: 0.6,
});
Why it happens: 'top top' means the element's top must reach the viewport's top. For elements partway down the page, this never happens during normal scrolling. Use 'top 80%' to fire earlier. Add markers: true during development to see exactly where triggers are.
gsap.to('.box', { x: 0 }) — box is already at x:0, so nothing happens. The most common "why won't it move?" bug.
// Animates TO x:0, opacity:1
// but the box is already there!
gsap.to('#box3', {
x: 0, // ← already 0
opacity: 1, // ← already 1
duration: 0.6,
ease: 'power2.out',
});
// Animates FROM x:-60 TO its
// natural (current) position
gsap.from('#box3', {
x: -60, // ← start here
opacity: 0, // ← fade in
duration: 0.6,
ease: 'power2.out',
});
Why it happens: gsap.to() animates to the values you provide. gsap.from() animates from the values you provide, ending at the element's current natural position. For entrance animations, you almost always want from.
querySelectorAll called before elements exist returns an empty NodeList. GSAP silently animates nothing.
// Script in <head>, runs before
// .list-item elements exist
const items =
document.querySelectorAll('.list-item');
// items.length === 0 ← empty!
gsap.from(items, {
y: 20,
opacity: 0,
stagger: 0.1,
});
// Wait for DOM to be ready
window.addEventListener(
'DOMContentLoaded',
() => {
gsap.from('.list-item', {
y: 20,
opacity: 0,
stagger: 0.1,
duration: 0.5,
});
}
);
Why it happens: If <script> runs in <head> before the HTML below it is parsed, the elements don't exist yet. Fix: wrap in DOMContentLoaded, or place your script tag at the end of <body>.
Using '-=2' on a tween that only lasts 0.4s pushes it to negative time. The tween never plays.
// A lasts 0.4s. Using '-=2' pushes
// B to t=-1.6 → clipped, never plays
tl.from('#tl5a', {
opacity: 0,
duration: 0.4,
})
.from('#tl5b', {
opacity: 0,
duration: 0.6,
}, '-=2'); // ← overlaps too much!
// Keep overlap smaller than
// the previous tween's duration
tl.from('#tl5a', {
opacity: 0,
duration: 0.4,
})
.from('#tl5b', {
opacity: 0,
duration: 0.6,
}, '-=0.2'); // ← safe overlap
Why it happens: GSAP positions tweens on a timeline using real time values. If '-=2' would place a tween before t=0, GSAP clips it. The tween starts at 0 but has already "missed" its beginning, so it may appear to skip or not play at all. Keep overlaps smaller than the previous tween duration.