home loading bug and flashes fixes

This commit is contained in:
Vincent van der Wal
2026-08-06 21:10:15 +02:00
parent 1c5ea2b52e
commit c3bb6de49c
10 changed files with 256 additions and 115 deletions
+1
View File
@@ -261,6 +261,7 @@
"table_now": "Jetzt", "table_now": "Jetzt",
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln", "interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
"page_loading": "Wird geladen…", "page_loading": "Wird geladen…",
"page_loading_dismiss": "Schließen",
"charts_loading": "Diagramme werden geladen…", "charts_loading": "Diagramme werden geladen…",
"chart_download": "Meteogramm als PNG-Bild herunterladen", "chart_download": "Meteogramm als PNG-Bild herunterladen",
"chart_credit_viz": "Visualisierung von", "chart_credit_viz": "Visualisierung von",
+1
View File
@@ -261,6 +261,7 @@
"table_now": "Now", "table_now": "Now",
"interval_toggle": "Toggle between 1-hour and 3-hour intervals", "interval_toggle": "Toggle between 1-hour and 3-hour intervals",
"page_loading": "Loading…", "page_loading": "Loading…",
"page_loading_dismiss": "Dismiss",
"charts_loading": "Loading charts...", "charts_loading": "Loading charts...",
"chart_download": "Download meteogram as PNG image", "chart_download": "Download meteogram as PNG image",
"chart_credit_viz": "visualisation by", "chart_credit_viz": "visualisation by",
+1
View File
@@ -261,6 +261,7 @@
"table_now": "Ahora", "table_now": "Ahora",
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas", "interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
"page_loading": "Cargando…", "page_loading": "Cargando…",
"page_loading_dismiss": "Descartar",
"charts_loading": "Cargando gráficos…", "charts_loading": "Cargando gráficos…",
"chart_download": "Descargar el meteograma como imagen PNG", "chart_download": "Descargar el meteograma como imagen PNG",
"chart_credit_viz": "visualización de", "chart_credit_viz": "visualización de",
+1
View File
@@ -261,6 +261,7 @@
"table_now": "Maintenant", "table_now": "Maintenant",
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h", "interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
"page_loading": "Chargement…", "page_loading": "Chargement…",
"page_loading_dismiss": "Fermer",
"charts_loading": "Chargement des graphiques…", "charts_loading": "Chargement des graphiques…",
"chart_download": "Télécharger le météogramme en PNG", "chart_download": "Télécharger le météogramme en PNG",
"chart_credit_viz": "visualisation par", "chart_credit_viz": "visualisation par",
+1
View File
@@ -261,6 +261,7 @@
"table_now": "Ora", "table_now": "Ora",
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore", "interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
"page_loading": "Caricamento…", "page_loading": "Caricamento…",
"page_loading_dismiss": "Chiudi",
"charts_loading": "Caricamento dei grafici…", "charts_loading": "Caricamento dei grafici…",
"chart_download": "Scarica il meteogramma come immagine PNG", "chart_download": "Scarica il meteogramma come immagine PNG",
"chart_credit_viz": "visualizzazione di", "chart_credit_viz": "visualizzazione di",
+10
View File
@@ -22,6 +22,16 @@ export function markPageLoading(): void {
pageContentReady.set(false); pageContentReady.set(false);
} }
/**
* The counterpart, for a route that has nothing to wait for (the maps page, the
* legal pages). Without it a navigation away from a page that never resolved
* would leave the flag stuck on "loading", and the layout's overlay would sit on
* top of a page that is perfectly finished.
*/
export function markPageReady(): void {
pageContentReady.set(true);
}
/** /**
* Declare a page's readiness. Pass a getter for "my data has arrived". Only * Declare a page's readiness. Pass a getter for "my data has arrived". Only
* ever sets the flag - clearing it is the layout's job (see above). * ever sets the flag - clearing it is the layout's job (see above).
+13 -15
View File
@@ -1,7 +1,11 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
const prefersReducedMotion = () => import {
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; isViewTransitionActive,
prefersReducedMotion,
startViewTransition,
supportsViewTransitions
} from './view-transition';
/** /**
* Runs a day change inside a view transition, so the outgoing day is still on * Runs a day change inside a view transition, so the outgoing day is still on
@@ -12,9 +16,13 @@ const prefersReducedMotion = () =>
* `.day-region-*` in routes/layout.css); everything else - the strip, the * `.day-region-*` in routes/layout.css); everything else - the strip, the
* header, the page chrome - is pinned by the `day-switch` class so it stays * header, the page chrome - is pinned by the `day-switch` class so it stays
* completely still. * completely still.
*
* A day switch that lands while a navigation transition is still on screen just
* applies: starting a rival transition would skip the running one and flash the
* page (see view-transition.ts).
*/ */
export async function runDayTransition(update: () => void): Promise<void> { export async function runDayTransition(update: () => void): Promise<void> {
if (prefersReducedMotion() || !document.startViewTransition) { if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) {
update(); update();
return; return;
} }
@@ -24,20 +32,10 @@ export async function runDayTransition(update: () => void): Promise<void> {
try { try {
// Svelte applies the change on the next tick; the transition has to wait // Svelte applies the change on the next tick; the transition has to wait
// for that before it snapshots the new state. // for that before it snapshots the new state.
const transition = document.startViewTransition(async () => { await startViewTransition(async () => {
update(); update();
await tick(); await tick();
}); });
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and that unhandled rejection is
// what turns one bad render into an "Uncaught" error on the page - so
// report it here instead and let `finished` drive the flow.
transition.updateCallbackDone.catch((error: unknown) => {
console.error('day transition failed to apply', error);
});
await transition.finished;
} catch {
/* a superseded transition is fine - the DOM is already up to date */
} finally { } finally {
root.classList.remove('day-switch'); root.classList.remove('day-switch');
} }
@@ -53,7 +51,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
const play = () => { const play = () => {
// view transitions handle it properly where they exist // view transitions handle it properly where they exist
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return; if (supportsViewTransitions() || prefersReducedMotion()) return;
node.animate([{ opacity: 0.1 }, { opacity: 1 }], { node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
duration: 460, duration: 460,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
+72
View File
@@ -0,0 +1,72 @@
/**
* One view transition at a time.
*
* The browser runs exactly one: starting a second while the first is still on
* screen *skips* the first, which drops its frozen snapshot on the spot and
* pops the live DOM into view. That is the whole-screen flash, and overlapping
* transitions are the normal case here rather than an edge case:
*
* - `/` and every bare `/weather/<view>/` page is a redirect stub that calls
* `goto` from `onMount`, so one click on a nav entry produces two (from the
* root, three) navigations back to back - and the stub renders nothing, so
* the frame it exposes is an empty page;
* - a theme toggle or a day switch can land while a navigation is still
* waiting for its forecast.
*
* Letting a second update ride along inside the transition that is already
* running keeps one continuous cross-fade across the whole chain.
*/
type UpdateCallback = () => void | Promise<void>;
let active: Promise<void> | null = null;
export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
export const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** True while a transition is on screen, from capture until its animation ends. */
export const isViewTransitionActive = (): boolean => active !== null;
/**
* Runs `update` inside a view transition, or straight away when one cannot (or
* must not) be started. Resolves once the transition has finished animating -
* or as soon as the update is done, when it ran on its own.
*
* Callers that need to know which of the two happened should check
* `isViewTransitionActive()` first; the point of this function is that they
* mostly should not care.
*/
export function startViewTransition(update: UpdateCallback): Promise<void> {
if (!supportsViewTransitions() || prefersReducedMotion() || active) {
return Promise.resolve(update()).then(
() => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
}
const transition = document.startViewTransition(update);
// A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is
// what turns one bad render into an "Uncaught" error on the page.
transition.updateCallbackDone.catch((error: unknown) => {
console.error('view transition update failed', error);
});
// A superseded transition rejects `finished`; the DOM is already up to date,
// so that is not an error worth surfacing - but the slot has to be freed.
const done = transition.finished.then(
() => {},
() => {}
);
active = done;
void done.then(() => {
if (active === done) active = null;
});
return done;
}
+134 -76
View File
@@ -6,9 +6,20 @@
import { afterNavigate, onNavigate } from '$app/navigation'; import { afterNavigate, onNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte'; import {
markPageLoading,
markPageReady,
pageContentReady
} from '$lib/stores/page-transition.svelte';
import { storedTheme } from '$lib/stores/settings'; import { storedTheme } from '$lib/stores/settings';
import {
isViewTransitionActive,
prefersReducedMotion,
startViewTransition,
supportsViewTransitions
} from '$lib/utils/view-transition';
import Footer from '$lib/components/navigation/footer.svelte'; import Footer from '$lib/components/navigation/footer.svelte';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -30,7 +41,9 @@
const apply = () => { const apply = () => {
const root = document.documentElement; const root = document.documentElement;
const dark = theme === 'dark' || (theme === 'system' && mq.matches); const dark = theme === 'dark' || (theme === 'system' && mq.matches);
const paint = () => root.classList.toggle('dark', dark); const paint = () => {
root.classList.toggle('dark', dark);
};
// The very first application is just painting the stored theme - only // The very first application is just painting the stored theme - only
// an actual switch afterwards is worth cross-fading. // an actual switch afterwards is worth cross-fading.
@@ -41,9 +54,17 @@
return; return;
} }
if (document.startViewTransition) { if (supportsViewTransitions() && !isViewTransitionActive()) {
// one cross-fade of the whole document; component transitions untouched // one cross-fade of the whole document; component transitions untouched
document.startViewTransition(paint); void startViewTransition(paint);
return;
}
// A navigation transition already owns the screen: repaint under it
// rather than skipping it (which would flash), and fall back to the
// colour transition below when the browser has none at all.
if (supportsViewTransitions()) {
paint();
return; return;
} }
@@ -72,43 +93,67 @@
// yet. Note the order: the overlay has to be in the DOM *before* the // yet. Note the order: the overlay has to be in the DOM *before* the
// transition captures the incoming state, because a running view transition // transition captures the incoming state, because a running view transition
// freezes the page and nothing painted after that point can appear. // freezes the page and nothing painted after that point can appear.
//
// There is deliberately no fade for browsers without view transitions. The
// old fallback dimmed the outgoing page to nothing and brought the new one
// back up, which on a single layer is a flash of the bare background rather
// than a cross-fade. Swapping outright and letting the overlay carry the
// "loading" message is quieter and honest.
const READY_HOLD_MS = 350; const READY_HOLD_MS = 350;
const FADE_OUT_MS = 170;
// Nothing reports ready when a fetch fails outright, so the overlay needs its // Nothing reports ready when a fetch fails outright, so the overlay needs its
// own way out rather than sitting on top of an error message forever. // own way out rather than sitting on top of an error message forever.
const OVERLAY_CEILING_MS = 15000; const OVERLAY_CEILING_MS = 15000;
let contentVisible = $state(true);
let revealTimer = 0;
let loadingOverlay = $state(false); let loadingOverlay = $state(false);
let overlayHoldTimer = 0;
let overlayCeilingTimer = 0; let overlayCeilingTimer = 0;
// A view transition freezes the page, so a Svelte in-transition started under
// one cannot play - the overlay would be captured at opacity 0 and pop in
// afterwards. Inside a transition the cross-fade does the fading instead.
let overlayFadesIn = $state(true);
function showOverlay(): void { function showOverlay(animate: boolean): void {
overlayFadesIn = animate;
loadingOverlay = true; loadingOverlay = true;
clearTimeout(overlayCeilingTimer); clearTimeout(overlayCeilingTimer);
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS); overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
} }
function hideOverlay(): void { function hideOverlay(): void {
clearTimeout(overlayHoldTimer);
clearTimeout(overlayCeilingTimer); clearTimeout(overlayCeilingTimer);
loadingOverlay = false; loadingOverlay = false;
} }
/**
* Manual dismissal. The overlay reports on a fetch it does not control, so
* "stuck" is always a possibility (a page that never reports ready, a request
* that neither resolves nor rejects) - and the page behind it still works.
* Whatever was loading carries on; only the veil goes.
*/
function dismissOverlay(): void {
hideOverlay();
}
function onWindowKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
}
/** The page that just mounted has its data: whatever we were waiting for is in. */ /** The page that just mounted has its data: whatever we were waiting for is in. */
$effect(() => { $effect(() => {
if ($pageContentReady) hideOverlay(); if ($pageContentReady) hideOverlay();
}); });
const reducedMotion = () => // Routes that are not finished on arrival: the five that fetch a forecast
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // after mounting, and the redirect stubs, which render nothing at all and
// bounce to a located URL from `onMount`. Knowing this up front is what makes
// Routes that fetch their own forecast after mounting. Knowing this up front // the wait reliable - the layout clears the ready flag before the swap rather
// is what makes the wait reliable: the layout clears the ready flag before // than trusting the incoming page to have done it.
// the swap rather than trusting the incoming page to have done it. const PENDING_ROUTES = new Set([
const DATA_ROUTES = new Set([ '/',
'/weather/week',
'/weather/14-day',
'/weather/compare',
'/weather/seasonal',
'/weather/historical',
'/weather/week/[location]', '/weather/week/[location]',
'/weather/14-day/[location]', '/weather/14-day/[location]',
'/weather/compare/[location]', '/weather/compare/[location]',
@@ -121,50 +166,63 @@
* period is up - in which case the overlay goes up first, so it is part of * period is up - in which case the overlay goes up first, so it is part of
* the state the transition is about to snapshot. * the state the transition is about to snapshot.
*/ */
async function waitForContent(): Promise<void> { async function waitForContent(underTransition: boolean): Promise<void> {
const deadline = Date.now() + READY_HOLD_MS; const deadline = Date.now() + READY_HOLD_MS;
while (!get(pageContentReady) && Date.now() < deadline) { while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
} }
if (!get(pageContentReady)) showOverlay(); if (!get(pageContentReady)) showOverlay(!underTransition);
// one more frame so the page paints its data before the snapshot is taken // one more frame so the page paints its data before the snapshot is taken
await tick(); await tick();
} }
/**
* True when the navigation lands on the route and params the page is already
* showing - the sidebar's home link from the 7-day page it points at, or a
* link that differs only in the query string.
*
* SvelteKit keeps the page component mounted for those, and nothing it holds
* changes: its forecast is already fetched, so the readiness effect it
* registered never re-runs and never re-announces. Clearing the flag for such
* a navigation strands it cleared, and the overlay sits there until its
* ceiling. There is genuinely nothing to wait for, so don't clear it.
*/
function landsOnCurrentPage(navigation: {
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
}): boolean {
const from = navigation.from;
const to = navigation.to;
if (!from?.route.id || from.route.id !== to?.route.id) return false;
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
}
onNavigate((navigation) => { onNavigate((navigation) => {
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? ''); const pending =
if (loadsData) markPageLoading(); PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
// Either way the flag is set explicitly: leaving a page that never resolved
// for one that has nothing to load would otherwise strand the overlay.
if (pending) markPageLoading();
else markPageReady();
if (reducedMotion()) { // `startViewTransition` decides whether a transition is possible at all
// No transition to hang the wait on, so the overlay gets its own timer. // (support, reduced motion, one already on screen) and runs the update
if (loadsData) { // inline when it is not - so there is exactly one path from here down.
clearTimeout(overlayHoldTimer); const underTransition =
overlayHoldTimer = window.setTimeout(() => { supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive();
if (!get(pageContentReady)) showOverlay();
}, READY_HOLD_MS);
}
return;
}
if (typeof document.startViewTransition === 'function') { return new Promise<void>((swap) => {
return new Promise<void>((swap) => { void startViewTransition(async () => {
document.startViewTransition(async () => { // hand control back so SvelteKit swaps the DOM underneath the
// hand control back so SvelteKit swaps the DOM underneath the // frozen snapshot of the old page
// frozen snapshot of the old page swap();
swap(); // A superseded navigation (a redirect, or a fast second click)
// A superseded navigation (a redirect, or a fast second click) // rejects this promise; that is not an error worth surfacing,
// rejects this promise; that is not an error worth surfacing, // and leaving it unhandled shows up as "navigation aborted".
// and leaving it unhandled shows up as "navigation aborted". await navigation.complete.catch(() => {});
await navigation.complete.catch(() => {}); if (pending) await waitForContent(underTransition);
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));
}); });
let mainEl = $state<HTMLElement | null>(null); let mainEl = $state<HTMLElement | null>(null);
@@ -176,26 +234,6 @@
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) { if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
mainEl?.scrollTo({ top: 0 }); mainEl?.scrollTo({ top: 0 });
} }
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
contentVisible = true;
return;
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady)) {
contentVisible = true;
return;
}
if (Date.now() - startedAt > READY_HOLD_MS) {
// same trade as above: show the skeleton, and say why it is empty
contentVisible = true;
showOverlay();
return;
}
revealTimer = window.setTimeout(reveal, 20);
};
reveal();
}); });
// the maps page embeds a full-bleed map: no padding, no scrolling // the maps page embeds a full-bleed map: no padding, no scrolling
@@ -225,15 +263,38 @@
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status <!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
indicator, not a modal, so the nav and the search stay usable while a slow indicator, not a modal, so the nav and the search stay usable while a slow
forecast is still on its way. --> forecast is still on its way. It is also always dismissable - Escape or the
close button - because a veil nobody can get rid of is worse than no veil,
and the page underneath is perfectly usable either way. -->
<svelte:window onkeydown={onWindowKeydown} />
{#if loadingOverlay} {#if loadingOverlay}
<div <div
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]" class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
in:fade={{ duration: 120 }} in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
out:fade={{ duration: 280 }} out:fade={{ duration: 280 }}
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
<button
type="button"
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
onclick={dismissOverlay}
aria-label={m.page_loading_dismiss()}
title={m.page_loading_dismiss()}
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div <div
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg" class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
> >
@@ -297,10 +358,7 @@
{:else} {:else}
<!-- cap the content width on very large screens; the footer below <!-- cap the content width on very large screens; the footer below
gives the page its ending, so only modest bottom room is needed --> gives the page its ending, so only modest bottom room is needed -->
<div <div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
class:page-fade-hidden={!contentVisible}
>
{@render children()} {@render children()}
</div> </div>
<!-- full-bleed footer inside the scroll area (its own inner max-w), <!-- full-bleed footer inside the scroll area (its own inner max-w),
+22 -24
View File
@@ -133,23 +133,6 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
page reports its data has arrived. */
.page-fade {
transition: opacity 320ms ease;
}
.page-fade-hidden {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.page-fade {
transition: none;
}
.page-fade-hidden {
opacity: 1;
}
}
/* ── Day switching ──────────────────────────────────────────────────────── /* ── Day switching ────────────────────────────────────────────────────────
Only the three regions whose content depends on the selected day take part Only the three regions whose content depends on the selected day take part
in the cross-fade. Everything else keeps its pixels: the `day-switch` in the cross-fade. Everything else keeps its pixels: the `day-switch`
@@ -210,15 +193,30 @@
} }
} }
/* Theme changes cross-fade the whole document in one pass (see /* Whole-document cross-fade, for both a page swap and a theme change (see
routes/+layout.svelte). Doing it as a view transition instead of a blanket routes/+layout.svelte). Doing the theme as a view transition instead of a
`* { transition }` matters: that blanket rule also stretched every blanket `* { transition }` matters: that blanket rule also stretched every
component's own hover and focus transitions to 400ms for the duration of component's own hover and focus transitions to 400ms for the duration of
the switch, which read as lag on interactive controls. */ the switch, which read as lag on interactive controls.
::view-transition-old(root),
The default cross-fade is NOT opacity-neutral. Both snapshots are opaque
and the browser fades one out while fading the other in, so halfway
through, the two together cover only ~75% of the screen and the page
background shows through everything: the whole screen visibly dips, which
reads as a flash. Holding the outgoing snapshot at full opacity and fading
only the incoming one in on top of it (it is painted above) keeps every
frame fully covered. */
::view-transition-old(root) {
animation: none;
opacity: 1;
}
::view-transition-new(root) { ::view-transition-new(root) {
animation-duration: 400ms; animation: root-fade-in 400ms ease;
animation-timing-function: ease; }
@keyframes root-fade-in {
from {
opacity: 0;
}
} }
/* Fallback for browsers without view transitions: fade the colours only. */ /* Fallback for browsers without view transitions: fade the colours only. */