crossfade

This commit is contained in:
Vincent van der Wal
2026-08-01 15:58:19 +02:00
parent 0d5dbbc61f
commit 17cf8df109
16 changed files with 258 additions and 51 deletions
+55 -11
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings';
import Footer from '$lib/components/navigation/footer.svelte';
@@ -15,8 +17,6 @@
import { routePath } from '$lib/i18n';
import { pageContentReady } from '$lib/stores/page-transition.svelte';
import './layout.css';
let { children } = $props();
@@ -61,13 +61,16 @@
});
// ── Page cross-fade ───────────────────────────────────────────────────────
// The weather pages fetch their forecast after the route swap, so tying the
// fade to navigation alone would cross-fade one skeleton into another and
// then cut hard to the real content. Instead the outgoing page fades out on
// navigation and the incoming one fades in once it reports that its data has
// landed - with a ceiling so a slow or silent page is never left hidden.
// A real cross-fade needs the outgoing page still on screen while the
// incoming one appears - so the view transition is opened at navigation and
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
const FADE_OUT_MS = 170;
const REVEAL_CEILING_MS = 2500;
let contentVisible = $state(true);
let revealTimer = 0;
@@ -75,21 +78,62 @@
const reducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
onNavigate(() => {
// Routes that fetch their own forecast after mounting. Knowing this up front
// is what makes the wait reliable: the layout clears the ready flag before
// the swap rather than trusting the incoming page to have done it.
const DATA_ROUTES = new Set([
'/weather/week/[location]',
'/weather/14-day/[location]',
'/weather/compare/[location]',
'/weather/seasonal/[location]',
'/weather/historical/[location]'
]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
async function waitForContent(): Promise<void> {
const deadline = Date.now() + READY_CEILING_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.startViewTransition(async () => {
// hand control back so SvelteKit swaps the DOM underneath the
// frozen snapshot of the old page
swap();
// A superseded navigation (a redirect, or a fast second click)
// rejects this promise; that is not an error worth surfacing,
// and leaving it unhandled shows up as "navigation aborted".
await navigation.complete.catch(() => {});
if (loadsData) await waitForContent();
});
});
}
// No view transitions: fall back to fading out, then back in on arrival.
clearTimeout(revealTimer);
contentVisible = false;
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
});
afterNavigate(() => {
if (reducedMotion()) {
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
contentVisible = true;
return;
}