Debugging Animations with Claude Code

Lesson 15

Animation bugs are uniquely frustrating because they’re invisible — nothing moves, or it moves wrong, and there’s no error in the console. Knowing the five most common GSAP bugs and how to describe them saves hours.

The debugging prompt pattern

Always describe what you see (or don’t see), then paste the code:

이 코드에서 왜 [symptom]이 발생하는지 설명하고 고쳐줘. 변경한 내용만 설명해줘.

[paste your code]

Claude Code will identify the root cause, show the fix, and explain only what changed.

Bug 1 — autoAlpha vs opacity

If you set opacity: 0 in CSS and then gsap.to('.el', { opacity: 1 }), it often works. But if GSAP’s own from set it to 0 first, the element might stay hidden after the animation ends due to inline style conflicts.

Fix: Use autoAlpha instead of opacity when you control visibility with GSAP. autoAlpha sets both opacity and visibility, and GSAP manages the lifecycle cleanly.

// Buggy
gsap.from('.box', { opacity: 0 });

// Fixed
gsap.from('.box', { autoAlpha: 0 });

Bug 2 — ScrollTrigger not firing

Two common causes: the trigger element isn’t in the DOM yet, or start is set so late the trigger never enters the viewport.

Fix: Set start: 'top 80%' so the animation fires before the element fully enters the viewport. Add markers: true during development to see the trigger lines on screen.

scrollTrigger: {
  trigger: '.section',
  start: 'top 80%',
  markers: true,  // ← remove before production
}

Bug 3 — from vs to confusion

gsap.to('.box', { x: 0 }) does nothing if the box is already at x:0. This is the most common “why isn’t this animating?” bug.

Fix: Use gsap.from to animate from a starting value to the current position.

// Buggy — animates TO x:0, but element is already there
gsap.to('.box', { x: 0, opacity: 1 });

// Fixed — animates FROM x:-40 TO current position
gsap.from('.box', { x: -40, opacity: 0 });

Bug 4 — DOM timing

document.querySelectorAll('.item') called before the DOM is ready returns an empty NodeList. GSAP silently animates nothing.

Fix: Wrap your GSAP code in a DOMContentLoaded listener, or place your <script> tag at the end of <body>.

// Buggy — runs before .item elements exist
gsap.from(document.querySelectorAll('.item'), { y: 20 });

// Fixed
window.addEventListener('DOMContentLoaded', () => {
  gsap.from('.item', { y: 20, stagger: 0.1 });
});

Bug 5 — Timeline position collisions

If you use '-=2' on a tween that only lasts 0.5s, you push it before the start of the timeline (negative time). GSAP clips it, the tween never plays, and the sequence looks broken.

Fix: Keep overlap amounts smaller than the previous tween’s duration.

// Buggy — '-=2' exceeds the 0.4s previous tween
tl.from('.a', { opacity: 0, duration: 0.4 })
  .from('.b', { opacity: 0, duration: 0.6 }, '-=2');

// Fixed
tl.from('.a', { opacity: 0, duration: 0.4 })
  .from('.b', { opacity: 0, duration: 0.6 }, '-=0.2');

What the demo shows

The demo is an interactive debug playground with five panels. Each panel has a “Buggy” button and a “Fixed” button that run the broken and corrected animations on a visible target element. A before/after code diff shows exactly what changed for each bug.

Source Code script.js
gsap.registerPlugin(ScrollTrigger);

// ── Nav entrance ──────────────────────────────────────────────
gsap.from('.site-nav', { y: -16, opacity: 0, duration: 0.4, ease: 'power3.out' });

// ── Hero entrance ─────────────────────────────────────────────
gsap.from(['.eyebrow', '.hero h1', '.sub'], {
  y: 20,
  opacity: 0,
  duration: 0.55,
  stagger: 0.1,
  ease: 'power2.out',
});

