Skip to content

Fixed Learn button continuous loop animation#708

Open
paras2707 wants to merge 7 commits intoaccordproject:mainfrom
paras2707:main
Open

Fixed Learn button continuous loop animation#708
paras2707 wants to merge 7 commits intoaccordproject:mainfrom
paras2707:main

Conversation

@paras2707
Copy link

Fix: Infinite Animation Loop on Learn Button in Navbar

Overview

Fixed a bug in the Learn button animation that was continuously looping indefinitely. The button now animates with a controlled, finite pulse effect that respects user accessibility preferences.


Problem Statement

The Learn button on the Navbar was displaying a continuously re-triggering animation. While loop: false did stop the animation from looping infinitely, the animation hook was being re-created on every render/UI state change, causing the animation to restart repeatedly and flash persistently. This created a distracting, flickering effect that disrupted the user experience.

Root Cause

The original implementation used useSpring with a static configuration:

const props = useSpring({
  loop: false,
  from: { opacity: 0.5, ... },
  to: [
    { opacity: 1, ... },
    { opacity: 0.9, ... },
  ],
  config: { duration: 1000 },
});

Issue: The useSpring hook was being recreated on every component render (whenever props or state changed). Since the animation was defined directly in the component body without memoization or effect control, each render would restart the animation. This caused the button to continuously pulse/flash every time the Navbar re-rendered—a much more disruptive problem than an infinite loop.


Solution

Replaced the stateless useSpring configuration with a combination of useSpring + useEffect + controlled animation state, which:

  1. Isolates animation logic – Moves the animation into a useEffect so it only runs once on component mount, not on every render
  2. Prevents re-triggering – Uses the animation API (api.start) within an effect dependency array, ensuring the animation starts only when intended
  3. Enables finite pulses – Animation now pulses exactly 3 times and stops (or 1 time if user prefers reduced motion)
  4. Ensures proper cleanup – Stops the animation on component unmount to prevent memory leaks and stray animations

Changes Made

Before (Lines 193–200)

const props = useSpring({
  loop: false,
  from: { opacity: 0.5, boxShadow: "0px 0px 0px rgba(255, 255, 255, 0)" },
  to: [
    { opacity: 1, boxShadow: "0px 0px 5px rgba(255, 255, 255, 1)" },
    { opacity: 0.9, boxShadow: "0px 0px 0px rgba(255, 255, 255, 0)" },
  ],
  config: { duration: 1000 },
});

After (Lines 200–230)

const [props, api] = useSpring(() => ({
  opacity: 0.9,
  boxShadow: "0px 0px 0px rgba(255, 255, 255, 0)",
}));

useEffect(() => {
  const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const pulseIterations = prefersReducedMotion ? 1 : 3;
  let animationStopped = false;

  const animation = api.start({
    from: { opacity: 0.5, boxShadow: "0px 0px 0px rgba(255, 255, 255, 0)" },
    to: async (next) => {
      for (let i = 0; i < pulseIterations; i += 1) {
        if (animationStopped) {
          return;
        }

        await next({ opacity: 1, boxShadow: "0px 0px 5px rgba(255, 255, 255, 1)", config: { duration: 500 } });
        await next({ opacity: 0.9, boxShadow: "0px 0px 0px rgba(255, 255, 255, 0)", config: { duration: 500 } });
      }
    },
    reset: true,
  });

  return () => {
    api.stop();
    animationStopped = true;
  };
}, [api]);

Key Improvements

Aspect Before After
Animation Trigger Re-triggers on every render ❌ Triggers once on mount only ✅
Animation Iterations 1 per render (repeats infinitely due to re-renders) ⚠️ Controlled 3 pulses total ✅
Accessibility Not considered ❌ Respects prefers-reduced-motion
Component Cleanup No cleanup logic ❌ Proper cleanup on unmount ✅
Memory Safety Potential leak ⚠️ Safe cleanup with animationStopped flag ✅

Behavior Explanation

Animation Flow

The animation now runs once on component mount and completes:

  1. Initial State: Button starts with low opacity (0.5) and no glow
  2. Pulse Cycle (repeats 3 times):
    • Phase 1 (500ms): Opacity → 1.0, glow brightens
    • Phase 2 (500ms): Opacity → 0.9, glow fades
  3. Final State: Button rests at 0.9 opacity with no glow and stays that way

