skills/hubeiqiao/skills/css-transition-stuck-spa-navigation

css-transition-stuck-spa-navigation

Installation
SKILL.md

CSS Transition Stuck in SPA Page Navigation

Problem

In single-page applications that show/hide page sections (via display: none/display: block), CSS transitions on scroll-reveal elements get "stuck" at their initial state after navigating between pages. Elements have opacity: 0; transform: translateY(24px) and adding a .visible class (which sets opacity: 1; transform: translateY(0)) doesn't trigger the transition. The computed style remains at opacity: 0 even with !important inline styles.

Context / Trigger Conditions

  • SPA with page sections toggled via display: none/display: block or similar
  • CSS transitions defined on .reveal elements: transition: opacity 0.7s, transform 0.7s
  • .visible class that changes opacity/transform to final values
  • Navigation function that: removes .visible, then re-adds it (directly or via observer)
  • Symptoms:
    • Elements remain invisible (opacity: 0) even though .visible class IS present
    • getComputedStyle(el).opacity returns "0" despite .reveal.visible { opacity: 1 }
    • Setting el.style.opacity = '1' or even '1 !important' has NO effect
    • The double requestAnimationFrame pattern does NOT fix it
    • Elements work correctly on initial page load but fail after navigation

Root Cause

When the browser removes and re-adds a CSS class in the same paint cycle (even across two requestAnimationFrame callbacks), it batches both operations into a single style recalculation. The browser sees the element go from opacity: 0 (base) to opacity: 0 (class removed) to opacity: 1 (class re-added) all in one paint. Since the transition start and end states are computed together, no transition is triggered, and the element remains at the base CSS value (opacity: 0).

The double requestAnimationFrame is commonly recommended but doesn't reliably fix this because modern browsers may still batch operations across RAF callbacks in certain conditions (especially when the element's parent was just toggled from display: none).

Solution

Force a synchronous reflow between removing and adding the class, and temporarily disable transitions during the reset phase:

function resetAndInitReveal(container) {
  const revealEls = container.querySelectorAll('.reveal, .reveal-scale, .reveal-left');

  // Step 1: Remove visible class AND disable transitions
  revealEls.forEach(el => {
    el.classList.remove('visible');
    el.style.transition = 'none';  // Kill transition so reset is instant
  });

  // Step 2: Force reflow - browser MUST paint the opacity:0 state
  void container.offsetHeight;

  // Step 3: Re-enable transitions
  revealEls.forEach(el => {
    el.style.transition = '';  // Restore CSS-defined transition
  });

  // Step 4: Force another reflow so transition re-enable takes effect
  void container.offsetHeight;

  // Step 5: Now add .visible - transition will animate from 0 to 1
  initScrollReveal();      // Set up IntersectionObserver
  forceCheckViewport();    // Immediately reveal elements already in viewport
}

function forceCheckViewport() {
  const els = document.querySelectorAll(
    '.reveal:not(.visible), .reveal-scale:not(.visible), .reveal-left:not(.visible)'
  );
  els.forEach(el => {
    const rect = el.getBoundingClientRect();
    if (rect.top < window.innerHeight - 50 && rect.bottom > 0) {
      el.classList.add('visible');
    }
  });
}

Why This Works

  1. transition: none ensures the class removal instantly sets opacity to 0 (no animation)
  2. void container.offsetHeight forces a synchronous layout/paint, committing the opacity:0 state
  3. transition: '' restores the CSS transition rules
  4. Second void container.offsetHeight commits the transition property change
  5. Now when .visible is added, the browser sees a real state change (0 -> 1) with a transition defined, so it animates

Why Double RAF Doesn't Work

// THIS DOES NOT RELIABLY WORK:
revealEls.forEach(el => el.classList.remove('visible'));
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    // Browser may still batch this with the removal above
    el.classList.add('visible');  // Transition doesn't fire
  });
});

The browser can optimize across RAF callbacks, especially when the element's container was just toggled from display: none to display: block.

Verification

After applying the fix:

  1. Navigate between SPA pages by clicking nav links
  2. Elements at the top of each page should fade in (opacity 0 -> 1) with smooth transition
  3. Check with: getComputedStyle(el).opacity should return "1" after transition completes
  4. Elements below the viewport should remain at opacity 0 until scrolled into view

Example

<style>
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.7s ease, transform 0.7s ease;
}
.reveal.visible {
  opacity: 1;
  transform: translateY(0);
}
.page-section { display: none; }
.page-section.active { display: block; }
</style>

<section id="home" class="page-section active">
  <div class="reveal">Content here</div>
</section>
<section id="about" class="page-section">
  <div class="reveal">About content</div>
</section>

Notes

  • This issue does NOT occur on initial page load because elements start at their base CSS state and the first .visible addition creates a real state change
  • The issue specifically manifests when REMOVING then RE-ADDING the same class
  • void element.offsetHeight is the standard way to force synchronous reflow in JavaScript
  • Other reflow-triggering properties also work: offsetWidth, getComputedStyle(el).opacity, etc.
  • If using IntersectionObserver, you must also handle elements that are already in the viewport when the page switches (they won't trigger an intersection event), hence forceCheckViewport()
  • The prefers-reduced-motion media query should set opacity: 1; transform: none directly (no transition) to bypass this entire mechanism for accessibility

References

Weekly Installs
1
First Seen
7 days ago