All Notes
All Notes

Theme toggle and carousels stopped working after navigating. View Transitions swap the DOM, killing event listeners. Web Components' connectedCallback() fires on every DOM insertion, fixing it.

#astro#view-transitions#web-components#debugging#tonyseets

View Transitions Gotcha#

The Symptom#

Built this site’s homepage with a theme toggle and a few carousels. Everything seemed to be working perfectly on first explorations. Then I clicked around the site a bit and came back to the homepage.

Suddenly, nothing worked. The theme toggle didn’t respond. Carousel arrows did nothing and lost their state.

The Confusing Part#

CSS hover states still worked fine. The elements were clearly there, responding to mouse movement. But clicks? Completely ignored.

Even weirder: clicking the logo when already on the homepage triggered the same death. Same page, “fresh” navigation via View Transitions, and all my event listeners were gone.

The Investigation#

My first instinct was to reach for Astro’s lifecycle events. I tried listening for astro:page-load:

js
document.addEventListener('astro:page-load', () => {
  // Re-attach listeners here
  setupThemeToggle();
  setupCarousel();
});

This worked, but it felt wrong. Now I’m managing lifecycle manually, duplicating initialization logic, and debugging race conditions.

Then I tried event delegation—attaching listeners to a parent that wouldn’t get swapped:

js
document.body.addEventListener('click', (e) => {
  if (e.target.matches('[data-theme-toggle]')) {
    // handle theme toggle
  }
});

Better, but now I’m writing conditionals for every interactive element. Plus, keyboard events and focus management get messy fast.

The Root Cause#

Astro’s View Transitions feature swaps the DOM on navigation. It’s fast and smooth, but here’s what happens under the hood:

  1. You navigate to a new page (or trigger same-page navigation)
  2. Astro fetches the new HTML
  3. It replaces the DOM content
  4. Your old elements (and their attached event listeners) get garbage collected
  5. Fresh DOM, zero listeners
Click linkFetch new page HTMLRemove from documentInsert into documentClick buttonEvent listeners<br/>garbage collectedFresh elements<br/>zero listenersNothing happensUserAstroOld DOMNew DOM

Your event handlers are attached to elements that no longer exist.

Web Components#

After bouncing between workarounds, I landed on Web Components. They solve this exact problem:

html
<theme-toggle>
  <button data-theme="light">Toggle Theme</button>
</theme-toggle>

<script>
  class ThemeToggle extends HTMLElement {
    connectedCallback() {
      this.querySelector('button')?.addEventListener('click', () => {
        // Toggle theme logic
      });
    }
  }
  customElements.define('theme-toggle', ThemeToggle);
</script>

connectedCallback() is a browser-native lifecycle hook that fires every time the element enters the DOM, including after View Transitions swap the content.

No manual lifecycle management. No event delegation boilerplate. No framework dependencies. The browser just handles it.

How It Works#

Web Components are framework-agnostic primitives. When Astro swaps the DOM:

  1. The old <theme-toggle> element gets removed
  2. The new <theme-toggle> element enters the DOM
  3. connectedCallback() fires automatically
  4. Listeners get attached to the fresh element
Click linkRemove from documentInsert into documentClick buttonEvent fires!disconnectedCallback()connectedCallback()<br/>attaches listenersUserAstroOld Web ComponentNew Web Component

It’s self-healing by design. Every navigation is effectively a fresh mount.

The Pattern#

Now all my interactive components follow this pattern:

html
<carousel-controls>
  <button data-action="prev">Previous</button>
  <button data-action="next">Next</button>
</carousel-controls>

<script>
  class CarouselControls extends HTMLElement {
    connectedCallback() {
      const prevBtn = this.querySelector('[data-action="prev"]');
      const nextBtn = this.querySelector('[data-action="next"]');

      prevBtn?.addEventListener('click', () => {
        // Previous slide logic
      });

      nextBtn?.addEventListener('click', () => {
        // Next slide logic
      });
    }
  }
  customElements.define('carousel-controls', CarouselControls);
</script>

No external dependencies. Vanilla web standards that play nice with View Transitions.

Real-World Example: ScheduleModal#

This exact pattern fixed a bug with this site’s schedule modal. The modal uses Cal.com’s embed, which has its own theme system that needs to sync with the site’s light/dark mode.

The original code captured DOM references once in an IIFE:

js
// ❌ Stale references after View Transitions
(function() {
  const modal = document.getElementById('schedule-modal');
  const calEmbed = document.getElementById('cal-embed');
  // These become stale after DOM swap...
})();

After View Transitions, modal pointed to a removed element. Clicking “Schedule a Call” called openModal() on a ghost element. Nothing visible happened.

The fix: wrap in a Web Component with fresh DOM queries in connectedCallback():

js
// ✅ Fresh references after every View Transition
class ScheduleModal extends HTMLElement {
  connectedCallback() {
    this.modal = this.querySelector('#schedule-modal');
    this.calEmbed = this.querySelector('#cal-embed');
    // Fresh references every time!
  }
}

One extra detail: Cal.com’s SDK maintains global state in window.Cal. We track initialization state in window.__calState so the Web Component can check if Cal was already loaded and just sync the UI:

js
window.__calState = window.__calState || {
  initialized: false,
  loaded: false
};

// In connectedCallback:
if (window.__calState.loaded) {
  this.calEmbed?.classList.add('loaded');
  this.calLoading?.classList.add('hidden');
}

This separates “global SDK state” from “local DOM state”. The SDK persists across navigations, but DOM references get refreshed.

What I Learned#

View Transitions swap the entire DOM, not just content. Your event listeners get tossed with the old elements. Lifecycle events like astro:page-load work but feel like fighting the framework. Web Components’ connectedCallback() is purpose-built for this since it fires on every DOM insertion.

Same-page navigation triggers View Transitions too, not just route changes. And Web Components aren’t heavy. You don’t need a framework, just class and customElements.define().

One more thing: separate global state from DOM state. Third-party SDKs may persist across navigations while your DOM references go stale.

References#


When using View Transitions, wrap interactive elements in Web Components. Let connectedCallback() handle the lifecycle instead of fighting it manually.

Theme toggle and carousels stopped working after navigating. View Transitions swap the DOM, killing event listeners. Web Components' connectedCallback() fires on every DOM insertion, fixing it.