#Custom page transition in Astro

27 messages · Page 1 of 1 (latest)

brisk verge
#

I'm building a custom curtain-style page transition with Astro's ClientRouter. I've disabled all default view transitions on the <html> element and handle everything manually in a persistent <PageTransition> component.

The curtain animation and the page fetch run at different speeds. Sometimes the page loads before the curtain finishes, sometimes the other way around. I need the DOM swap to only happen once the curtain fully covers the screen.

My solution is to intercept event.loader in astro:before-preparation and replace it with a Promise.all that waits for both the original fetch AND the curtain animation

my shortend scripts...

<html transition:animate="none">
  <head>
    <ClientRouter />
  </head>
  <body >
    <StickyHeader  />
    <main>
      <slot />
    </main>
   <Footer />
    <PageTransition />
  </body>
</html>
let enterPromise = null;

// 1. Navigation starts → begin curtain animation, wrap loader
document.addEventListener('astro:before-preparation', (e) => {
  enterPromise = new Promise((resolve) => {
    // curtain slides in... when done:
    setTimeout(resolve, 800); // simulate animation
  });

  // Wrap the loader so swap waits for BOTH fetch + curtain
  const originalLoader = e.loader;
  e.loader = async () => {
    await Promise.all([originalLoader(), enterPromise]);
  };
});

// After swap: wait, then animate curtain out
document.addEventListener('astro:page-load', async () => {
  if (!enterPromise) return; // first load, skip

  await enterPromise; // safety — should already be resolved
  await new Promise((r) => setTimeout(r, 600)); // hold delay
  
  // curtain slides out...
  enterPromise = null;
});

My questions:

Is intercepting event.loader the recommended way to delay the swap, or is there a better approach?
Any concerns with this pattern in general?

summer flintBOT
#

Hi @brisk verge. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

Your approach is well-grounded and aligns with what Astro's documentation describes for this use case.

Is intercepting event.loader the recommended way?

Yes. The astro:before-preparation event and its loader() callback are explicitly designed for this purpose. The docs describe it as:

"Implementation of the following phase in the navigation (loading the next page). This implementation can be overridden to add extra behavior."

[loader() reference]

The official example even shows the exact same wrapping pattern you're using:

document.addEventListener("astro:before-preparation", (event) => {
  const originalLoader = event.loader;
  event.loader = async function () {
    const { startSpinner } = await import("./spinner.js");
    const stop = startSpinner();
    await originalLoader();
    stop();
  };
});

[before-preparation guide]

Your use of Promise.all([originalLoader(), enterPromise]) is a natural extension of this — running the fetch and the curtain animation concurrently and waiting for both before the swap proceeds. This is a valid and idiomatic approach.

Concerns with the pattern

A few things worth noting based on the documentation:

  1. is:inline on scripts: The docs recommend using is:inline for scripts that hook into lifecycle events (like astro:before-preparation), since bundled module scripts only execute once and may not re-run after navigation. Your script needs to register the listener on every page load. [script re-execution]
#
  1. astro:page-load fires after scripts execute: The swap and script execution happen before astro:page-load, so using it to trigger the curtain-out animation is correct — it fires at the very end of the navigation process. [page-load event]

  2. enterPromise as shared state: Since your script may run more than once (if inline), the docs advise guarding global state:

    if (!window.SomeGlobal) {
      window.SomeGlobal = {};
    }
    

    You may want to store enterPromise on window to avoid re-initialization issues across navigations. [script re-execution]

  3. Fallback behavior: If you need to support non-Chromium browsers, be aware that the initial animation type is not simulated by Astro's fallback. Since you're using transition:animate="none" and handling everything manually, this is less of a concern, but worth keeping in mind. [fallback control]

Overall, your architecture is sound and uses the lifecycle hooks as intended.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@brisk verge Kapa.ai is still learning and improving, please let me know how I did by reacting below.

brisk verge
#

@summer flint why should i use is inline and then use a guard that it doesn't get re excuted on each page? now because i don't use is:inline it gets only executed once

summer flintBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You're right to push back on that — it's a valid point. Let me clarify the tradeoff:

