feat(hikes): view-transition flow across /hikes ↔ /hikes/[slug]

Cards + filter bar fly up from below when arriving at /hikes and drop
back down when leaving (in both directions of /hikes ↔ detail). Clicked
card morphs into the detail hero with a cross-fade so the thumbnail
dissolves into the map instead of snapping. Photo strip slides in from
the right. Root content cross-fades so metrics + content under the hero
phase in rather than appear at the end of the morph.

Track JSON moves from a client-side $effect into +page.ts so the strip
is in the DOM at view-transition snapshot time — also kills the brief
layout shift when it used to pop in post-load.
This commit is contained in:
2026-05-26 10:34:00 +02:00
parent f1c0304b14
commit b49a299371
7 changed files with 174 additions and 50 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.89.11", "version": "1.90.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+95
View File
@@ -464,6 +464,101 @@ a:focus-visible {
animation: none; animation: none;
} }
/* ============================================
HIKES TRANSITIONS
Cards + filter fly in/out vertically, clicked card morphs into the hero
map (cross-fade between thumbnail and map), photo strip slides in from
the right. Page chrome under the hero cross-fades so nothing snaps in
at transition end. Lives in app.css (not the page component) so the
rules are still loaded on the OLD side of a nav AWAY from /hikes.
============================================ */
@keyframes hikes-fly-up {
from { transform: translateY(100vh); }
to { transform: translateY(0); }
}
@keyframes hikes-fly-down {
from { transform: translateY(0); }
to { transform: translateY(100vh); }
}
@keyframes hikes-root-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes hikes-root-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hike-strip-in-right {
from { transform: translateX(100vw); }
to { transform: translateX(0); }
}
@keyframes hike-strip-out-right {
from { transform: translateX(0); }
to { transform: translateX(100vw); }
}
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
* kill UA's default fade, switch blend mode so the custom fly animation
* shows clean motion against the rest of the page. */
::view-transition-old(.hike-fly-in):only-child,
::view-transition-new(.hike-fly-in):only-child {
animation: none;
mix-blend-mode: normal;
}
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
* into the hero map — otherwise the new image would just cover the old one
* and the thumbnail would vanish silently at t=0. Stretch the duration to
* match the group so the fade ends exactly when the morph does. */
::view-transition-old(.hike-fly-in):not(:only-child),
::view-transition-new(.hike-fly-in):not(:only-child) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Group (the morphing bbox) timing. */
::view-transition-group(.hike-fly-in) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Cards + filter rise from below the viewport on enter. */
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
/* Cards + filter drop off the bottom on exit. */
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Photo strip slides in from the right when arriving at the detail page,
* and slides back out when returning to /hikes. Overrides the global root
* `animation: none` only for these hike-specific transitions. */
html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child {
animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
html.vt-enter-hikes::view-transition-old(hike-strip):only-child {
animation: hike-strip-out-right 600ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
* the destination's chrome — metrics + content + footer on the detail page,
* overview hero + credit on the index — phases in instead of snapping in
* at the end of the morph. Overrides the global rule above; scope keeps
* other routes' transitions on their existing instant-swap behavior. */
html.vt-enter-hike-detail::view-transition-old(root),
html.vt-enter-hikes::view-transition-old(root),
html.vt-exit-hikes::view-transition-old(root) {
animation: hikes-root-fade-out 450ms ease-out both;
}
html.vt-enter-hike-detail::view-transition-new(root),
html.vt-enter-hikes::view-transition-new(root),
html.vt-exit-hikes::view-transition-new(root) {
animation: hikes-root-fade-in 450ms ease-out both;
}
/* ============================================ /* ============================================
RECIPE GRID RECIPE GRID
Responsive card grid used across recipe pages Responsive card grid used across recipe pages
+1 -1
View File
@@ -53,7 +53,7 @@
const canton = $derived(resolveCanton(hike.canton)); const canton = $derived(resolveCanton(hike.canton));
</script> </script>
<a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}"> <a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
<div class="cover"> <div class="cover">
{#if hike.cover.src} {#if hike.cover.src}
<picture> <picture>
+32 -5
View File
@@ -60,16 +60,43 @@
onNavigate((navigation) => { onNavigate((navigation) => {
if (!(/** @type {any} */ (document)).startViewTransition) return; if (!(/** @type {any} */ (document)).startViewTransition) return;
// Skip if staying within the same route group (recipe layout handles its own) const fromId = navigation.from?.route.id ?? '';
const fromGroup = navigation.from?.route.id?.split('/')[1] ?? ''; const toId = navigation.to?.route.id ?? '';
const toGroup = navigation.to?.route.id?.split('/')[1] ?? ''; const fromGroup = fromId.split('/')[1] ?? '';
if (fromGroup === toGroup) return; const toGroup = toId.split('/')[1] ?? '';
// Skip same-group nav (recipe layout handles its own). Hikes is the
// exception: we want the card↔hero morph for /hikes ↔ /hikes/[slug].
if (fromGroup === toGroup && fromGroup !== 'hikes') return;
// Tag <html> so scoped CSS can target each variant of hike nav:
// - vt-enter-hikes: arriving at /hikes from any other route →
// non-paired cards + filter bar fly up from below the viewport.
// (Covers / → /hikes AND back-nav /hikes/[slug] → /hikes, where
// the clicked card pairs with the hero and the rest fly in.)
// - vt-exit-hikes: leaving /hikes for any other route →
// non-paired cards + filter bar fly down off-screen.
// (Covers /hikes → / AND /hikes → /hikes/[slug], where the clicked
// card pairs into the hero and the rest fly out.)
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
const intoHikeDetail = toId === '/hikes/[slug]';
return new Promise((resolve) => { return new Promise((resolve) => {
(/** @type {any} */ (document)).startViewTransition(async () => { const root = document.documentElement;
if (intoHikesIndex) root.classList.add('vt-enter-hikes');
if (outOfHikesIndex) root.classList.add('vt-exit-hikes');
if (intoHikeDetail) root.classList.add('vt-enter-hike-detail');
const transition = (/** @type {any} */ (document)).startViewTransition(async () => {
resolve(); resolve();
await navigation.complete; await navigation.complete;
}); });
transition.finished.finally(() => {
root.classList.remove('vt-enter-hikes');
root.classList.remove('vt-exit-hikes');
root.classList.remove('vt-enter-hike-detail');
});
}); });
}); });
</script> </script>
+22 -8
View File
@@ -227,14 +227,20 @@
</section> </section>
<div class="below-hero"> <div class="below-hero">
<HikesFilterBar <!-- Wrapped in a named view-transition box so the filter bar can fly
hikes={data.hikes} up alongside the cards when arriving at /hikes from outside the
{filter} hikes group. Same `view-transition-class: hike-fly-in` as each
resultCount={visible.length} HikeCard so one CSS rule animates both. -->
totalCount={data.hikes.length} <div class="filter-vt-box">
totalKm={totals.km} <HikesFilterBar
totalGain={totals.gain} hikes={data.hikes}
/> {filter}
resultCount={visible.length}
totalCount={data.hikes.length}
totalKm={totals.km}
totalGain={totals.gain}
/>
</div>
{#if visible.length === 0} {#if visible.length === 0}
<p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p> <p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p>
@@ -393,4 +399,12 @@
gap: 1rem; gap: 1rem;
} }
} }
/* Wrapper sets the view-transition name/class on the filter bar so the
* same .hike-fly-in rules in app.css that animate the cards also
* animate this bar (fly-in on /hikes enter, fly-out on /hikes exit). */
.filter-vt-box {
view-transition-name: hikes-filter-bar;
view-transition-class: hike-fly-in;
}
</style> </style>
+9 -32
View File
@@ -29,8 +29,12 @@
const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent); const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent);
const showPrivate = $derived(!!data.session?.user); const showPrivate = $derived(!!data.session?.user);
let track = $state<HikeTrackPoint[] | null>(null); // Track is now loaded synchronously in +page.ts so the photo strip,
let trackError = $state<string | null>(null); // elevation chart, and hero polyline are in the DOM on first paint —
// fixes both the brief layout shift when the strip used to pop in
// after an async fetch, and the /hikes → /hikes/[slug] view-transition
// slide-in (snapshot is captured before client effects run).
const track = $derived(data.track);
// Toggled true once Leaflet's first tile batch paints. Drives the // Toggled true once Leaflet's first tile batch paints. Drives the
// fade-out of the SSR-rendered static hero so the static→interactive // fade-out of the SSR-rendered static hero so the static→interactive
// handover is a soft cross-fade rather than a swap. // handover is a soft cross-fade rather than a swap.
@@ -86,24 +90,6 @@
return null; return null;
}); });
$effect(() => {
let aborted = false;
fetch(hike.trackUrl)
.then((r) => {
if (!r.ok) throw new Error(`Track fetch failed: ${r.status}`);
return r.json() as Promise<HikeTrackPoint[]>;
})
.then((data) => {
if (!aborted) track = data;
})
.catch((err: Error) => {
if (!aborted) trackError = err.message;
});
return () => {
aborted = true;
};
});
// Active-stage scoping (multi-day hikes). When a stage is selected, the // Active-stage scoping (multi-day hikes). When a stage is selected, the
// metrics row + elevation view switch to that stage; "Alle Etappen" (null) // metrics row + elevation view switch to that stage; "Alle Etappen" (null)
// shows the whole route. Single-stage hikes never show the nav. // shows the whole route. Single-stage hikes never show the nav.
@@ -286,13 +272,6 @@
/> />
<svelte:head> <svelte:head>
<link
rel="preload"
as="fetch"
href={hike.trackUrl}
type="application/json"
crossorigin="anonymous"
/>
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" /> <link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head> </svelte:head>
@@ -302,7 +281,7 @@
hike, so we lead with it. Title overlays at the bottom-left. A second hike, so we lead with it. Title overlays at the bottom-left. A second
HikeMap further down sticks in the scroll-area; both share state via HikeMap further down sticks in the scroll-area; both share state via
the focusedImageStore so they animate together. --> the focusedImageStore so they animate together. -->
<section class="hero-map" style="view-transition-name: hike-{hike.slug}"> <section class="hero-map" style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
{#if hike.heroMapUrlLight} {#if hike.heroMapUrlLight}
<!-- Build-time static composite of Swisstopo tiles + the trail <!-- Build-time static composite of Swisstopo tiles + the trail
polyline + public photo markers. Four variants ship — theme polyline + public photo markers. Four variants ship — theme
@@ -368,10 +347,8 @@
initialZoom={heroPose?.zoom} initialZoom={heroPose?.zoom}
onReady={() => (heroMapReady = true)} onReady={() => (heroMapReady = true)}
/> />
{:else if trackError}
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
{:else if !hike.heroMapUrlLight} {:else if !hike.heroMapUrlLight}
<div class="map-fallback">Track wird geladen…</div> <div class="map-fallback">Keine Trackdaten verfügbar.</div>
{/if} {/if}
<div class="hero-title"> <div class="hero-title">
<h1>{hike.title}</h1> <h1>{hike.title}</h1>
@@ -402,7 +379,7 @@
{/if} {/if}
{#if track && track.length > 0 && visibleImagePoints.length > 0} {#if track && track.length > 0 && visibleImagePoints.length > 0}
<section class="strip-area"> <section class="strip-area" style="view-transition-name: hike-strip">
<HikePhotoStrip images={visibleImagePoints} {track} {stages} /> <HikePhotoStrip images={visibleImagePoints} {track} {stages} />
</section> </section>
{/if} {/if}
+14 -3
View File
@@ -1,5 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { HIKES } from '$lib/data/hikes.generated'; import { HIKES } from '$lib/data/hikes.generated';
import type { HikeTrackPoint } from '$types/hikes';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
// Not prerendered: the page needs the live session so private images can be // Not prerendered: the page needs the live session so private images can be
@@ -16,7 +17,7 @@ const mdxModules = import.meta.glob<{ default: unknown; metadata?: Record<string
'/src/content/hikes/*/index.svx' '/src/content/hikes/*/index.svx'
); );
export const load: PageLoad = async ({ params }) => { export const load: PageLoad = async ({ params, fetch }) => {
const hike = HIKES.find((h) => h.slug === params.slug); const hike = HIKES.find((h) => h.slug === params.slug);
if (!hike) throw error(404, 'Hike not found'); if (!hike) throw error(404, 'Hike not found');
@@ -24,11 +25,21 @@ export const load: PageLoad = async ({ params }) => {
const loader = mdxModules[modPath]; const loader = mdxModules[modPath];
if (!loader) throw error(404, 'Hike content missing'); if (!loader) throw error(404, 'Hike content missing');
const mod = await loader(); // Load the MDX module and the track JSON in parallel. The track was
// previously fetched in a client-side $effect; doing it here means the
// strip + map + elevation chart all render with real data on first
// paint (no async pop-in / layout shift), and crucially the photo
// strip exists in the DOM at view-transition snapshot time so the
// /hikes → /hikes/[slug] slide-in animation actually has something
// to capture.
const [mod, trackResp] = await Promise.all([loader(), fetch(hike.trackUrl)]);
if (!trackResp.ok) throw error(500, `Track konnte nicht geladen werden: ${trackResp.status}`);
const track = (await trackResp.json()) as HikeTrackPoint[];
return { return {
hike, hike,
MdxComponent: mod.default, MdxComponent: mod.default,
mdxMetadata: mod.metadata ?? {} mdxMetadata: mod.metadata ?? {},
track
}; };
}; };