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",
|
||||
"version": "1.89.11",
|
||||
"version": "1.90.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+95
@@ -464,6 +464,101 @@ a:focus-visible {
|
||||
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
|
||||
Responsive card grid used across recipe pages
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
const canton = $derived(resolveCanton(hike.canton));
|
||||
</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">
|
||||
{#if hike.cover.src}
|
||||
<picture>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -227,6 +227,11 @@
|
||||
</section>
|
||||
|
||||
<div class="below-hero">
|
||||
<!-- Wrapped in a named view-transition box so the filter bar can fly
|
||||
up alongside the cards when arriving at /hikes from outside the
|
||||
hikes group. Same `view-transition-class: hike-fly-in` as each
|
||||
HikeCard so one CSS rule animates both. -->
|
||||
<div class="filter-vt-box">
|
||||
<HikesFilterBar
|
||||
hikes={data.hikes}
|
||||
{filter}
|
||||
@@ -235,6 +240,7 @@
|
||||
totalKm={totals.km}
|
||||
totalGain={totals.gain}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if visible.length === 0}
|
||||
<p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p>
|
||||
@@ -393,4 +399,12 @@
|
||||
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>
|
||||
|
||||
@@ -29,8 +29,12 @@
|
||||
const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent);
|
||||
const showPrivate = $derived(!!data.session?.user);
|
||||
|
||||
let track = $state<HikeTrackPoint[] | null>(null);
|
||||
let trackError = $state<string | null>(null);
|
||||
// Track is now loaded synchronously in +page.ts so the photo strip,
|
||||
// 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
|
||||
// fade-out of the SSR-rendered static hero so the static→interactive
|
||||
// handover is a soft cross-fade rather than a swap.
|
||||
@@ -86,24 +90,6 @@
|
||||
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
|
||||
// metrics row + elevation view switch to that stage; "Alle Etappen" (null)
|
||||
// shows the whole route. Single-stage hikes never show the nav.
|
||||
@@ -286,13 +272,6 @@
|
||||
/>
|
||||
|
||||
<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="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</svelte:head>
|
||||
@@ -302,7 +281,7 @@
|
||||
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
|
||||
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}
|
||||
<!-- Build-time static composite of Swisstopo tiles + the trail
|
||||
polyline + public photo markers. Four variants ship — theme
|
||||
@@ -368,10 +347,8 @@
|
||||
initialZoom={heroPose?.zoom}
|
||||
onReady={() => (heroMapReady = true)}
|
||||
/>
|
||||
{:else if trackError}
|
||||
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
|
||||
{:else if !hike.heroMapUrlLight}
|
||||
<div class="map-fallback">Track wird geladen…</div>
|
||||
<div class="map-fallback">Keine Trackdaten verfügbar.</div>
|
||||
{/if}
|
||||
<div class="hero-title">
|
||||
<h1>{hike.title}</h1>
|
||||
@@ -402,7 +379,7 @@
|
||||
{/if}
|
||||
|
||||
{#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} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { HIKES } from '$lib/data/hikes.generated';
|
||||
import type { HikeTrackPoint } from '$types/hikes';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// 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'
|
||||
);
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const hike = HIKES.find((h) => h.slug === params.slug);
|
||||
if (!hike) throw error(404, 'Hike not found');
|
||||
|
||||
@@ -24,11 +25,21 @@ export const load: PageLoad = async ({ params }) => {
|
||||
const loader = mdxModules[modPath];
|
||||
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 {
|
||||
hike,
|
||||
MdxComponent: mod.default,
|
||||
mdxMetadata: mod.metadata ?? {}
|
||||
mdxMetadata: mod.metadata ?? {},
|
||||
track
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user