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: {}