diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx
index f0a09e0..a4afcdf 100644
--- a/frontend/src/components/RuntimePresentation.tsx
+++ b/frontend/src/components/RuntimePresentation.tsx
@@ -242,15 +242,38 @@ export default function RuntimePresentation({
// Use shared background transition hook for crossfade effects
// NOTE: fadeOut config is NOT used for video transitions.
// Video transitions end instantly (last frame = new page, then overlay removed).
- // fadeIn is used for non-video navigation (crossfade 500ms).
- const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } =
- useBackgroundTransition({
- pageSwitch,
- // No fadeOut - video transitions don't use fade
- fadeIn: {
- hasActiveTransition: Boolean(transitionPreview),
- },
- });
+ // fadeIn is used for non-video navigation (crossfade 700ms via CSS transitions).
+ const {
+ isFadingIn,
+ crossfadePhase,
+ onFadeInAnimationEnd,
+ onTransitionEnd,
+ resetFadeIn,
+ } = useBackgroundTransition({
+ pageSwitch,
+ // No fadeOut - video transitions don't use fade
+ fadeIn: {
+ hasActiveTransition: Boolean(transitionPreview),
+ },
+ });
+
+ // Helper to generate crossfade classes for CSS transitions
+ // Two-phase approach: 'starting' = opacity 0, 'running' = opacity 1 (transition animates)
+ const getCrossfadeInClasses = () => {
+ if (!isFadingIn) return '';
+ const base = 'crossfade-layer';
+ if (crossfadePhase === 'starting') return `${base} crossfade-in-start`;
+ if (crossfadePhase === 'running') return `${base} crossfade-in-end`;
+ return '';
+ };
+
+ const getCrossfadeOutClasses = () => {
+ if (!isFadingIn) return '';
+ const base = 'crossfade-layer';
+ if (crossfadePhase === 'starting') return `${base} crossfade-out-start`;
+ if (crossfadePhase === 'running') return `${base} crossfade-out-end`;
+ return '';
+ };
const toggleFullscreen = useCallback(async () => {
try {
@@ -603,40 +626,43 @@ export default function RuntimePresentation({
)}
{/* Previous background overlays - show during loading AND crossfade.
- Uses CSS animation for fade-out effect.
+ Uses CSS transitions for fade-out effect (more reliable in Safari).
Cleared by useBackgroundTransition after fade completes. */}
{pageSwitch.previousBgImageUrl &&
(isFadingIn ||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
{/* Background image element */}
@@ -699,10 +725,10 @@ export default function RuntimePresentation({
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top.
- Fades in together with background. */}
+ Fades in together with background using CSS transitions. */}
{pageElements.map((element: CanvasElement) => (
void;
/** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean;
+ /** Current crossfade phase for transition-based approach */
+ crossfadePhase: CrossfadePhase;
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
onFadeInAnimationEnd: (e?: React.AnimationEvent) => void;
+ /** Handler for transition end (alternative to animation end) */
+ onTransitionEnd: (e?: React.TransitionEvent) => void;
/** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
}
@@ -122,6 +132,8 @@ export function useBackgroundTransition({
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const [isFadingIn, setIsFadingIn] = useState(false);
+ // Crossfade phase for CSS transitions (more reliable in Safari)
+ const [crossfadePhase, setCrossfadePhase] = useState('idle');
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
@@ -130,6 +142,8 @@ export function useBackgroundTransition({
const fadeInTimerRef = useRef | null>(null);
// Track if animation was already completed (by event or timer)
const fadeInCompletedRef = useRef(false);
+ // RAF handle for two-phase transition approach
+ const rafRef = useRef(null);
/**
* Reset fade-out state before starting a new transition.
@@ -144,16 +158,21 @@ export function useBackgroundTransition({
*/
const resetFadeIn = useCallback(() => {
setIsFadingIn(false);
+ setCrossfadePhase('idle');
fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
}, []);
/**
* Complete fade-in animation.
- * Called either by onAnimationEnd or by timer fallback.
+ * Called either by onAnimationEnd, onTransitionEnd, or timer fallback.
* Uses ref to prevent double-completion.
*/
const completeFadeIn = useCallback(() => {
@@ -165,7 +184,12 @@ export function useBackgroundTransition({
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ setCrossfadePhase('complete');
setIsFadingIn(false);
}, []);
@@ -183,6 +207,19 @@ export function useBackgroundTransition({
[completeFadeIn],
);
+ /**
+ * Handler for onTransitionEnd event.
+ * Called when CSS transition completes (more reliable in Safari than animations).
+ */
+ const onTransitionEnd = useCallback(
+ (e?: React.TransitionEvent) => {
+ // Only handle opacity transitions, not other properties
+ if (e && e.propertyName !== 'opacity') return;
+ completeFadeIn();
+ },
+ [completeFadeIn],
+ );
+
/**
* Effect: Fade out and remove transition overlay when background is ready.
* Only runs when fadeOut config is provided.
@@ -281,8 +318,10 @@ export function useBackgroundTransition({
* IMPORTANT: Skip this for transitions - transition video IS the effect.
*
* Cross-browser handling:
- * - Sets up JS timer fallback for Safari (unreliable onAnimationEnd)
- * - Chrome/Firefox rely on CSS onAnimationEnd event
+ * - Uses two-phase CSS transitions (more reliable in Safari than animations)
+ * - Phase 1 (starting): Apply initial opacity
+ * - Phase 2 (running): After one frame, apply final opacity - CSS transition animates
+ * - Fallback timer ensures completion even if events don't fire
*/
useLayoutEffect(() => {
if (!fadeIn) {
@@ -302,21 +341,36 @@ export function useBackgroundTransition({
// Reset completion flag for new animation
fadeInCompletedRef.current = false;
- // Clear any existing timer
+ // Clear any existing timers/rafs
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
setIsFadingIn(true);
- // Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
- // can be unreliable or fire on wrong animations.
- // Timer is slightly longer than CSS duration to let CSS complete first.
- // Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
+ // Two-phase approach for Safari:
+ // 1. First, set 'starting' phase with initial opacity
+ // 2. After browser paints, set 'running' phase - CSS transition animates
+ setCrossfadePhase('starting');
+
+ // Use double-RAF for Safari to ensure initial state is painted
+ // Safari needs to see the starting state before transitioning
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ setCrossfadePhase('running');
+ });
+ });
+
+ // Fallback timer - ensures completion even if transition events don't fire
const duration = getCrossfadeDuration();
- // Add 50ms buffer for Safari's animation timing variance
- const bufferMs = isSafari() ? 100 : 50;
+ // Add buffer: Safari needs more time, Chrome/Firefox less
+ const bufferMs = isSafari() ? 150 : 100;
fadeInTimerRef.current = setTimeout(() => {
fadeInTimerRef.current = null;
@@ -325,13 +379,17 @@ export function useBackgroundTransition({
}
}, [pageSwitch.isSwitching, fadeIn, completeFadeIn]);
- // Cleanup timer on unmount
+ // Cleanup timer and RAF on unmount
useEffect(() => {
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
};
}, []);
@@ -339,7 +397,9 @@ export function useBackgroundTransition({
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
+ crossfadePhase,
onFadeInAnimationEnd,
+ onTransitionEnd,
resetFadeIn,
};
}