// ── Bug section headings fade in on scroll ────────────────────
gsap.utils.toArray('.bug-section').forEach(section => {
  gsap.from(section.querySelector('.bug-header'), {
    scrollTrigger: { trigger: section, start: 'top 85%' },
    y: 16,
    opacity: 0,
    duration: 0.5,
    ease: 'power2.out',
  });
});

// ─────────────────────────────────────────────────────────────
// BUG 1: autoAlpha vs opacity
// ─────────────────────────────────────────────────────────────
// The box has style="opacity:0" in HTML.
// Buggy: gsap.from with opacity leaves inline style; element stays invisible.
// Fixed: gsap.from with autoAlpha manages visibility lifecycle.

document.querySelector('[data-bug="1"][data-mode="buggy"]').addEventListener('click', () => {
  // Reset
  gsap.set('#box1', { clearProps: 'all' });
  document.getElementById('box1').style.opacity = '0';

  // Buggy: animate opacity, but inline style wins in some browsers/states
  gsap.from('#box1', {
    opacity: 0,
    y: 20,
    duration: 0.5,
    ease: 'power2.out',
    // This appears to work at first, but the inline style="" can conflict.
    // To make the bug visible we simulate it: after animation, re-apply opacity:0
    onComplete: () => {
      // Simulate: inline opacity:0 wasn't cleared by GSAP, element snaps back
      document.getElementById('box1').style.opacity = '0';
    }
  });
});

document.querySelector('[data-bug="1"][data-mode="fixed"]').addEventListener('click', () => {
  // Clear everything so autoAlpha starts fresh
  gsap.set('#box1', { clearProps: 'all' });

  // Fixed: autoAlpha handles opacity AND visibility. No inline style conflict.
  gsap.from('#box1', {
    autoAlpha: 0,
    y: 20,
    duration: 0.5,
    ease: 'power2.out',
    // Element stays visible after animation completes
  });
});

// ─────────────────────────────────────────────────────────────
// BUG 2: ScrollTrigger start value
// ─────────────────────────────────────────────────────────────
// Buggy: start: 'top top' — fires only when element top hits viewport top.
// Fixed: start: 'top 80%' — fires when element is 20% into viewport.

let st2buggy = null;
let st2fixed = null;

document.querySelector('[data-bug="2"][data-mode="buggy"]').addEventListener('click', () => {
  if (st2buggy) st2buggy.kill();
  if (st2fixed) st2fixed.kill();
  gsap.set('#box2', { clearProps: 'all', x: -60, opacity: 0 });
  updateTriggerInfo('triggerInfo2', false, 'start: top top (likely won\'t fire)');

  st2buggy = ScrollTrigger.create({
    trigger: '#box2',
    start: 'top top', // ← usually never fires for mid-page elements
    onEnter: () => {
      gsap.to('#box2', { x: 0, opacity: 1, duration: 0.6 });
      updateTriggerInfo('triggerInfo2', true, 'Fired! (rare)');
    },
  });
  ScrollTrigger.refresh();
});

document.querySelector('[data-bug="2"][data-mode="fixed"]').addEventListener('click', () => {
  if (st2buggy) st2buggy.kill();
  if (st2fixed) st2fixed.kill();
  gsap.set('#box2', { clearProps: 'all', x: -60, opacity: 0 });
  updateTriggerInfo('triggerInfo2', false, 'Waiting for scroll...');

  st2fixed = ScrollTrigger.create({
    trigger: '#box2',
    start: 'top 80%', // ← fires when element enters viewport
    onEnter: () => {
      gsap.to('#box2', { x: 0, opacity: 1, duration: 0.6 });
      updateTriggerInfo('triggerInfo2', true, 'Fired! ✓');
    },
  });
  ScrollTrigger.refresh();
  // Scroll element into view to demo it
  document.getElementById('box2').scrollIntoView({ behavior: 'smooth', block: 'center' });
});

function updateTriggerInfo(id, active, text) {
  const el = document.getElementById(id);
  if (!el) return;
  el.querySelector('.ti-dot').classList.toggle('active', active);
  el.querySelector('.ti-text').textContent = text;
}

