| 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-08T00:00:00.000Z" |
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');
revealEls.forEach(el => {
el.classList.remove('visible');
el.style.transition = 'none';
});
void container.offsetHeight;
revealEls.forEach(el => {
el.style.transition = '';
});
void container.offsetHeight;
initScrollReveal();
forceCheckViewport();
}
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: none ensures the class removal instantly sets opacity to 0 (no animation)
void container.offsetHeight forces a synchronous layout/paint, committing the opacity:0 state
transition: '' restores the CSS transition rules
- Second
void container.offsetHeight commits the transition property change
- 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
revealEls.forEach(el => el.classList.remove('visible'));
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('visible');
});
});
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).opacity should 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
.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