diff --git a/docs/package.json b/docs/package.json
index 62910c3523..ab0e070da2 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -87,9 +87,9 @@
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@types/unist": "^3.0.3",
"chai": "^4.5.0",
- "framer-motion": "^11.12.0",
"fs-extra": "^11.2.0",
"mdast-util-mdx-jsx": "^3.1.3",
+ "motion": "^11.15.0",
"prettier": "^3.4.1",
"rimraf": "^5.0.10",
"serve": "^14.2.4",
diff --git a/docs/reference/generated/popover-portal.json b/docs/reference/generated/popover-portal.json
index 4a4554c421..721ef34a9d 100644
--- a/docs/reference/generated/popover-portal.json
+++ b/docs/reference/generated/popover-portal.json
@@ -7,7 +7,7 @@
"description": "A parent element to render the portal element into."
},
"keepMounted": {
- "type": "boolean",
+ "type": "{ current: { unmount: func } } | bool",
"default": "false",
"description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
}
diff --git a/docs/reference/generated/popover-root.json b/docs/reference/generated/popover-root.json
index 6b3efcddcc..6a14df1a02 100644
--- a/docs/reference/generated/popover-root.json
+++ b/docs/reference/generated/popover-root.json
@@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the popover is opened or closed."
},
+ "unmountRef": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to manually unmount the popover."
+ },
"openOnHover": {
"type": "boolean",
"default": "false",
diff --git a/docs/src/app/(private)/experiments/collapsible-framer.tsx b/docs/src/app/(private)/experiments/collapsible-framer.tsx
index 64f032aedd..f876c569c1 100644
--- a/docs/src/app/(private)/experiments/collapsible-framer.tsx
+++ b/docs/src/app/(private)/experiments/collapsible-framer.tsx
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Collapsible } from '@base-ui-components/react/collapsible';
-import { motion } from 'framer-motion';
+import { motion } from 'motion/react';
import c from './collapsible.module.css';
export default function CollapsibleFramer() {
diff --git a/docs/src/app/(private)/experiments/motion.tsx b/docs/src/app/(private)/experiments/motion.tsx
new file mode 100644
index 0000000000..dcabcbed32
--- /dev/null
+++ b/docs/src/app/(private)/experiments/motion.tsx
@@ -0,0 +1,106 @@
+'use client';
+import * as React from 'react';
+import { Popover } from '@base-ui-components/react/popover';
+import { motion, AnimatePresence } from 'motion/react';
+
+function ConditionallyMounted() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ Trigger
+
+ {open && (
+
+
+
+ }
+ >
+ Popup
+
+
+
+ )}
+
+
+ );
+}
+
+function AlwaysMounted() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ Trigger
+
+
+
+ }
+ >
+ Popup
+
+
+
+
+ );
+}
+
+function NoOpacity() {
+ const [open, setOpen] = React.useState(false);
+ const unmountRef = React.useRef({ unmount: () => {} });
+
+ return (
+
+ Trigger
+
+ {open && (
+
+
+ {
+ if (!open) {
+ unmountRef.current.unmount();
+ }
+ }}
+ />
+ }
+ >
+ Popup
+
+
+
+ )}
+
+
+ );
+}
+
+export default function Page() {
+ return (
+
+
Conditionally mounted
+
+
Always mounted
+
+
No opacity
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/tooltip.tsx b/docs/src/app/(private)/experiments/tooltip.tsx
index e205e70991..9c77b8d5ce 100644
--- a/docs/src/app/(private)/experiments/tooltip.tsx
+++ b/docs/src/app/(private)/experiments/tooltip.tsx
@@ -2,7 +2,7 @@
import * as React from 'react';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { styled, keyframes } from '@mui/system';
-import { motion, AnimatePresence } from 'framer-motion';
+import { motion, AnimatePresence } from 'motion/react';
const scaleIn = keyframes`
from {
diff --git a/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx b/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx
index 792148415a..f61a979ab5 100644
--- a/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx
+++ b/docs/src/app/(public)/(content)/react/handbook/animation/page.mdx
@@ -74,5 +74,126 @@ Use the following Base UI attributes for creating CSS animations when a compone
## JavaScript animations
-JavaScript animation libraries such as [Motion](https://motion.dev) require control of the mounting and unmounting lifecycle of components.
-Most Base UI components are unmounted when hidden. These components usually provide the `keepMounted` prop to allow JavaScript animation libraries to take control.
+JavaScript animation libraries such as [Motion](https://motion.dev) require control of the mounting and unmounting lifecycle of components in order for exit animations to play.
+
+Base UI relies on [`element.getAnimations()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getAnimations) to detect if animations have finished on an element.
+When using Motion, the `opacity` property lets this detection work easily, so always animating `opacity` to a new value for exit animations will work.
+If it shouldn't be animated, you can use a value close to `1`, such as `opacity: 0.9999`.
+
+### Elements removed from the DOM when closed
+
+Most components like Popover are unmounted from the DOM when they are closed. To animate them:
+
+- Make the component controlled with the `open` prop so `AnimatePresence` can see the state as a child
+- Specify `keepMounted` on the `Portal` part
+- Use the `render` prop to compose the `Popup` with `motion.div`
+
+```jsx title="animated-popover.tsx" {11-17} "keepMounted"
+function App() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ Trigger
+
+ {open && (
+
+
+
+ }
+ >
+ Popup
+
+
+
+ )}
+
+
+ );
+}
+```
+
+### Elements kept in the DOM when closed
+
+The `Select` component must be kept mounted in the DOM even when closed. In this case, a
+different approach is needed to animate it with Motion.
+
+- Make the component controlled with the `open` prop
+- Use the `render` prop to compose the `Popup` with `motion.div`
+- Animate the properties based on the `open` state, avoiding `AnimatePresence`
+
+```jsx title="animated-select.tsx" {11-19}
+function App() {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+
+
+
+
+
+
+ }
+ >
+ Popup
+
+
+
+
+ );
+}
+```
+
+### Manual unmounting
+
+For full control, you can manually unmount the component when it's closed once animations have finished using an `unmountRef` passed to the `Root`:
+
+```jsx title="manual-unmount.tsx" "unmountRef"
+function App() {
+ const [open, setOpen] = React.useState(false);
+ const unmountRef = React.useRef({ unmount: () => {} });
+
+ return (
+
+ Trigger
+
+ {open && (
+
+
+ {
+ if (!open) {
+ unmountRef.current.unmount();
+ }
+ }}
+ />
+ }
+ >
+ Popup
+
+
+
+ )}
+
+
+ );
+}
+```
diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx
index 080d1d06b4..647fd12f95 100644
--- a/packages/react/src/popover/portal/PopoverPortal.tsx
+++ b/packages/react/src/popover/portal/PopoverPortal.tsx
@@ -61,7 +61,14 @@ PopoverPortal.propTypes /* remove-proptypes */ = {
* Whether to keep the portal mounted in the DOM while the popup is hidden.
* @default false
*/
- keepMounted: PropTypes.bool,
+ keepMounted: PropTypes.oneOfType([
+ PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
+ PropTypes.bool,
+ ]),
} as any;
export { PopoverPortal };
diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx
index 9fedbbf161..6231c11370 100644
--- a/packages/react/src/popover/root/PopoverRoot.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.tsx
@@ -12,7 +12,7 @@ import { OPEN_DELAY } from '../utils/constants';
* Documentation: [Base UI Popover](https://base-ui.com/react/components/popover)
*/
const PopoverRoot: React.FC = function PopoverRoot(props) {
- const { openOnHover = false, delay, closeDelay = 0 } = props;
+ const { openOnHover = false, delay, closeDelay = 0, unmountRef } = props;
const delayWithDefault = delay ?? OPEN_DELAY;
@@ -43,6 +43,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
open: props.open,
onOpenChange: props.onOpenChange,
defaultOpen: props.defaultOpen,
+ unmountRef,
});
const contextValue: PopoverRootContext = React.useMemo(
@@ -153,6 +154,14 @@ PopoverRoot.propTypes /* remove-proptypes */ = {
* @default false
*/
openOnHover: PropTypes.bool,
+ /**
+ * A ref to manually unmount the popover.
+ */
+ unmountRef: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
} as any;
export { PopoverRoot };
diff --git a/packages/react/src/popover/root/usePopoverRoot.ts b/packages/react/src/popover/root/usePopoverRoot.ts
index 02ecfe22f5..a972418305 100644
--- a/packages/react/src/popover/root/usePopoverRoot.ts
+++ b/packages/react/src/popover/root/usePopoverRoot.ts
@@ -75,15 +75,20 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
},
);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ setOpenReason(null);
+ });
+
useAfterExitAnimation({
+ enabled: !params.unmountRef,
open,
animatedElementRef: popupRef,
- onFinished: () => {
- setMounted(false);
- setOpenReason(null);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.unmountRef, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
React.useEffect(() => {
return () => {
clearTimeout(clickEnabledTimeoutRef.current);
@@ -229,6 +234,10 @@ export namespace usePopoverRoot {
* @default 0
*/
closeDelay?: number;
+ /**
+ * A ref to manually unmount the popover.
+ */
+ unmountRef?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/utils/useAfterExitAnimation.tsx b/packages/react/src/utils/useAfterExitAnimation.tsx
index 4ad5f1f3e8..5843965fd0 100644
--- a/packages/react/src/utils/useAfterExitAnimation.tsx
+++ b/packages/react/src/utils/useAfterExitAnimation.tsx
@@ -1,5 +1,5 @@
+import * as React from 'react';
import { useAnimationsFinished } from './useAnimationsFinished';
-import { useEnhancedEffect } from './useEnhancedEffect';
import { useEventCallback } from './useEventCallback';
import { useLatestRef } from './useLatestRef';
@@ -8,13 +8,17 @@ import { useLatestRef } from './useLatestRef';
* Useful for unmounting the component after animating out.
*/
export function useAfterExitAnimation(parameters: useAfterExitAnimation.Parameters) {
- const { open, animatedElementRef, onFinished: onFinishedParam } = parameters;
+ const { enabled = true, open, animatedElementRef, onFinished: onFinishedParam } = parameters;
const onFinished = useEventCallback(onFinishedParam);
const runOnceAnimationsFinish = useAnimationsFinished(animatedElementRef);
const openRef = useLatestRef(open);
- useEnhancedEffect(() => {
+ React.useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
function callOnFinished() {
if (!openRef.current) {
onFinished();
@@ -24,11 +28,12 @@ export function useAfterExitAnimation(parameters: useAfterExitAnimation.Paramete
if (!open) {
runOnceAnimationsFinish(callOnFinished);
}
- }, [open, openRef, runOnceAnimationsFinish, onFinished]);
+ }, [enabled, open, openRef, runOnceAnimationsFinish, onFinished]);
}
export namespace useAfterExitAnimation {
export interface Parameters {
+ enabled?: boolean;
/**
* Determines if the component is open.
* The logic runs when the component goes from open to closed.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9165f640a6..cbec199fba 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -576,15 +576,15 @@ importers:
chai:
specifier: ^4.5.0
version: 4.5.0
- framer-motion:
- specifier: ^11.12.0
- version: 11.12.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614)
fs-extra:
specifier: ^11.2.0
version: 11.2.0
mdast-util-mdx-jsx:
specifier: ^3.1.3
version: 3.1.3
+ motion:
+ specifier: ^11.15.0
+ version: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614)
prettier:
specifier: ^3.4.1
version: 3.4.1
@@ -5376,12 +5376,12 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
- framer-motion@11.12.0:
- resolution: {integrity: sha512-gZaZeqFM6pX9kMVti60hYAa75jGpSsGYWAHbBfIkuHN7DkVHVkxSxeNYnrGmHuM0zPkWTzQx10ZT+fDjn7N4SA==}
+ framer-motion@11.15.0:
+ resolution: {integrity: sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
- react: ^18.0.0
- react-dom: ^18.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
@@ -7128,6 +7128,26 @@ packages:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'}
+ motion-dom@11.14.3:
+ resolution: {integrity: sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==}
+
+ motion-utils@11.14.3:
+ resolution: {integrity: sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==}
+
+ motion@11.15.0:
+ resolution: {integrity: sha512-iZ7dwADQJWGsqsSkBhNHdI2LyYWU+hA1Nhy357wCLZq1yHxGImgt3l7Yv0HT/WOskcYDq9nxdedyl4zUv7UFFw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -15270,8 +15290,10 @@ snapshots:
forwarded@0.2.0: {}
- framer-motion@11.12.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614):
+ framer-motion@11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614):
dependencies:
+ motion-dom: 11.14.3
+ motion-utils: 11.14.3
tslib: 2.6.2
optionalDependencies:
'@emotion/is-prop-valid': 1.3.0
@@ -17559,6 +17581,19 @@ snapshots:
modify-values@1.0.1: {}
+ motion-dom@11.14.3: {}
+
+ motion-utils@11.14.3: {}
+
+ motion@11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614):
+ dependencies:
+ framer-motion: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614))(react@19.0.0-rc-fb9a90fa48-20240614)
+ tslib: 2.6.2
+ optionalDependencies:
+ '@emotion/is-prop-valid': 1.3.0
+ react: 19.0.0-rc-fb9a90fa48-20240614
+ react-dom: 19.0.0-rc-fb9a90fa48-20240614(react@19.0.0-rc-fb9a90fa48-20240614)
+
mri@1.2.0: {}
mrmime@2.0.0: {}