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.