recipes: view transitions for recipe detail navigation
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:
2026-02-17 18:59:18 +01:00
parent f074c0af08
commit 207efcc38e
5 changed files with 59 additions and 5 deletions

View File

@@ -99,6 +99,7 @@ nav{
box-shadow: 0 1em 1rem 0rem rgba(0,0,0,0.4);
height: var(--header-h);
padding-left: 0.5rem;
view-transition-name: site-header;
}
.nav-toggle{
display: none;

View File

@@ -23,6 +23,11 @@
const img_color = $derived(recipe.images?.[0]?.color || '');
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>
<style>
.compact-card {
@@ -63,6 +68,7 @@
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
.info {
position: relative;
@@ -157,7 +163,9 @@
}
</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>
{#if showFavoriteIndicator && isFavorite}
<span class="favorite">❤️</span>
@@ -167,6 +175,7 @@
src="https://bocken.org/static/rezepte/thumb/{img_name}"
alt={img_alt}
loading={loading_strat}
data-recipe={recipe.short_name}
/>
</div>
<div class="info">

View File

@@ -1,7 +1,7 @@
<script>
import { onMount } from "svelte";
let { src, color = '', alt = "", children } = $props();
let { src, color = '', alt = "", transitionName = '', children } = $props();
let isredirected = $state(false);
@@ -145,7 +145,7 @@ dialog button{
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class:zoom-in={!isredirected} onclick={show_dialog_img}>
<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>
<noscript>
<div class="image-wrap" style:background-color={color}>

View File

@@ -1,7 +1,37 @@
<script>
import '$lib/css/recipe-links.css';
import { page } from '$app/stores';
import { onNavigate } from '$app/navigation';
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 LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';

View File

@@ -282,6 +282,20 @@ h2{
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>
<svelte:head>
<title>{data.strippedName} - {labels.title}</title>
@@ -299,8 +313,8 @@ h2{
<link rel="alternate" hreflang="x-default" href="https://bocken.org/rezepte/{data.germanShortName}" />
</svelte:head>
<TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt}>
<div class=title>
<TitleImgParallax src={hero_img_src} color={img_color} alt={img_alt} transitionName="recipe-{data.short_name}-img">
<div class=title style="view-transition-name: recipe-title">
{#if data.category}
<a class="category g-pill g-btn-dark" href='/{data.recipeLang}/category/{data.category}'>{data.category}</a>
{/if}