implement View Transitions API for smoother page navigation and replace the current implementation

This commit is contained in:
Mo Tarbin
2026-01-15 20:12:36 -05:00
parent 3a074ef6c5
commit f527064e55
2 changed files with 186 additions and 120 deletions

View File

@@ -1,58 +1,93 @@
/* Page Transition Animations */ /* Page Wrapper */
.page-wrapper { .page-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
} }
/* Enter animations */ /* View Transitions API - Native browser page transitions */
.page-enter { /* These run automatically when the browser supports view transitions */
opacity: 0;
transform: translateX(20px); /* Forward navigation (going deeper) */
::view-transition-old(root):only-child {
animation: slide-out-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
} }
.page-enter-active { ::view-transition-new(root):only-child {
opacity: 1; animation: slide-in-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
transform: translateX(0);
transition: opacity 300ms ease-out, transform 300ms ease-out;
} }
/* Exit animations */ /* Back navigation (going up) */
.page-exit { html[data-transition='back']::view-transition-old(root):only-child {
opacity: 1; animation: slide-out-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
transform: translateX(0);
} }
.page-exit-active { html[data-transition='back']::view-transition-new(root):only-child {
opacity: 0; animation: slide-in-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
transform: translateX(-20px);
transition: opacity 200ms ease-in, transform 200ms ease-in;
} }
/* Specific animation for back navigation */ /* Fade for auth pages */
.page-back-enter { html[data-transition='fade']::view-transition-old(root):only-child,
opacity: 0; html[data-transition='fade']::view-transition-new(root):only-child {
transform: translateX(-20px); animation: fade 200ms ease-in-out;
} }
.page-back-enter-active { /* Animation keyframes */
opacity: 1; @keyframes slide-out-left {
transform: translateX(0); from {
transition: opacity 300ms ease-out, transform 300ms ease-out; transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-30%);
opacity: 0;
}
} }
.page-back-exit { @keyframes slide-in-right {
opacity: 1; from {
transform: translateX(0); transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
.page-back-exit-active { @keyframes slide-out-right {
opacity: 0; from {
transform: translateX(20px); transform: translateX(0);
transition: opacity 200ms ease-in, transform 200ms ease-in; opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
} }
/* Modal/overlay animations */ @keyframes slide-in-left {
from {
transform: translateX(-30%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* Modal/overlay animations (still useful for non-route transitions) */
.modal-enter { .modal-enter {
opacity: 0; opacity: 0;
transform: scale(0.95) translateY(10px); transform: scale(0.95) translateY(10px);
@@ -61,7 +96,9 @@
.modal-enter-active { .modal-enter-active {
opacity: 1; opacity: 1;
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
transition: opacity 250ms ease-out, transform 250ms ease-out; transition:
opacity 250ms ease-out,
transform 250ms ease-out;
} }
.modal-exit { .modal-exit {
@@ -72,26 +109,9 @@
.modal-exit-active { .modal-exit-active {
opacity: 0; opacity: 0;
transform: scale(0.95) translateY(10px); transform: scale(0.95) translateY(10px);
transition: opacity 200ms ease-in, transform 200ms ease-in; transition:
} opacity 200ms ease-in,
transform 200ms ease-in;
/* Fade animation for simple transitions */
.fade-enter {
opacity: 0;
}
.fade-enter-active {
opacity: 1;
transition: opacity 250ms ease-out;
}
.fade-exit {
opacity: 1;
}
.fade-exit-active {
opacity: 0;
transition: opacity 200ms ease-in;
} }
/* Slide up animation for bottom sheets/panels */ /* Slide up animation for bottom sheets/panels */
@@ -103,7 +123,9 @@
.slide-up-enter-active { .slide-up-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 300ms ease-out, transform 300ms ease-out; transition:
opacity 300ms ease-out,
transform 300ms ease-out;
} }
.slide-up-exit { .slide-up-exit {
@@ -114,7 +136,9 @@
.slide-up-exit-active { .slide-up-exit-active {
opacity: 0; opacity: 0;
transform: translateY(100%); transform: translateY(100%);
transition: opacity 250ms ease-in, transform 250ms ease-in; transition:
opacity 250ms ease-in,
transform 250ms ease-in;
} }
/* Loading spinner animation */ /* Loading spinner animation */
@@ -171,33 +195,49 @@
.stagger-enter-active { .stagger-enter-active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 300ms ease-out, transform 300ms ease-out; transition:
opacity 300ms ease-out,
transform 300ms ease-out;
} }
/* Add animation delays for staggered effects */ /* Add animation delays for staggered effects */
.stagger-enter-active:nth-child(1) { transition-delay: 0ms; } .stagger-enter-active:nth-child(1) {
.stagger-enter-active:nth-child(2) { transition-delay: 50ms; } transition-delay: 0ms;
.stagger-enter-active:nth-child(3) { transition-delay: 100ms; } }
.stagger-enter-active:nth-child(4) { transition-delay: 150ms; } .stagger-enter-active:nth-child(2) {
.stagger-enter-active:nth-child(5) { transition-delay: 200ms; } transition-delay: 50ms;
.stagger-enter-active:nth-child(6) { transition-delay: 250ms; } }
.stagger-enter-active:nth-child(7) { transition-delay: 300ms; } .stagger-enter-active:nth-child(3) {
.stagger-enter-active:nth-child(8) { transition-delay: 350ms; } transition-delay: 100ms;
}
.stagger-enter-active:nth-child(4) {
transition-delay: 150ms;
}
.stagger-enter-active:nth-child(5) {
transition-delay: 200ms;
}
.stagger-enter-active:nth-child(6) {
transition-delay: 250ms;
}
.stagger-enter-active:nth-child(7) {
transition-delay: 300ms;
}
.stagger-enter-active:nth-child(8) {
transition-delay: 350ms;
}
/* Reduced motion for accessibility */ /* Reduced motion for accessibility */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.page-enter, /* Disable View Transitions for users who prefer reduced motion */
.page-enter-active, ::view-transition-old(root),
.page-exit, ::view-transition-new(root) {
.page-exit-active, animation: none !important;
}
.modal-enter, .modal-enter,
.modal-enter-active, .modal-enter-active,
.modal-exit, .modal-exit,
.modal-exit-active, .modal-exit-active,
.fade-enter,
.fade-enter-active,
.fade-exit,
.fade-exit-active,
.interactive-element, .interactive-element,
.stagger-enter, .stagger-enter,
.stagger-enter-active { .stagger-enter-active {

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import './PageTransition.css' import './PageTransition.css'
// Route hierarchy for determining navigation direction // Route hierarchy for determining navigation direction
@@ -14,9 +14,11 @@ const routeHierarchy = {
'/activities': 1, '/activities': 1,
'/points': 1, '/points': 1,
'/labels': 1, '/labels': 1,
'/projects': 1,
'/login': 0, '/login': 0,
'/signup': 1, '/signup': 1,
'/landing': 0, '/landing': 0,
'/archived': 1,
} }
const getRouteLevel = pathname => { const getRouteLevel = pathname => {
@@ -32,16 +34,23 @@ const getRouteLevel = pathname => {
if (pathname.includes('/chores/') && pathname.includes('/history')) { if (pathname.includes('/chores/') && pathname.includes('/history')) {
return 3 return 3
} }
if (pathname.includes('/chores/') && pathname.includes('/timer')) {
return 3
}
if ( if (
pathname.includes('/chores/') && pathname.includes('/chores/') &&
!pathname.includes('/edit') && !pathname.includes('/edit') &&
!pathname.includes('/history') !pathname.includes('/history') &&
!pathname.includes('/timer')
) { ) {
return 2 return 2
} }
if (pathname.includes('/things/')) { if (pathname.includes('/things/')) {
return 2 return 2
} }
if (pathname.includes('/settings/')) {
return 2
}
// Default level // Default level
return 1 return 1
@@ -49,55 +58,72 @@ const getRouteLevel = pathname => {
const PageTransition = ({ children }) => { const PageTransition = ({ children }) => {
const location = useLocation() const location = useLocation()
const prevLocation = useRef(location) const prevLevel = useRef(0)
const isNavigatingBack = useRef(false) const [displayLocation, setDisplayLocation] = useState(location)
const nodeRef = useRef(null) const isFirstRender = useRef(true)
useEffect(() => { useLayoutEffect(() => {
const currentLevel = getRouteLevel(location.pathname) // Skip transition on first render
const previousLevel = getRouteLevel(prevLocation.current.pathname) if (isFirstRender.current) {
isFirstRender.current = false
// Determine if we're navigating back (to a higher level in hierarchy) setDisplayLocation(location)
// isNavigatingBack.current = currentLevel < previousLevel prevLevel.current = getRouteLevel(location.pathname)
window.scrollTo({ top: 0, left: 0, behavior: 'instant' }) return
prevLocation.current = location
}, [location])
const getTransitionClasses = () => {
if (
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing')
) {
return 'fade' // Use fade for auth pages
} }
return isNavigatingBack.current ? 'page-back' : 'page' // Don't transition if location hasn't actually changed
} if (location.pathname === displayLocation.pathname) {
return
}
const currentLevel = getRouteLevel(location.pathname)
const previousLevel = prevLevel.current
// Determine navigation direction
const isBack = currentLevel < previousLevel
const isFade =
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing') ||
location.pathname.includes('/auth/')
// Apply transition type as data attribute for CSS
document.documentElement.dataset.transition = isFade
? 'fade'
: isBack
? 'back'
: 'forward'
// Trigger View Transition if supported
if (document.startViewTransition) {
document.startViewTransition(() => {
// flushSync forces React to update the DOM synchronously
// This ensures the View Transition captures the actual DOM change
flushSync(() => {
setDisplayLocation(location)
})
// Scroll to top after DOM update
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
} else {
// Fallback for browsers without View Transitions API
setDisplayLocation(location)
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}
// Update refs
prevLevel.current = currentLevel
}, [location, displayLocation.pathname])
return ( return (
<TransitionGroup component={null}> <div
<CSSTransition className='page-wrapper'
key={location.pathname} style={{
classNames={getTransitionClasses()} paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
timeout={{ }}
enter: 50, >
exit: 50, {displayLocation.pathname === location.pathname ? children : null}
}} </div>
nodeRef={nodeRef}
>
<div
ref={nodeRef}
className='page-wrapper'
style={{
paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
}}
>
{children}
</div>
</CSSTransition>
</TransitionGroup>
) )
} }