css-transition-stuck-spa-navigation
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: blockor similar - CSS transitions defined on
.revealelements:transition: opacity 0.7s, transform 0.7s .visibleclass 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
.visibleclass IS present getComputedStyle(el).opacityreturns"0"despite.reveal.visible { opacity: 1 }- Setting
el.style.opacity = '1'or even'1 !important'has NO effect - The double
requestAnimationFramepattern does NOT fix it - Elements work correctly on initial page load but fail after navigation
- Elements remain invisible (opacity: 0) even though
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
transition: noneensures the class removal instantly sets opacity to 0 (no animation)void container.offsetHeightforces a synchronous layout/paint, committing the opacity:0 statetransition: ''restores the CSS transition rules- Second
void container.offsetHeightcommits the transition property change - Now when
.visibleis 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:
- Navigate between SPA pages by clicking nav links
- Elements at the top of each page should fade in (opacity 0 -> 1) with smooth transition
- Check with:
getComputedStyle(el).opacityshould return"1"after transition completes - 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
.visibleaddition creates a real state change - The issue specifically manifests when REMOVING then RE-ADDING the same class
void element.offsetHeightis 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), henceforceCheckViewport() - The
prefers-reduced-motionmedia query should setopacity: 1; transform: nonedirectly (no transition) to bypass this entire mechanism for accessibility
References
- MDN: Using CSS transitions
- MDN: HTMLElement.offsetHeight (forcing reflow)
- CSS Tricks: Restart CSS Animation - related pattern for restarting animations