# CSS Transition Stuck Spa Navigation

> 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.

- Skill: `hubeiqiao/css-transition-stuck-spa-navigation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hubeiqiao/css-transition-stuck-spa-navigation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hubeiqiao/css-transition-stuck-spa-navigation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: hubeiqiao (https://skillmd.com/u/hubeiqiao)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hubeiqiao/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: 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:

```javascript
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

```javascript
// 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

```html
<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

- [MDN: Using CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions)
- [MDN: HTMLElement.offsetHeight (forcing reflow)](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetHeight)
- [CSS Tricks: Restart CSS Animation](https://css-tricks.com/restart-css-animation/) - related pattern for restarting animations

