fixed fades issue in the Safari browser

This commit is contained in:
Dmitri 2026-04-14 18:30:11 +04:00
parent 1e2b72d2bd
commit 4d189346d9
2 changed files with 144 additions and 17 deletions

View File

@ -16,9 +16,27 @@
:root {
--crossfade-duration: 700ms;
/* Smooth easing: slow start, gentle acceleration, soft landing */
/* Chrome/Firefox: Material Design standard easing */
--crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Safari-specific easing - Safari's bezier curve rendering is different,
use a simpler curve that Safari handles more smoothly */
@supports (-webkit-touch-callout: none) {
:root {
/* Safari: use ease-in-out which Safari renders more consistently */
--crossfade-easing: cubic-bezier(0.42, 0, 0.58, 1);
}
}
/* Firefox-specific optimizations */
@-moz-document url-prefix() {
:root {
/* Firefox handles the standard easing well, but we can optimize */
--crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
}
}
.introjs-tooltip {
@apply min-w-[400px] max-w-[480px] p-2 !important;
@ -90,32 +108,52 @@
}
}
/* Crossfade animation classes - GPU accelerated for Safari */
/* Crossfade animation classes - GPU accelerated for all browsers */
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */
.animate-crossfade-in {
/* Explicit initial state prevents Safari flash during animation setup */
/* Explicit initial state prevents flash during animation setup */
opacity: 0;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
/* Full animation property for maximum browser compatibility */
-webkit-animation-name: page-crossfade-in;
animation-name: page-crossfade-in;
-webkit-animation-duration: var(--crossfade-duration, 700ms);
animation-duration: var(--crossfade-duration, 700ms);
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-play-state: running;
animation-play-state: running;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
/* Only animate opacity - transform is for GPU layer promotion */
/* Optimize compositing */
will-change: opacity;
contain: layout style paint;
}
.animate-crossfade-out {
/* Explicit initial state prevents Safari flash during animation setup */
/* Explicit initial state prevents flash during animation setup */
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
/* Full animation property for maximum browser compatibility */
-webkit-animation-name: page-crossfade-out;
animation-name: page-crossfade-out;
-webkit-animation-duration: var(--crossfade-duration, 700ms);
animation-duration: var(--crossfade-duration, 700ms);
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-play-state: running;
animation-play-state: running;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
/* Only animate opacity - transform is for GPU layer promotion */
/* Optimize compositing */
will-change: opacity;
contain: layout style paint;
}
/* Safari-specific GPU compositing optimization */
@ -126,8 +164,22 @@
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
/* Prevent Safari from optimizing away the GPU layer */
-webkit-perspective: 1000;
perspective: 1000;
-webkit-perspective: 1000px;
perspective: 1000px;
/* Safari performs better with explicit transform-style */
-webkit-transform-style: preserve-3d;
transform-style: preserve-3d;
/* Isolate the layer for better compositing */
isolation: isolate;
}
}
/* Firefox-specific optimizations */
@-moz-document url-prefix() {
.animate-crossfade-in,
.animate-crossfade-out {
/* Firefox handles animations well but benefits from layer isolation */
will-change: opacity, transform;
}
}

View File

@ -14,6 +14,11 @@
* Two modes:
* 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
* 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
*
* Cross-browser notes:
* - Chrome: Uses CSS animations with onAnimationEnd event
* - Safari: Uses JS timer fallback (Safari's onAnimationEnd can be unreliable)
* - Firefox: Uses CSS animations with onAnimationEnd event
*/
import {
@ -74,7 +79,7 @@ export interface UseBackgroundTransitionResult {
/** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean;
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
onFadeInAnimationEnd: () => void;
onFadeInAnimationEnd: (e?: React.AnimationEvent) => void;
/** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
}
@ -121,6 +126,11 @@ export function useBackgroundTransition({
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
// Safari fallback timer ref - Safari's onAnimationEnd can be unreliable
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track if animation was already completed (by event or timer)
const fadeInCompletedRef = useRef(false);
/**
* Reset fade-out state before starting a new transition.
* This prevents the fade-out effect from re-triggering when state resets.
@ -134,15 +144,44 @@ export function useBackgroundTransition({
*/
const resetFadeIn = useCallback(() => {
setIsFadingIn(false);
fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
}, []);
/**
* Complete fade-in animation.
* Called either by onAnimationEnd or by timer fallback.
* Uses ref to prevent double-completion.
*/
const completeFadeIn = useCallback(() => {
if (fadeInCompletedRef.current) return;
fadeInCompletedRef.current = true;
// Clear backup timer if it exists
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false);
}, []);
/**
* Handler for onAnimationEnd event.
* Called when CSS animation completes - CSS is the source of truth for duration.
* Called when CSS animation completes.
* Uses completeFadeIn to handle deduplication with timer fallback.
*/
const onFadeInAnimationEnd = useCallback(() => {
setIsFadingIn(false);
}, []);
const onFadeInAnimationEnd = useCallback(
(e?: React.AnimationEvent) => {
// Only handle the crossfade animation, not child animations
if (e && e.animationName !== 'page-crossfade-in') return;
completeFadeIn();
},
[completeFadeIn],
);
/**
* Effect: Fade out and remove transition overlay when background is ready.
@ -240,6 +279,10 @@ export function useBackgroundTransition({
* preventing any flash of new content at full opacity.
*
* 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
*/
useLayoutEffect(() => {
if (!fadeIn) {
@ -256,9 +299,41 @@ export function useBackgroundTransition({
// Only start crossfade for NON-transition navigation
// Transitions use video overlay - no fade needed
if (justStartedSwitching && !hasActiveTransition) {
// Reset completion flag for new animation
fadeInCompletedRef.current = false;
// Clear any existing timer
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.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.
const duration = getCrossfadeDuration();
// Add 50ms buffer for Safari's animation timing variance
const bufferMs = isSafari() ? 100 : 50;
fadeInTimerRef.current = setTimeout(() => {
fadeInTimerRef.current = null;
completeFadeIn();
}, duration + bufferMs);
}
}, [pageSwitch.isSwitching, fadeIn]);
}, [pageSwitch.isSwitching, fadeIn, completeFadeIn]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, []);
return {
isOverlayFadingOut,