css-transition-stuck-spa-navigation

star 0

Fix CSS transitions that get "stuck" at their initial state (opacity: 0, transform unchanged) when toggling classes during SPA page navigation. Use when: (1) elements have CSS transition on opacity/transform but remain invisible after adding a `.visible` class, (2) even inline `style="opacity: 1 !important"` doesn't change computed opacity, (3) scroll-reveal animations work on initial page load but fail after navigating between SPA pages, (4) double requestAnimationFrame trick doesn't fix the stuck transition. Common in IntersectionObserver-based reveal systems with show/hide page sections.

hubeiqiao By hubeiqiao schedule Updated 3/4/2026

name: css-transition-stuck-spa-navigation description: | Fix CSS transitions that get "stuck" at their initial state (opacity: 0, transform unchanged) when toggling classes during SPA page navigation. Use when: (1) elements have CSS transition on opacity/transform but remain invisible after adding a .visible class, (2) even inline style="opacity: 1 !important" doesn't change computed opacity, (3) scroll-reveal animations work on initial page load but fail after navigating between SPA pages, (4) double requestAnimationFrame trick doesn't fix the stuck transition. Common in IntersectionObserver-based reveal systems with show/hide page sections. author: Claude Code version: 1.0.0 date: 2026-02-08

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

Install via CLI
npx skills add https://github.com/hubeiqiao/skills --skill css-transition-stuck-spa-navigation
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator