diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 5e73e4319a1ba..7ab260cd8ab1b 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -4,6 +4,7 @@
### Enhancements
+- `Navigator`: use vanilla CSS animations instead of `framer-motion` ([#56909](https://github.com/WordPress/gutenberg/pull/56909)).
- `FormToggle`: fix sass deprecation warning ([#56672](https://github.com/WordPress/gutenberg/pull/56672)).
- `QueryControls`: Add opt-in prop for 40px default size ([#56576](https://github.com/WordPress/gutenberg/pull/56576)).
- `CheckboxControl`: Add option to not render label ([#56158](https://github.com/WordPress/gutenberg/pull/56158)).
diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx
index cccbb84f0d093..cd38bea574813 100644
--- a/packages/components/src/navigator/navigator-provider/component.tsx
+++ b/packages/components/src/navigator/navigator-provider/component.tsx
@@ -2,7 +2,6 @@
* External dependencies
*/
import type { ForwardedRef } from 'react';
-import { css } from '@emotion/react';
/**
* WordPress dependencies
@@ -23,15 +22,16 @@ import isShallowEqual from '@wordpress/is-shallow-equal';
import type { WordPressComponentProps } from '../../context';
import { contextConnect, useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
+import { patternMatch, findParent } from '../utils/router';
import { View } from '../../view';
import { NavigatorContext } from '../context';
+import * as styles from '../styles';
import type {
NavigatorProviderProps,
NavigatorLocation,
NavigatorContext as NavigatorContextType,
Screen,
} from '../types';
-import { patternMatch, findParent } from '../utils/router';
type MatchedPath = ReturnType< typeof patternMatch >;
type ScreenAction = { type: string; screen: Screen };
@@ -248,8 +248,7 @@ function UnconnectedNavigatorProvider(
const cx = useCx();
const classes = useMemo(
- // Prevents horizontal overflow while animating screen transitions.
- () => cx( css( { overflowX: 'hidden' } ), className ),
+ () => cx( styles.navigatorProviderWrapper, className ),
[ className, cx ]
);
diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx
index ed4ab9629d3a8..29981d46770ee 100644
--- a/packages/components/src/navigator/navigator-screen/component.tsx
+++ b/packages/components/src/navigator/navigator-screen/component.tsx
@@ -2,11 +2,6 @@
* External dependencies
*/
import type { ForwardedRef } from 'react';
-// eslint-disable-next-line no-restricted-imports
-import type { MotionProps } from 'framer-motion';
-// eslint-disable-next-line no-restricted-imports
-import { motion } from 'framer-motion';
-import { css } from '@emotion/react';
/**
* WordPress dependencies
@@ -19,8 +14,8 @@ import {
useRef,
useId,
} from '@wordpress/element';
-import { useReducedMotion, useMergeRefs } from '@wordpress/compose';
-import { isRTL } from '@wordpress/i18n';
+import { useMergeRefs } from '@wordpress/compose';
+import { isRTL as isRTLFn } from '@wordpress/i18n';
import { escapeAttribute } from '@wordpress/escape-html';
/**
@@ -31,22 +26,11 @@ import { contextConnect, useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import { View } from '../../view';
import { NavigatorContext } from '../context';
+import * as styles from '../styles';
import type { NavigatorScreenProps } from '../types';
-const animationEnterDelay = 0;
-const animationEnterDuration = 0.14;
-const animationExitDuration = 0.14;
-const animationExitDelay = 0;
-
-// Props specific to `framer-motion` can't be currently passed to `NavigatorScreen`,
-// as some of them would overlap with HTML props (e.g. `onAnimationStart`, ...)
-type Props = Omit<
- WordPressComponentProps< NavigatorScreenProps, 'div', false >,
- Exclude< keyof MotionProps, 'style' | 'children' >
->;
-
function UnconnectedNavigatorScreen(
- props: Props,
+ props: WordPressComponentProps< NavigatorScreenProps, 'div', false >,
forwardedRef: ForwardedRef< any >
) {
const screenId = useId();
@@ -55,7 +39,6 @@ function UnconnectedNavigatorScreen(
'NavigatorScreen'
);
- const prefersReducedMotion = useReducedMotion();
const { location, match, addScreen, removeScreen } =
useContext( NavigatorContext );
const isMatch = match === screenId;
@@ -70,19 +53,20 @@ function UnconnectedNavigatorScreen(
return () => removeScreen( screen );
}, [ screenId, path, addScreen, removeScreen ] );
+ const isRTL = isRTLFn();
+ const { isInitial, isBack } = location;
const cx = useCx();
const classes = useMemo(
() =>
cx(
- css( {
- // Ensures horizontal overflow is visually accessible.
- overflowX: 'auto',
- // In case the root has a height, it should not be exceeded.
- maxHeight: '100%',
+ styles.navigatorScreen( {
+ isInitial,
+ isBack,
+ isRTL,
} ),
className
),
- [ className, cx ]
+ [ className, cx, isInitial, isBack, isRTL ]
);
const locationRef = useRef( location );
@@ -149,73 +133,11 @@ function UnconnectedNavigatorScreen(
const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );
- if ( ! isMatch ) {
- return null;
- }
-
- if ( prefersReducedMotion ) {
- return (
-
- { children }
-
- );
- }
-
- const animate = {
- opacity: 1,
- transition: {
- delay: animationEnterDelay,
- duration: animationEnterDuration,
- ease: 'easeInOut',
- },
- x: 0,
- };
- // Disable the initial animation if the screen is the very first screen to be
- // rendered within the current `NavigatorProvider`.
- const initial =
- location.isInitial && ! location.isBack
- ? false
- : {
- opacity: 0,
- x:
- ( isRTL() && location.isBack ) ||
- ( ! isRTL() && ! location.isBack )
- ? 50
- : -50,
- };
- const exit = {
- delay: animationExitDelay,
- opacity: 0,
- x:
- ( ! isRTL() && location.isBack ) || ( isRTL() && ! location.isBack )
- ? 50
- : -50,
- transition: {
- duration: animationExitDuration,
- ease: 'easeInOut',
- },
- };
-
- const animatedProps = {
- animate,
- exit,
- initial,
- };
-
- return (
-
+ return isMatch ? (
+
{ children }
-
- );
+
+ ) : null;
}
/**
diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts
new file mode 100644
index 0000000000000..8ec5f11da16d3
--- /dev/null
+++ b/packages/components/src/navigator/styles.ts
@@ -0,0 +1,71 @@
+/**
+ * External dependencies
+ */
+import { css, keyframes } from '@emotion/react';
+
+export const navigatorProviderWrapper = css`
+ /* Prevents horizontal overflow while animating screen transitions */
+ overflow-x: hidden;
+ /* Mark this subsection of the DOM as isolated, providing performance benefits
+ * by limiting calculations of layout, style, paint, size, or any combination
+ * to a DOM subtree rather than the entire page.
+ */
+ contain: strict;
+`;
+
+const fadeInFromRight = keyframes( {
+ '0%': {
+ opacity: 0,
+ transform: `translateX( 50px )`,
+ },
+ '100%': { opacity: 1, transform: 'none' },
+} );
+
+const fadeInFromLeft = keyframes( {
+ '0%': {
+ opacity: 0,
+ transform: `translateX( -50px )`,
+ },
+ '100%': { opacity: 1, transform: 'none' },
+} );
+
+type NavigatorScreenAnimationProps = {
+ isInitial?: boolean;
+ isBack?: boolean;
+ isRTL: boolean;
+};
+
+const navigatorScreenAnimation = ( {
+ isInitial,
+ isBack,
+ isRTL,
+}: NavigatorScreenAnimationProps ) => {
+ if ( isInitial && ! isBack ) {
+ return;
+ }
+
+ const animationName =
+ ( isRTL && isBack ) || ( ! isRTL && ! isBack )
+ ? fadeInFromRight
+ : fadeInFromLeft;
+
+ return css`
+ animation-duration: 0.14s;
+ animation-timing-function: ease-in-out;
+ will-change: transform, opacity;
+ animation-name: ${ animationName };
+
+ @media ( prefers-reduced-motion ) {
+ animation-duration: 0s;
+ }
+ `;
+};
+
+export const navigatorScreen = ( props: NavigatorScreenAnimationProps ) => css`
+ /* Ensures horizontal overflow is visually accessible */
+ overflow-x: auto;
+ /* In case the root has a height, it should not be exceeded */
+ max-height: 100%;
+
+ ${ navigatorScreenAnimation( props ) }
+`;
diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx
index 5a711b8730224..b83bd70d9d744 100644
--- a/packages/components/src/navigator/test/index.tsx
+++ b/packages/components/src/navigator/test/index.tsx
@@ -769,68 +769,4 @@ describe( 'Navigator', () => {
).toHaveFocus();
} );
} );
-
- describe( 'animation', () => {
- it( 'should not animate the initial screen', async () => {
- const onHomeAnimationStartSpy = jest.fn();
-
- render(
-
-
-
- To child
-
-
-
- );
-
- expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled();
- } );
-
- it( 'should animate all other screens (including the initial screen when navigating back)', async () => {
- const user = userEvent.setup();
-
- const onHomeAnimationStartSpy = jest.fn();
- const onChildAnimationStartSpy = jest.fn();
-
- render(
-
-
-
- To child
-
-
-
-
- Back to home
-
-
-
- );
-
- expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled();
- expect( onChildAnimationStartSpy ).not.toHaveBeenCalled();
-
- await user.click(
- screen.getByRole( 'button', { name: 'To child' } )
- );
- expect( onChildAnimationStartSpy ).toHaveBeenCalledTimes( 1 );
- expect( onHomeAnimationStartSpy ).not.toHaveBeenCalled();
-
- await user.click(
- screen.getByRole( 'button', { name: 'Back to home' } )
- );
- expect( onChildAnimationStartSpy ).toHaveBeenCalledTimes( 1 );
- expect( onHomeAnimationStartSpy ).toHaveBeenCalledTimes( 1 );
- } );
- } );
} );