flashing fixed

This commit is contained in:
Vincent van der Wal
2026-08-06 21:23:59 +02:00
parent c3bb6de49c
commit c4a74ba91c
4 changed files with 157 additions and 95 deletions
+10 -12
View File
@@ -1,7 +1,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import { import {
isViewTransitionActive, canStartViewTransition,
prefersReducedMotion, prefersReducedMotion,
startViewTransition, startViewTransition,
supportsViewTransitions supportsViewTransitions
@@ -22,23 +22,21 @@ import {
* page (see view-transition.ts). * page (see view-transition.ts).
*/ */
export async function runDayTransition(update: () => void): Promise<void> { export async function runDayTransition(update: () => void): Promise<void> {
if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) { if (!canStartViewTransition()) {
update(); update();
return; return;
} }
const root = document.documentElement; // Svelte applies the change on the next tick; the transition has to wait for
root.classList.add('day-switch'); // that before it snapshots the new state. `day-switch` scopes which regions
try { // take part (see routes/layout.css) and is cleared for us when it ends.
// Svelte applies the change on the next tick; the transition has to wait await startViewTransition(
// for that before it snapshots the new state. async () => {
await startViewTransition(async () => {
update(); update();
await tick(); await tick();
}); },
} finally { { rootClass: 'day-switch' }
root.classList.remove('day-switch'); );
}
} }
/** /**
+64 -35
View File
@@ -1,24 +1,40 @@
/** /**
* One view transition at a time. * Central entry point for every view transition in the app.
* *
* The browser runs exactly one: starting a second while the first is still on * Two things it exists to get right.
* 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 * **One transition at a time, but only where it matters.** Starting a
* `goto` from `onMount`, so one click on a nav entry produces two (from the * transition while another is running makes the browser skip the first, and
* root, three) navigations back to back - and the stub renders nothing, so * what that looks like depends entirely on which phase the first is in:
* 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 * - *capturing* - its update callback has not resolved yet, so the screen is
* running keeps one continuous cross-fade across the whole chain. * frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
* and pops the half-updated live DOM into view: the whole-screen flash.
* This phase is common here, because navigation deliberately holds the
* callback open while the new page fetches, and `/` plus every bare
* `/weather/<view>/` page is a redirect stub that navigates again from
* `onMount` - one click, two or three navigations.
* - *animating* - the DOM is already in its final state and the pseudo
* elements are playing out. Skipping just finishes them early, landing on
* exactly the state they were heading for.
*
* So a new transition rides along inside the running one only while it is
* capturing; once it is animating, superseding it is the better answer (waiting
* would strand the new update under a stale snapshot until the animation ends).
*
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
* transition, so the stylesheet can tell a page swap from a day switch and pin
* the parts that are identical on both sides (see routes/layout.css).
*/ */
type UpdateCallback = () => void | Promise<void>; type UpdateCallback = () => void | Promise<void>;
let active: Promise<void> | null = null; interface Options {
/** Class set on `<html>` while the transition runs, for scoping CSS. */
rootClass?: string;
}
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
let capturing: Promise<void> | null = null;
export const supportsViewTransitions = (): boolean => export const supportsViewTransitions = (): boolean =>
typeof document !== 'undefined' && typeof document.startViewTransition === 'function'; typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
@@ -26,20 +42,25 @@ export const supportsViewTransitions = (): boolean =>
export const prefersReducedMotion = (): boolean => export const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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; * True while a transition is frozen on its outgoing snapshot - the phase in
* which starting a rival transition would flash the page.
*/
export const isViewTransitionCapturing = (): boolean => capturing !== null;
/** Whether `startViewTransition` would actually open one right now. */
export const canStartViewTransition = (): boolean =>
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
/** /**
* Runs `update` inside a view transition, or straight away when one cannot (or * Runs `update` inside a view transition, or straight away when one cannot (or
* must not) be started. Resolves once the transition has finished animating - * 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. * 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> { export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
if (!supportsViewTransitions() || prefersReducedMotion() || active) { const { rootClass } = options;
if (!canStartViewTransition()) {
return Promise.resolve(update()).then( return Promise.resolve(update()).then(
() => {}, () => {},
(error: unknown) => { (error: unknown) => {
@@ -48,25 +69,33 @@ export function startViewTransition(update: UpdateCallback): Promise<void> {
); );
} }
const root = document.documentElement;
if (rootClass) root.classList.add(rootClass);
const transition = document.startViewTransition(update); const transition = document.startViewTransition(update);
// A throw inside the callback rejects `updateCallbackDone` as well as // A throw inside the callback rejects `updateCallbackDone` as well as
// `finished`. Nothing awaits the former, and an unhandled rejection there is // `finished`. Nothing awaits the former, and an unhandled rejection there is
// what turns one bad render into an "Uncaught" error on the page. // what turns one bad render into an "Uncaught" error on the page.
transition.updateCallbackDone.catch((error: unknown) => { const captured = transition.updateCallbackDone.then(
console.error('view transition update failed', error); () => {},
(error: unknown) => {
console.error('view transition update failed', error);
}
);
capturing = captured;
void captured.then(() => {
if (capturing === captured) capturing = null;
}); });
// A superseded transition rejects `finished`; the DOM is already up to date, // 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. // so that is not an error worth surfacing.
const done = transition.finished.then( return transition.finished
() => {}, .then(
() => {} () => {},
); () => {}
active = done; )
void done.then(() => { .finally(() => {
if (active === done) active = null; if (rootClass) root.classList.remove(rootClass);
}); });
return done;
} }
+19 -17
View File
@@ -14,8 +14,7 @@
import { storedTheme } from '$lib/stores/settings'; import { storedTheme } from '$lib/stores/settings';
import { import {
isViewTransitionActive, canStartViewTransition,
prefersReducedMotion,
startViewTransition, startViewTransition,
supportsViewTransitions supportsViewTransitions
} from '$lib/utils/view-transition'; } from '$lib/utils/view-transition';
@@ -54,7 +53,7 @@
return; return;
} }
if (supportsViewTransitions() && !isViewTransitionActive()) { if (canStartViewTransition()) {
// one cross-fade of the whole document; component transitions untouched // one cross-fade of the whole document; component transitions untouched
void startViewTransition(paint); void startViewTransition(paint);
return; return;
@@ -206,22 +205,25 @@
else markPageReady(); else markPageReady();
// `startViewTransition` decides whether a transition is possible at all // `startViewTransition` decides whether a transition is possible at all
// (support, reduced motion, one already on screen) and runs the update // (support, reduced motion, one already capturing) and runs the update
// inline when it is not - so there is exactly one path from here down. // inline when it is not - so there is exactly one path from here down.
const underTransition = const underTransition = canStartViewTransition();
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive();
return new Promise<void>((swap) => { return new Promise<void>((swap) => {
void startViewTransition(async () => { void startViewTransition(
// hand control back so SvelteKit swaps the DOM underneath the async () => {
// frozen snapshot of the old page // hand control back so SvelteKit swaps the DOM underneath the
swap(); // frozen snapshot of the old page
// A superseded navigation (a redirect, or a fast second click) swap();
// rejects this promise; that is not an error worth surfacing, // A superseded navigation (a redirect, or a fast second click)
// and leaving it unhandled shows up as "navigation aborted". // rejects this promise; that is not an error worth surfacing,
await navigation.complete.catch(() => {}); // and leaving it unhandled shows up as "navigation aborted".
if (pending) await waitForContent(underTransition); await navigation.complete.catch(() => {});
}); if (pending) await waitForContent(underTransition);
},
// pins the chrome that is the same on both sides (routes/layout.css)
{ rootClass: 'page-switch' }
);
}); });
}); });
@@ -315,7 +317,7 @@
<div class="flex h-screen overflow-hidden bg-background text-foreground"> <div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar --> <!-- Desktop sidebar -->
<div class="hidden h-full shrink-0 md:block"> <div class="sidebar-region hidden h-full shrink-0 md:block">
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} /> <WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
</div> </div>
+64 -31
View File
@@ -159,25 +159,27 @@
z-index: 20; z-index: 20;
} }
/* Same for the topbar: a captured region's snapshot is painted at its layout /* Same for the topbar and the sidebar: a captured region's snapshot is
position, including the part normally scrolled up behind the chrome. */ painted at its layout position, including the part normally scrolled up
:root.day-switch .topbar { behind the chrome. Both are also identical either side of a *page* swap,
so they are captured there too and pinned below - a region that does not
change should not be animated at all. */
:root.day-switch .topbar,
:root.page-switch .topbar {
view-transition-name: topbar; view-transition-name: topbar;
} }
::view-transition-group(topbar) { ::view-transition-group(topbar) {
z-index: 30; z-index: 30;
} }
:root.page-switch .sidebar-region {
::view-transition-old(day-table), view-transition-name: sidebar;
::view-transition-new(day-table), }
::view-transition-old(day-summary), ::view-transition-group(sidebar) {
::view-transition-new(day-summary), z-index: 25;
::view-transition-old(day-charts),
::view-transition-new(day-charts) {
animation-duration: 420ms;
animation-timing-function: ease;
} }
/* The root fade is the page swap's own; during a day switch the chrome must
not so much as flicker, so it is cancelled outright. */
:root.day-switch::view-transition-old(root), :root.day-switch::view-transition-old(root),
:root.day-switch::view-transition-new(root) { :root.day-switch::view-transition-new(root) {
animation: none; animation: none;
@@ -188,35 +190,66 @@
.day-region-summary, .day-region-summary,
.day-region-charts, .day-region-charts,
.daystrip, .daystrip,
.topbar { .topbar,
.sidebar-region {
view-transition-name: none; view-transition-name: none;
} }
} }
/* Whole-document cross-fade, for both a page swap and a theme change (see /* ── Cross-fades that do not dip ──────────────────────────────────────────
routes/+layout.svelte). Doing the theme as a view transition instead of a The default cross-fade is NOT opacity-neutral: both snapshots are opaque
blanket `* { transition }` matters: that blanket rule also stretched every and the browser fades one out while fading the other in, so at the
component's own hover and focus transitions to 400ms for the duration of midpoint the pair covers only ~75% of the region and the page background
the switch, which read as lag on interactive controls. shows through both. That dip is the flash - over the whole viewport for a
page swap, and over the table, summary and charts (which between them are
the whole content column) for a day switch.
The default cross-fade is NOT opacity-neutral. Both snapshots are opaque The only opacity-neutral pairing with normal blending is to hold the
and the browser fades one out while fading the other in, so halfway outgoing snapshot at full opacity and fade the incoming one in on top of
through, the two together cover only ~75% of the screen and the page it - it is painted above - so every frame stays fully covered. */
background shows through everything: the whole screen visibly dips, which @keyframes vt-fade-in {
reads as a flash. Holding the outgoing snapshot at full opacity and fading from {
only the incoming one in on top of it (it is painted above) keeps every opacity: 0;
frame fully covered. */ }
::view-transition-old(root) { }
::view-transition-old(root),
::view-transition-old(day-table),
::view-transition-old(day-summary),
::view-transition-old(day-charts) {
animation: none; animation: none;
opacity: 1; opacity: 1;
} }
::view-transition-new(root) { ::view-transition-new(root) {
animation: root-fade-in 400ms ease; animation: vt-fade-in 400ms ease;
} }
@keyframes root-fade-in { ::view-transition-new(day-table),
from { ::view-transition-new(day-summary),
opacity: 0; ::view-transition-new(day-charts) {
} animation: vt-fade-in 420ms ease;
}
/* The outgoing snapshot no longer fades, so a region that gets *shorter*
would keep showing its old tail below the new content for the whole
transition. Clipping to the group - which animates between the two sizes -
turns that into the shrink it actually is. */
::view-transition-group(day-table),
::view-transition-group(day-summary),
::view-transition-group(day-charts) {
overflow: clip;
}
/* Chrome that is identical on both sides of the swap: pinned, never faded.
(Whole-document theme changes are a `root` transition with no page-switch
class, so they still cross-fade the chrome along with everything else.) */
::view-transition-old(topbar),
::view-transition-new(topbar),
::view-transition-old(sidebar),
::view-transition-new(sidebar),
::view-transition-old(daystrip),
::view-transition-new(daystrip) {
animation: none;
opacity: 1;
} }
/* Fallback for browsers without view transitions: fade the colours only. */ /* Fallback for browsers without view transitions: fade the colours only. */