Key Difference: Previously, every UI state change (hover, routing, etc.) would re-render the component and restart the animation. Now, the animation is isolated in a useEffect and only runs once on initial mount, preventing the re-triggering effect.

Accessibility

  • Users who enable "Reduce Motion" in their OS settings will see only 1 pulse instead of 3
  • This respects user preferences and reduces unnecessary motion for those who are motion-sensitive

Cleanup

  • When the component unmounts or dependencies change, the animation is properly stopped and resources are cleaned up
  • Prevents animation from competing with other effects or causing memory leaks

Technical Deep Dive: Why Async/Await?

The animation uses an async function with await statements—this is crucial for controlling the animation sequence:

to: async (next) => {
  for (let i = 0; i < pulseIterations; i += 1) {
    if (animationStopped) return;  // Early exit if component unmounts
    
    // Phase 1: Glow up (500ms)
    await next({ opacity: 1, boxShadow: "...", config: { duration: 500 } });
    
    // Phase 2: Glow down (500ms) - waits for Phase 1 to complete
    await next({ opacity: 0.9, boxShadow: "...", config: { duration: 500 } });
  }
}

Why This Pattern?

1. Sequential vs. Parallel Animation

  • Without await: Both animation phases would try to run simultaneously, overriding each other
  • With await: Each next() call waits for the previous animation to complete (500ms) before starting the next one

2. Control Over Timing

  • await next(...) is a promise that resolves when that specific animation completes
  • This allows precise timing: glow brightens for 500ms, then fades for 500ms (1 second per full pulse)

3. Safety with Component Unmount

  • Between each await, we check if (animationStopped) and exit early if the component unmounted
  • Without this check, animations could continue running on an unmounted component (memory leak)
  • With async/await, we can safely interrupt the animation sequence at any phase

4. Finite Loop Control

  • The for loop with pulseIterations ensures exactly N pulses
  • Each iteration completes both phases (glow up + glow down) before the next iteration
  • When the loop ends, the animation naturally stops—no infinite restart

The Problem Without Async/Await

If the code was:

// ❌ Wrong approach
to: [
  { opacity: 1, ... },  // Starts immediately
  { opacity: 0.9, ... },  // Starts immediately, overrides first animation
  { opacity: 1, ... },  // Keeps looping
]
  • Animations would conflict and layer on top of each other
  • No way to stop at a specific iteration count
  • No opportunity to check animationStopped between phases

Testing Recommendations

  1. Visual Test: Check that the Learn button pulses exactly 3 times on page load, then stops
  2. Accessibility Test:
    • Enable "Reduce Motion" in your OS settings
    • Verify the button only pulses once
  3. Interaction Test: Navigate between pages and confirm animation stops/restarts appropriately
  4. Cross-browser Test: Test on Chrome, Safari, and Firefox to ensure timing consistency

Technical Notes

  • Uses react-spring@^9.7.4 (already in dependencies)
  • Implements the async generator pattern from react-spring for sequential animations
  • No new dependencies added
  • Backward compatible with existing UI/styling

@paras2707 paras2707 requested a review from a team as a code owner February 15, 2026 17:29
@netlify
Copy link

netlify bot commented Feb 15, 2026

Deploy Preview for ap-template-playground ready!

Name Link
🔨 Latest commit 2340f7b
🔍 Latest deploy log https://app.netlify.com/projects/ap-template-playground/deploys/69ac63a31642d400082e3445
😎 Deploy Preview https://deploy-preview-708--ap-template-playground.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Signed-off-by: Paras Thapar <73242340+paras2707@users.noreply.github.com>
Copy link
Member

@mttrbrts mttrbrts left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the loop animation should be removed.

Although, can you instead have the hover behaviour match the Discord and Github buttons, pleasE?

@paras2707
Copy link
Author

I agree that the loop animation should be removed.

Although, can you instead have the hover behaviour match the Discord and Github buttons, pleasE?

Ok, i'll look into it.

@paras2707
Copy link
Author

I’ve addressed the requested changes. Please review again.
Now the Learn button has exact same hover effect as github and discord button in navbar.

@paras2707 paras2707 requested a review from mttrbrts February 22, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants