hubeiqiao/skills · Archived

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 s…

First seen Apr 16, 2026

Installation

$ npx skills add hubeiqiao/skills --skill css-transition-stuck-spa-navigation

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from hubeiqiao/skills · top by installs.

npx skills add hubeiqiao/skills

Browse all from hubeiqiao/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Default branch main
Open issues 0
Status Archived

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
Declared agents claude-code

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 7,130 B
  • docs SUMMARY.md 640 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

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