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
+32 -5
View File
@@ -60,16 +60,43 @@
onNavigate((navigation) => {
if (!(/** @type {any} */ (document)).startViewTransition) return;
// Skip if staying within the same route group (recipe layout handles its own)
const fromGroup = navigation.from?.route.id?.split('/')[1] ?? '';
const toGroup = navigation.to?.route.id?.split('/')[1] ?? '';
if (fromGroup === toGroup) return;
const fromId = navigation.from?.route.id ?? '';
const toId = navigation.to?.route.id ?? '';
const fromGroup = fromId.split('/')[1] ?? '';
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) => {
(/** @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();
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>