+ {/* Background image element - z-1 keeps it below backdrop blur (z-5).
+ CSS backgroundImage provides instant display.
+ Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
+ {backgroundImageUrl && !backgroundVideoUrl && (
+
+ {backgroundImageUrl.startsWith('blob:') ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

{
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
+ onError={() => {
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
+ />
+ ) : (
+
{
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
+ onError={() => {
+ setIsBackgroundReady(true);
+ pageSwitch.markBackgroundReady();
+ }}
+ />
+ )}
+
+ )}
+
+ {/* Background video - z-1 keeps it below backdrop blur (z-5) */}
+ {backgroundVideoUrl && (
+
+ )}
+
+ {/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
+
+ {pageElements.map((element: CanvasElement) => (
+ handleElementClick(element)}
+ resolveUrl={resolveUrlWithBlob}
+ onGalleryCardClick={(cardIndex) =>
+ handleGalleryCardClick(element, cardIndex)
+ }
+ />
+ ))}
+
+ {/* End new page content wrapper */}
{/* Controls: Offline toggle and Fullscreen button */}
@@ -661,24 +672,13 @@ export default function RuntimePresentation({
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && (
-
-
-
+
)}
{/* Gallery Carousel Overlay */}
diff --git a/frontend/src/config/canvas.config.ts b/frontend/src/config/canvas.config.ts
index 5317abb..cdd0ece 100644
--- a/frontend/src/config/canvas.config.ts
+++ b/frontend/src/config/canvas.config.ts
@@ -43,14 +43,10 @@ export const CANVAS_CONFIG = {
// Page transition effects
pageTransition: {
- /**
- * Fade-in duration for non-transition navigation (ms).
- * Applied when switching pages without a transition video.
- */
- fadeInDurationMs: 500,
/**
* Fade-out duration for transition video overlay (ms).
* Applied after transition video finishes playing.
+ * Note: Crossfade duration is controlled by CSS in main.css (.animate-crossfade-in/out)
*/
fadeOutDurationMs: 300,
/**
diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css
index 4e57dc6..0ba12c4 100644
--- a/frontend/src/css/main.css
+++ b/frontend/src/css/main.css
@@ -34,6 +34,34 @@
@apply bg-transparent border border-blue-600 text-blue-600 !important;
}
+/* Page crossfade animation keyframes */
+@keyframes page-crossfade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes page-crossfade-out {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+/* Crossfade animation classes */
+.animate-crossfade-in {
+ animation: page-crossfade-in 500ms ease-out forwards;
+}
+
+.animate-crossfade-out {
+ animation: page-crossfade-out 500ms ease-out forwards;
+}
+
/* Element appear animation keyframes */
@keyframes element-fade-in {
from {
diff --git a/frontend/src/hooks/useBackgroundTransition.ts b/frontend/src/hooks/useBackgroundTransition.ts
index de9d2c8..119d85d 100644
--- a/frontend/src/hooks/useBackgroundTransition.ts
+++ b/frontend/src/hooks/useBackgroundTransition.ts
@@ -14,19 +14,20 @@
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
*/
-import { useEffect, useState, useCallback, useRef } from 'react';
+import {
+ useEffect,
+ useLayoutEffect,
+ useState,
+ useCallback,
+ useRef,
+} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
/**
- * Fade-out duration from config
+ * Fade-out duration from config (for transition video overlay)
*/
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
-/**
- * Fade-in duration from config
- */
-const FADE_IN_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeInDurationMs;
-
/**
* Fade-out configuration (optional - for RuntimePresentation)
*/
@@ -47,8 +48,6 @@ export interface FadeOutConfig {
export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean;
- /** Optional duration override (uses config default if not provided) */
- durationMs?: number;
}
export interface UseBackgroundTransitionOptions {
@@ -58,6 +57,7 @@ export interface UseBackgroundTransitionOptions {
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
+ previousBgVideoUrl: string;
};
/** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig;
@@ -70,11 +70,11 @@ export interface UseBackgroundTransitionResult {
isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void;
- /** Whether page content is currently fading in */
+ /** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean;
- /** Opacity value for elements container (0 during fade, 1 when complete) */
- elementsOpacity: number;
- /** Reset fade-in state before starting new navigation */
+ /** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
+ onFadeInAnimationEnd: () => void;
+ /** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
}
@@ -83,7 +83,7 @@ export interface UseBackgroundTransitionResult {
*
* @example
* // Full mode with fade-out and fade-in (RuntimePresentation)
- * const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
+ * const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
* pageSwitch,
* fadeOut: {
* pendingTransitionComplete,
@@ -99,6 +99,12 @@ export interface UseBackgroundTransitionResult {
* },
* });
*
+ * // In JSX:
+ *
+ *
* @example
* // Simple mode - direct navigation only (constructor)
* useBackgroundTransition({ pageSwitch });
@@ -109,15 +115,10 @@ export function useBackgroundTransition({
fadeIn,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
-
- // Fade-in state
const [isFadingIn, setIsFadingIn] = useState(false);
- const [elementsOpacity, setElementsOpacity] = useState(1);
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
- // Track timer for cleanup
- const fadeInTimerRef = useRef
| null>(null);
/**
* Reset fade-out state before starting a new transition.
@@ -128,16 +129,18 @@ export function useBackgroundTransition({
}, []);
/**
- * Reset fade-in state before starting new navigation.
- * Clears any in-progress fade animation.
+ * Reset fade-in state (for cleanup or cancellation).
*/
const resetFadeIn = useCallback(() => {
- if (fadeInTimerRef.current) {
- clearTimeout(fadeInTimerRef.current);
- fadeInTimerRef.current = null;
- }
setIsFadingIn(false);
- setElementsOpacity(1);
+ }, []);
+
+ /**
+ * Handler for onAnimationEnd event.
+ * Called when CSS animation completes - CSS is the source of truth for duration.
+ */
+ const onFadeInAnimationEnd = useCallback(() => {
+ setIsFadingIn(false);
}, []);
/**
@@ -187,92 +190,55 @@ export function useBackgroundTransition({
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
/**
- * Effect: Clear previous background overlay when new background is ready (direct navigation).
+ * Effect: Clear previous background overlay after fade completes (direct navigation).
*
- * This handles the case when navigating without a transition video.
- * The previous background stays visible until the new one is ready to paint.
+ * The previous background stays visible during the entire fade animation,
+ * providing a smooth crossfade effect. Only cleared after fade ends.
*/
useEffect(() => {
- if (
- pageSwitch.isSwitching &&
- pageSwitch.isNewBgReady &&
- pageSwitch.previousBgImageUrl
- ) {
- // New background is ready - clear the previous background overlay
+ if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
+ // Fade is complete - clear the previous background overlay
+ // This also resets isSwitching state so next navigation triggers fade-in
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
- pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
+ isFadingIn,
]);
/**
- * Effect: Fade-in page content on non-transition navigation.
+ * Layout effect: Set up crossfade BEFORE browser paints when switching starts.
+ * useLayoutEffect runs synchronously after DOM mutations but before paint,
+ * preventing any flash of new content at full opacity.
*
- * Trigger: pageSwitch.isSwitching becomes true AND no active transition
- *
- * Sequence:
- * 1. Navigation starts (isSwitching: false → true)
- * 2. No transition video active
- * 3. Set elementsOpacity = 0
- * 4. Use double RAF to ensure paint before animation
- * 5. Set elementsOpacity = 1 (CSS animates)
- * 6. After duration, set isFadingIn = false
+ * IMPORTANT: Skip this for transitions - transition video IS the effect.
*/
- useEffect(() => {
+ useLayoutEffect(() => {
if (!fadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching;
return;
}
- const { hasActiveTransition, durationMs = FADE_IN_DURATION_MS } = fadeIn;
+ const { hasActiveTransition } = fadeIn;
const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current;
+
wasSwitchingRef.current = pageSwitch.isSwitching;
- // Start fade-in when:
- // - Just started switching (transition from false to true)
- // - No active transition video
+ // Only start crossfade for NON-transition navigation
+ // Transitions use video overlay - no fade needed
if (justStartedSwitching && !hasActiveTransition) {
setIsFadingIn(true);
- setElementsOpacity(0);
-
- // Double RAF ensures opacity:0 is painted before transition starts
- // (Same pattern as usePageSwitch.ts:396-397)
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setElementsOpacity(1);
- });
- });
-
- // Clear any existing timer
- if (fadeInTimerRef.current) {
- clearTimeout(fadeInTimerRef.current);
- }
-
- // Mark fade as complete after duration
- fadeInTimerRef.current = setTimeout(() => {
- setIsFadingIn(false);
- fadeInTimerRef.current = null;
- }, durationMs);
}
-
- // Cleanup on unmount
- return () => {
- if (fadeInTimerRef.current) {
- clearTimeout(fadeInTimerRef.current);
- fadeInTimerRef.current = null;
- }
- };
}, [pageSwitch.isSwitching, fadeIn]);
return {
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
- elementsOpacity,
+ onFadeInAnimationEnd,
resetFadeIn,
};
}
diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts
index 762fb0c..242ee64 100644
--- a/frontend/src/hooks/usePageSwitch.ts
+++ b/frontend/src/hooks/usePageSwitch.ts
@@ -63,6 +63,8 @@ export interface UsePageSwitchResult {
currentBgAudioUrl: string;
/** Previous background image URL (for overlay) */
previousBgImageUrl: string;
+ /** Previous background video URL (for overlay during fade) */
+ previousBgVideoUrl: string;
/** Whether we're in the middle of a page switch */
isSwitching: boolean;
/** Whether the new background is ready to display */
@@ -198,6 +200,10 @@ export function usePageSwitch(
const previousBgImageUrlRef = useRef('');
previousBgImageUrlRef.current = previousBgImageUrl;
+ const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
+ const previousBgVideoUrlRef = useRef('');
+ previousBgVideoUrlRef.current = previousBgVideoUrl;
+
// Transition state
const [isSwitching, setIsSwitching] = useState(false);
const [isNewBgReady, setIsNewBgReady] = useState(true);
@@ -362,16 +368,20 @@ export function usePageSwitch(
setCurrentBgVideoUrl('');
setCurrentBgAudioUrl('');
setPreviousBgImageUrl('');
+ setPreviousBgVideoUrl('');
setIsSwitching(false);
setIsNewBgReady(true);
onSwitched?.();
return;
}
- // Save current image as previous for overlay (use ref to avoid dependency)
+ // Save current backgrounds as previous for overlay (use refs to avoid dependencies)
if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current);
}
+ if (currentBgVideoUrlRef.current) {
+ setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
+ }
setIsSwitching(true);
setIsNewBgReady(false);
@@ -433,16 +443,21 @@ export function usePageSwitch(
}, []);
/**
- * Clear the previous background overlay
+ * Clear the previous background overlay (both image and video)
*/
const clearPreviousBackground = useCallback(() => {
- const prevUrl = previousBgImageUrlRef.current;
+ const prevImageUrl = previousBgImageUrlRef.current;
+ const prevVideoUrl = previousBgVideoUrlRef.current;
setPreviousBgImageUrl('');
+ setPreviousBgVideoUrl('');
setIsSwitching(false);
- // Revoke the previous blob URL after clearing
- if (prevUrl) {
- revokeBlobUrl(prevUrl);
+ // Revoke the previous blob URLs after clearing
+ if (prevImageUrl) {
+ revokeBlobUrl(prevImageUrl);
+ }
+ if (prevVideoUrl) {
+ revokeBlobUrl(prevVideoUrl);
}
}, [revokeBlobUrl]);
@@ -451,6 +466,7 @@ export function usePageSwitch(
currentBgVideoUrl,
currentBgAudioUrl,
previousBgImageUrl,
+ previousBgVideoUrl,
isSwitching,
isNewBgReady,
switchToPage,
diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx
index 08a0897..50cad67 100644
--- a/frontend/src/pages/constructor.tsx
+++ b/frontend/src/pages/constructor.tsx
@@ -358,10 +358,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage;
- // Use shared background transition hook for direct navigation clearing and fade-in
- // (No fade-out needed in constructor - transitions complete immediately)
- // NOTE: Must be defined before switchToPage callback which uses resetFadeIn
- const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
+ // Use shared background transition hook for direct navigation clearing and crossfade
+ // Crossfade starts automatically when new background is ready
+ const { isFadingIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
@@ -374,9 +373,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => {
- // Reset fade-in state to start fresh
- resetFadeIn();
-
// Mark this page as initialized to prevent redundant effect calls
if (page) {
lastInitializedPageIdRef.current = page.id;
@@ -386,6 +382,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage(page);
// Use hook to resolve and set blob URLs for display
+ // Fade starts automatically when new background is ready (crossfade effect)
await pageSwitchToPage(
page
? {
@@ -403,12 +400,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
);
},
- [
- pageSwitchToPage,
- updateBackgroundFromPage,
- applyPageSelection,
- resetFadeIn,
- ],
+ [pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
);
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@@ -1466,8 +1458,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoUrl={backgroundVideoSrc}
backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={pageSwitch.previousBgImageUrl}
+ previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady}
+ isFadingIn={isFadingIn}
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
@@ -1478,13 +1472,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
{/* Elements container - z-10 ensures they appear above backdrop layer */}
{isLoading ? (
@@ -1590,6 +1578,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering}
+ letterboxStyles={letterboxStyles}
/>
{/* Gallery Carousel Overlay */}