Bundled (non-inline) scripts are only executed once per site visit, even if the component appears on multiple pages. [script re-execution] This means your document.addEventListener('astro:before-preparation', ...) call runs once, but that's actually fine — event listeners registered on document persist across navigations because document itself is not replaced during a client-side swap.

So if your script is not is:inline, you get:

  • The listener registered once ✅
  • No need for a global state guard ✅
  • No risk of duplicate listeners being added on each navigation ✅

The is:inline + guard pattern is only necessary when you need the script to re-run on each navigation (e.g., to re-query DOM elements that get replaced during the swap). In that case, you'd use is:inline (or data-astro-rerun) and then guard against re-initializing global state with something like if (!window.SomeGlobal). [script re-execution]

For your use case, since you're only registering document-level event listeners (which survive the swap), a standard bundled script is the correct and simpler choice. My earlier suggestion to use is:inline was unnecessary for your specific pattern.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@brisk verge Kapa.ai is still learning and improving, please let me know how I did by reacting below.

brisk verge
#

@summer flint when i use this it breaks anything... any chance to use my appraoch an prefatch pages?

 prefetch: {
    defaultStrategy: 'hover',
    prefetchAll: true,
 },
summer flintBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Yes, you can absolutely combine prefetching with your custom curtain transition approach — they operate at different layers and don't conflict by design.

How prefetching interacts with your pattern

Prefetching pre-fetches the page content in the background before the user even clicks the link. When the user does click, the astro:before-preparation event still fires and your event.loader override still runs — it's just that originalLoader() may resolve faster (or near-instantly) because the page is already cached. [using with view transitions]

Your Promise.all([originalLoader(), enterPromise]) pattern handles this gracefully: if originalLoader() resolves before the curtain animation finishes, it simply waits for enterPromise. Nothing breaks.

Enabling prefetch alongside your setup

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  prefetch: {
    defaultStrategy: 'hover',
    prefetchAll: true,
  },
});

[prefetch config]

Since you're already using <ClientRouter />, prefetching is actually enabled by default with { prefetchAll: true }. Your explicit config just sets defaultStrategy: 'hover', which is also the default. [using with view transitions]

What could "break" things

If you're seeing issues, the most likely culprits are browser-specific prefetch behavior rather than a conflict with your transition logic:

#
  • Firefox may error on prefetch without proper cache headers (Cache-Control, ETag). [browser support]
  • Safari doesn't support <link rel="prefetch"> and falls back to fetch(), which also requires cache headers. [browser support]

If you can describe what specifically breaks, that would help narrow it down further.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@brisk verge Kapa.ai is still learning and improving, please let me know how I did by reacting below.

brisk verge
#

@summer flint pages changes instand. no events are fired so there is my page transition not visible

summer flintBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

This sounds like prefetching is causing the navigation to bypass the <ClientRouter /> lifecycle entirely — but actually, the more likely cause is that browser-native cross-document view transitions are firing instead of Astro's <ClientRouter /> router.

However, based on the knowledge sources available, the most relevant thing to check is: are the lifecycle events actually not firing, or is the swap happening before your curtain animation has time to show?

A few things to verify:

1. Confirm <ClientRouter /> is present on every page

The lifecycle events (astro:before-preparation, etc.) are only fired by Astro's <ClientRouter /> router. If it's missing from a page, you get a full browser navigation with no events. [view transitions guide]

2. Prefetch resolving originalLoader() instantly

With prefetchAll: true and defaultStrategy: 'hover', the page is already fetched before the user clicks. When they click, originalLoader() resolves near-instantly. Your Promise.all still waits for enterPromise (the curtain), so the swap should still be delayed — unless enterPromise is null or not being set correctly.

Double-check that astro:before-preparation is actually firing by adding a console.log inside the listener.

3. Script execution timing

Since you're using a bundled (non-inline) script, confirm the listener is registered before any navigation occurs. If the script hasn't executed yet when the first navigation fires, the event is missed. [script re-execution]

4. astro:page-load on first load

#

The docs note that astro:page-load fires on initial page load as well as subsequent navigations. Your guard if (!enterPromise) return; correctly skips the curtain-out on first load, so that part looks fine. [page-load event]


