Back to Article index

Migrating to Astro 3 Without Breaking View Transitions

Notes on the View Transitions and dark-mode changes I needed when updating this site to Astro 3.

Published
October 31, 2023
Read in Japanese

After updating this site to Astro 3, dark mode worked only on the first paint. Reload the page and it went dark correctly; follow any internal link and it snapped back to light. View Transitions were the cause, and the fix was one line:

document.addEventListener("astro:after-swap", setup);

Here is why that line is needed, along with the rest of what the migration required.

How the View Transition API works

Calling document.startViewTransition asks the browser to capture the current page. The callback passed to the function updates the DOM, after which the browser captures the new state and crossfades between the two by default.

Those captured states are exposed as CSS pseudo-elements, which makes custom animations possible. One API and a set of transition pseudo-elements can express an entire page transition.

What changed

This site uses Tailwind CSS dark mode by adding a dark class to the html element based on a value in localStorage. The script lived in the body, and after the Astro 3 update it ran only on the initial load. Following a client-side navigation, the site returned to light mode regardless of the stored preference.

Running the setup again when Astro fires astro:after-swap, after it replaces the page, restores the expected behavior. The event is documented, and the implementation is small:

<script is:inline>
  function setup() {
    if (
      localStorage.theme === "dark" ||
      (!("theme" in localStorage) &&
        window.matchMedia("(prefers-color-scheme: dark)").matches)
    ) {
      document.documentElement.classList.add("dark");
      localStorage.theme = "dark";
    } else {
      document.documentElement.classList.remove("dark");
      localStorage.theme = "light";
    }
  }
  // Run this manually because the event does not fire on the initial load
  setup();
  document.addEventListener("astro:after-swap", setup);
</script>

Why the script did not run again

With Astro’s client-side router enabled, Astro handles internal links and browser history navigation instead of performing a full page load. The router does more than call document.startViewTransition; it also replaces the DOM and decides how scripts on the new page should be handled.

As shown in this version of router.ts, Astro checks a custom data-astro-exec attribute when deciding whether a script needs to run:

for (const script of Array.from(document.scripts)) {
  // The condition is evaluated here
  if (script.dataset.astroExec === "") continue;
  // ...
}

That branch is why my inline setup script did not execute on every navigation. Listening for the lifecycle event gave it an explicit point at which to update the theme.

Closing note

The View Transition API itself is compact, but a production navigation system also has to account for fallbacks, animation, DOM replacement, and work that must happen around a transition. Astro handles most of that and exposes lifecycle events for the application-specific pieces.

If you find a script that works on the first load and never again, check whether something you wrote inline in the body needs to subscribe to astro:after-swap.

References