recipes: view transitions for recipe detail navigation
All checks were successful
CI / update (push) Successful in 1m31s
All checks were successful
CI / update (push) Successful in 1m31s
Image morphs between CompactCard thumbnail and hero, title block slides up from bottom, header persists across transitions. Only activates for recipe detail navigations, not between list pages.
This commit is contained in:
@@ -99,6 +99,7 @@ nav{
|
|||||||
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
|
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
|
||||||
height: var(--header-h);
|
height: var(--header-h);
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
|
view-transition-name: site-header;
|
||||||
}
|
}
|
||||||
.nav-toggle{
|
.nav-toggle{
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
const img_color = $derived(recipe.images?.[0]?.color || '');
|
const img_color = $derived(recipe.images?.[0]?.color || '');
|
||||||
|
|
||||||
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month));
|
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month));
|
||||||
|
|
||||||
|
function activateTransitions(event) {
|
||||||
|
const img = event.currentTarget.querySelector('.img-wrap img');
|
||||||
|
if (img) img.style.viewTransitionName = `recipe-${recipe.short_name}-img`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.compact-card {
|
.compact-card {
|
||||||
@@ -63,6 +68,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transition: transform 0.4s ease;
|
transition: transform 0.4s ease;
|
||||||
|
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||||
}
|
}
|
||||||
.info {
|
.info {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -157,7 +163,9 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="compact-card">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="compact-card" onclick={activateTransitions}>
|
||||||
<a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
|
<a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
|
||||||
{#if showFavoriteIndicator && isFavorite}
|
{#if showFavoriteIndicator && isFavorite}
|
||||||
<span class="favorite">❤️</span>
|
<span class="favorite">❤️</span>
|
||||||
@@ -167,6 +175,7 @@
|
|||||||
src="https://bocken.org/static/rezepte/thumb/{img_name}"
|
src="https://bocken.org/static/rezepte/thumb/{img_name}"
|
||||||
alt={img_alt}
|
alt={img_alt}
|
||||||
loading={loading_strat}
|
loading={loading_strat}
|
||||||
|
data-recipe={recipe.short_name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let { src, color = '', alt = "", children } = $props();
|
let { src, color = '', alt = "", transitionName = '', children } = $props();
|
||||||
|
|
||||||
let isredirected = $state(false);
|
let isredirected = $state(false);
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ dialog button{
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class:zoom-in={!isredirected} onclick={show_dialog_img}>
|
<div class:zoom-in={!isredirected} onclick={show_dialog_img}>
|
||||||
<div class="image-wrap" style:background-color={color}>
|
<div class="image-wrap" style:background-color={color}>
|
||||||
<img class="image" {src} {alt}/>
|
<img class="image" {src} {alt} style:view-transition-name={transitionName || null}/>
|
||||||
</div>
|
</div>
|
||||||
<noscript>
|
<noscript>
|
||||||
<div class="image-wrap" style:background-color={color}>
|
<div class="image-wrap" style:background-color={color}>
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
<script>
|
<script>
|
||||||
import '$lib/css/recipe-links.css';
|
import '$lib/css/recipe-links.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { onNavigate } from '$app/navigation';
|
||||||
import Header from '$lib/components/Header.svelte'
|
import Header from '$lib/components/Header.svelte'
|
||||||
|
|
||||||
|
onNavigate((navigation) => {
|
||||||
|
if (!document.startViewTransition) return;
|
||||||
|
|
||||||
|
// Only use view transitions when navigating to/from a recipe detail page
|
||||||
|
const toRecipe = navigation.to?.params?.name;
|
||||||
|
const fromRecipe = navigation.from?.params?.name;
|
||||||
|
if (!toRecipe && !fromRecipe) return;
|
||||||
|
|
||||||
|
// Measure title block position so the slide animation covers exactly the right distance
|
||||||
|
const title = document.querySelector('[style*="view-transition-name: recipe-title"]');
|
||||||
|
if (title) {
|
||||||
|
const dist = window.innerHeight - title.getBoundingClientRect().top;
|
||||||
|
document.documentElement.style.setProperty('--title-slide', `${dist}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
document.startViewTransition(async () => {
|
||||||
|
resolve();
|
||||||
|
await navigation.complete;
|
||||||
|
|
||||||
|
// Set view-transition-name on the matching CompactCard image for reverse morph
|
||||||
|
if (fromRecipe) {
|
||||||
|
const card = document.querySelector(`img[data-recipe="${fromRecipe}"]`);
|
||||||
|
if (card) card.style.viewTransitionName = `recipe-${fromRecipe}-img`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||||
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
|
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
|
||||||
|
|||||||
@@ -282,6 +282,20 @@ h2{
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View transition: slide title block up from bottom */
|
||||||
|
:global(::view-transition-new(recipe-title)) {
|
||||||
|
animation: slide-up 0.35s ease both;
|
||||||
|
}
|
||||||
|
:global(::view-transition-old(recipe-title)) {
|
||||||
|
animation: slide-down 0.25s ease both;
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(var(--title-slide, 100vh)); }
|
||||||
|
}
|
||||||
|
@keyframes slide-down {
|
||||||
|
to { transform: translateY(var(--title-slide, 100vh)); }
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.strippedName} - {labels.title}</title>
|
<title>{data.strippedName} - {labels.title}</title>
|
||||||
@@ -299,8 +313,8 @@ h2{
|
|||||||
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt}>
|
<TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt} transitionName="recipe-{data.short_name}-img">
|
||||||
<div class=title>
|
<div class=title style="view-transition-name: recipe-title">
|
||||||
{#if data.category}
|
{#if data.category}
|
||||||
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
|
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user