beter crossfade

This commit is contained in:
Vincent van der Wal
2026-08-06 19:24:22 +02:00
parent f4a02a208c
commit 1c5ea2b52e
6 changed files with 276 additions and 199 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { TransitionConfig } from 'svelte/transition';
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Fades a loading placeholder out *over* the content that replaces it.
*
* A plain `out:fade` is not usable on a skeleton: Svelte keeps the node in the
* layout for the length of the transition, so the real content stacks below the
* placeholder and the whole page jumps up the moment the placeholder finally
* unmounts. That is why these placeholders only ever faded in - the swap was
* abrupt, but at least nothing moved.
*
* This pins the placeholder to the box it already occupies and takes it out of
* flow for the fade instead. The content settles into its final position
* immediately and the skeleton dissolves on top of it.
*
* Requirements at the call site: the placeholder's parent must be positioned
* (`class="relative"`) and must be the same element that renders the real
* content, or the overlay lands somewhere else on the page.
*/
export function skeletonOut(node: HTMLElement, { duration = 260 } = {}): TransitionConfig {
if (prefersReducedMotion()) return { duration: 0 };
// measured while the node is still in flow, which is when Svelte builds the
// transition - the values below are what freeze it in place
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node;
return {
duration,
css: (t) => `
opacity: ${t};
position: absolute;
top: ${offsetTop}px;
left: ${offsetLeft}px;
width: ${offsetWidth}px;
height: ${offsetHeight}px;
margin: 0;
pointer-events: none;
z-index: 5;
`
};
}
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
@@ -342,7 +344,9 @@
</div>
{/if}
{#if fetchedData}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
@@ -374,10 +378,18 @@
</div>
{/each}
</div>
{:else}
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
{/if}
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.length || 1}
chartHeight={340}
bleed={false}
/>
</div>
{/if}
</div>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -9,6 +9,7 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { listUnlessDefault, readList, syncSearchParams } from '$lib/utils/url-state';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
@@ -379,7 +380,9 @@
<!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives -->
{#if fetchedData}
<!-- `relative` lets the placeholder dissolve over the finished charts (skeletonOut) -->
<div class="relative">
{#if fetchedData}
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
@@ -408,15 +411,18 @@
</div>
{/each}
</div>
{:else}
{:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<div in:fade={{ duration: 200 }} out:skeletonOut>
<ChartContainer
loading
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={340}
bleed={false}
/>
{/if}
</div>
{/if}
</div>
{#if fetchedData && !loading}
<ModelPictogramTimeline
@@ -13,6 +13,8 @@
storedVariablePrefs
} from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
@@ -258,7 +260,7 @@
</div>
{/if}
<div class="mt-4">
<div class="relative mt-4">
{#if result && fetchedHourly && fetchedDaily}
<HistoricalDaily
daily={result.daily}
@@ -282,7 +284,7 @@
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
{:else}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
</div>
@@ -6,6 +6,8 @@
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
@@ -270,6 +272,9 @@
</div>
{/if}
<!-- `relative` anchors the placeholder while it fades out over the real
outlook rather than holding its own slot in the layout (skeletonOut). -->
<div class="relative">
{#if visible && months.length > 0}
<SeasonalMonths {months} units={params} {normals} />
@@ -297,7 +302,7 @@
</ChartToolbar>
</div>
{:else if !loadError}
<div transition:fade={{ duration: 200 }} class="grid gap-3">
<div in:fade={{ duration: 200 }} out:skeletonOut class="grid gap-3">
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
@@ -306,4 +311,5 @@
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
</div>
{/if}
</div>
</PaywallGate>
@@ -18,6 +18,7 @@
import { formatZoned } from '$lib/utils/date';
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
import { buildLocationRoute } from '$lib/utils/location';
import { skeletonOut } from '$lib/utils/skeleton-fade';
import { syncSearchParams, unlessDefault } from '$lib/utils/url-state';
import { ChartContainer } from '$lib/components/charts';
@@ -473,6 +474,9 @@
{locationRoute}
/>
<!-- `relative` is what lets the placeholder fade out on top of the table
instead of holding a second slot in the layout (see skeletonOut). -->
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-table" use:daySwap={selectedDayKey}>
<HourlyTable
@@ -486,12 +490,10 @@
</div>
{:else}
<!-- Mirrors the real table: same header bar and the same body height,
so the heading doesn't pop in and nothing below moves.
Placeholders only fade IN - a fade-out would keep them in the
layout while the real content mounts below, and everything below
would jump the moment they finally unmount. -->
so the heading doesn't pop in and nothing below moves. -->
<div
in:fade={{ duration: 200 }}
out:skeletonOut
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div
@@ -520,7 +522,9 @@
</div>
</div>
{/if}
</div>
<div class="relative">
{#if fetchedHourly && fetchedDaily}
<div class="day-region-summary" use:daySwap={selectedDayKey}>
<DaySummary data={fetchedHourly} daily={fetchedDaily} {selectedDay} units={params} />
@@ -532,13 +536,15 @@
five lines with a fixed toggle row, so that side is exact; from md
up the text runs free and this is the typical height. The sun/moon
grid reflows at sm, md and lg, which is why all four are needed. -->
<section class="mt-6" in:fade={{ duration: 200 }}>
<section class="mt-6" in:fade={{ duration: 200 }} out:skeletonOut>
<div
class="h-96.5 animate-pulse rounded-2xl border border-border/70 bg-card sm:h-85 md:h-73 lg:h-56"
></div>
</section>
{/if}
</div>
<div class="relative">
{#if fetchedHourly}
<div class="day-region-charts" use:daySwap={selectedDayKey}>
<MeteogramCharts
@@ -552,7 +558,7 @@
{:else}
<!-- reserve the exact chart area height before the first fetch resolves,
header row included -->
<section class="mt-8" in:fade={{ duration: 200 }}>
<section class="mt-8" in:fade={{ duration: 200 }} out:skeletonOut>
<!-- The real header wraps to two rows until the controls fit beside
the title, which happens at different widths than you would
expect because the sidebar takes its share from md up. These
@@ -576,6 +582,7 @@
/>
</section>
{/if}
</div>
{#if selectedDayKey}
<NearbyCities