// ─────────────────────────────────────────────────────────────
// BUG 3: from vs to
// ─────────────────────────────────────────────────────────────
// Buggy: gsap.to('#box3', { x: 0 }) — box is already at x:0. Nothing moves.
// Fixed: gsap.from('#box3', { x: -60 }) — box slides in from left.

document.querySelector('[data-bug="3"][data-mode="buggy"]').addEventListener('click', () => {
  gsap.set('#box3', { clearProps: 'all' });
  const box = document.getElementById('box3');
  box.textContent = 'Nothing moves';

  // Buggy: to x:0 from x:0 — no movement
  gsap.to('#box3', {
    x: 0,
    opacity: 1,
    duration: 0.6,
    ease: 'power2.out',
    onComplete: () => { box.textContent = '... still nothing'; }
  });
});

document.querySelector('[data-bug="3"][data-mode="fixed"]').addEventListener('click', () => {
  gsap.set('#box3', { clearProps: 'all' });
  const box = document.getElementById('box3');
  box.textContent = 'Slides in ✓';

  // Fixed: from x:-60, animates into natural position
  gsap.from('#box3', {
    x: -60,
    opacity: 0,
    duration: 0.6,
    ease: 'power2.out',
  });
});

// ─────────────────────────────────────────────────────────────
// BUG 4: DOM timing
// ─────────────────────────────────────────────────────────────
// Buggy: querySelectorAll called before items ready (we simulate the empty list).
// Fixed: query inside DOMContentLoaded callback (or after DOM ready).

document.querySelector('[data-bug="4"][data-mode="buggy"]').addEventListener('click', () => {
  // Simulate the bug: pass an empty NodeList to GSAP
  gsap.set('.list-item', { clearProps: 'all', opacity: 0 });

  const emptyList = document.querySelectorAll('.list-item-DOESNT-EXIST');
  // emptyList.length === 0 → GSAP animates nothing
  gsap.from(emptyList, {
    y: 20,
    opacity: 0,
    stagger: 0.1,
    duration: 0.5,
    // Nothing happens — items stay hidden
    onComplete: () => {
      // Reveal them after a pause to show they were never animated
      setTimeout(() => {
        gsap.set('.list-item', { opacity: 0.25 });
      }, 800);
    }
  });
});

document.querySelector('[data-bug="4"][data-mode="fixed"]').addEventListener('click', () => {
  gsap.set('.list-item', { clearProps: 'all' });

  // Fixed: DOM is already ready by the time this click fires
  // In real code you'd wrap in DOMContentLoaded
  gsap.from('.list-item', {
    y: 20,
    opacity: 0,
    stagger: 0.1,
    duration: 0.5,
    ease: 'power2.out',
  });
});

// ─────────────────────────────────────────────────────────────
// BUG 5: Timeline position collision
// ─────────────────────────────────────────────────────────────
// Buggy: '-=2' overlap on a 0.4s tween → pushes B to negative time.
// Fixed: '-=0.2' overlap — safe, B plays just before A ends.

document.querySelector('[data-bug="5"][data-mode="buggy"]').addEventListener('click', () => {
  gsap.set(['#tl5a', '#tl5b'], { clearProps: 'all' });

  const tl = gsap.timeline();
  tl.from('#tl5a', { opacity: 0, y: 16, duration: 0.4 })
    // '-=2' exceeds 0.4s duration → B pushed to t < 0 → clipped → B appears to snap
    .from('#tl5b', { opacity: 0, y: 16, duration: 0.6 }, '-=2');
});

document.querySelector('[data-bug="5"][data-mode="fixed"]').addEventListener('click', () => {
  gsap.set(['#tl5a', '#tl5b'], { clearProps: 'all' });

  const tl = gsap.timeline();
  tl.from('#tl5a', { opacity: 0, y: 16, duration: 0.4 })
    // '-=0.2' is safe: B starts 0.2s before A ends, smooth overlap
    .from('#tl5b', { opacity: 0, y: 16, duration: 0.6 }, '-=0.2');
});