If you can confirm whether astro:before-preparation is firing at all (via console.log), that will tell you whether the issue is with the <ClientRouter /> not being active, or with the curtain logic itself.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@brisk verge Kapa.ai is still learning and improving, please let me know how I did by reacting below.

brisk verge
#

@summer flint
only astro:page-load gets fired... everything else is skipped
so prefatch and clientrouzter doesn't work together?

document.addEventListener('astro:before-preparation', (e: Event) => {
      console.log('[PT] before-preparation state');
     
    });

    document.addEventListener('astro:after-preparation', () => {
      console.log('[PT] after-preparation state');
    });

    document.addEventListener('astro:before-swap', () => {
      console.log('[PT] before-swap state');
    });

    document.addEventListener('astro:after-swap', () => {
      console.log('[PT] after-swap state');
    });

    document.addEventListener('astro:page-load', async () => {
      console.log('[PT] page-load state');
    });
summer flintBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

The fact that only astro:page-load fires — but not astro:before-preparation, astro:after-preparation, astro:before-swap, or astro:after-swap — is a strong signal that the navigation is not going through Astro's <ClientRouter /> at all. Prefetch and <ClientRouter /> do work together, so that's not the root issue.

The most likely cause: browser-native cross-document view transitions are handling the navigation instead of <ClientRouter />. The astro:page-load event fires on initial page load too, which is why you see it — but the router-specific events only fire when <ClientRouter /> intercepts the navigation. [view transitions guide]

Things to check

1. Is <ClientRouter /> present on every page?

The lifecycle events are only fired when <ClientRouter /> is active on the current page (the one you're navigating from). If it's missing from even one page in the chain, that navigation falls back to a full browser navigation. [client-side navigation]

2. Are you using browser-native view transitions instead?

You mentioned <html transition:animate="none">. If you've also enabled browser-native cross-document view transitions (e.g. via CSS @view-transition { navigation: auto; }), the browser may be handling navigation natively, bypassing <ClientRouter /> entirely. Native cross-document transitions do not fire Astro's lifecycle events. [differences explained]

#

3. Where are your event listeners registered?

Since you're using a bundled (non-inline) script, the listeners must be registered before any navigation occurs. If the script is in a component that only appears on some pages, the listeners may not be active when navigation fires.


Quick diagnostic: Add a log on initial load to confirm <ClientRouter /> is present and active, and check your browser's Network tab to see if navigations are full-page reloads (status 200 with full HTML) or partial fetches. If they're full reloads, <ClientRouter /> is not intercepting them.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@brisk verge Kapa.ai is still learning and improving, please let me know how I did by reacting below.

brisk verge
#

@summer flint yes its always loaded because its inside my layout

<html transition:animate="none">
  <head>
    <ClientRouter />
  </head>
  <body >
    <StickyHeader  />
    <main>
      <slot />
    </main>
   <Footer />
    <PageTransition />
  </body>
</html>

there are no other @view-transition, listensers a in my <PageTransition /> definded.

after every route changes i see a full page relaod instead of swapping...

summer flintBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Based on what you're describing — full page reloads on every navigation, only astro:page-load firing, and <ClientRouter /> present in the layout — the knowledge sources I have don't contain enough detail to definitively diagnose this specific issue.

However, there is one thing the docs are explicit about that's worth double-checking:

"You cannot navigate via client-side routing since both pages involved must use the <ClientRouter /> router to prevent a full-page reload."

[preventing client-side navigation]

So even though your layout always includes <ClientRouter />, confirm that every single page in your site uses that layout (including error pages, dynamic routes, etc.). If any destination page doesn't have <ClientRouter /> in its <head>, the navigation to it will be a full page reload.

Also worth checking:

  • Are any of your <a> tags using data-astro-reload? That attribute forces a full-page navigation and bypasses the router entirely. [router control]
  • Are you navigating to external URLs or different origins? <ClientRouter /> only intercepts internal navigation.

Beyond these points, the knowledge sources I have don't cover deeper debugging scenarios for why <ClientRouter /> might fail to intercept navigation when it appears to be correctly set up. I'd recommend checking the Astro Discord or GitHub issues for more targeted help with this specific behavior.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: