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:
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user