diff --git a/docs/package.json b/docs/package.json
index 7f4042bdee..57aaa92eea 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -87,9 +87,9 @@
"@types/react-dom": "^19.0.2",
"@types/unist": "^3.0.3",
"chai": "^4.5.0",
- "framer-motion": "^11.15.0",
"fs-extra": "^11.2.0",
"mdast-util-mdx-jsx": "^3.1.3",
+ "motion": "^11.15.0",
"prettier": "^3.4.2",
"rimraf": "^5.0.10",
"serve": "^14.2.4",
diff --git a/docs/reference/generated/alert-dialog-root.json b/docs/reference/generated/alert-dialog-root.json
index 7b06febe0f..9d6e612551 100644
--- a/docs/reference/generated/alert-dialog-root.json
+++ b/docs/reference/generated/alert-dialog-root.json
@@ -14,6 +14,10 @@
"onOpenChange": {
"type": "(open, event, reason) => void",
"description": "Event handler called when the dialog is opened or closed."
+ },
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
}
},
"dataAttributes": {},
diff --git a/docs/reference/generated/dialog-root.json b/docs/reference/generated/dialog-root.json
index ae72c2198a..10bdd88567 100644
--- a/docs/reference/generated/dialog-root.json
+++ b/docs/reference/generated/dialog-root.json
@@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the dialog is opened or closed."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"dismissible": {
"type": "boolean",
"default": "true",
diff --git a/docs/reference/generated/menu-root.json b/docs/reference/generated/menu-root.json
index aee7fbf47e..ad36f0f012 100644
--- a/docs/reference/generated/menu-root.json
+++ b/docs/reference/generated/menu-root.json
@@ -15,6 +15,10 @@
"type": "(open, event) => void",
"description": "Event handler called when the menu is opened or closed."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"closeParentOnEsc": {
"type": "boolean",
"default": "true",
diff --git a/docs/reference/generated/popover-root.json b/docs/reference/generated/popover-root.json
index 6b3efcddcc..1939f52f70 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."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"openOnHover": {
"type": "boolean",
"default": "false",
diff --git a/docs/reference/generated/preview-card-root.json b/docs/reference/generated/preview-card-root.json
index e99b8789d7..b0cae6d630 100644
--- a/docs/reference/generated/preview-card-root.json
+++ b/docs/reference/generated/preview-card-root.json
@@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the preview card is opened or closed."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"delay": {
"type": "number",
"default": "600",
diff --git a/docs/reference/generated/select-root.json b/docs/reference/generated/select-root.json
index 50fb8031c1..c5224d3921 100644
--- a/docs/reference/generated/select-root.json
+++ b/docs/reference/generated/select-root.json
@@ -32,6 +32,10 @@
"type": "(open, event) => void",
"description": "Event handler called when the select menu is opened or closed."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"alignItemToTrigger": {
"type": "boolean",
"default": "true",
diff --git a/docs/reference/generated/tooltip-root.json b/docs/reference/generated/tooltip-root.json
index 0e7602dee0..4193d68e2c 100644
--- a/docs/reference/generated/tooltip-root.json
+++ b/docs/reference/generated/tooltip-root.json
@@ -15,6 +15,10 @@
"type": "(open, event, reason) => void",
"description": "Event handler called when the tooltip is opened or closed."
},
+ "action": {
+ "type": "{ current: { unmount: func } }",
+ "description": "A ref to imperative actions."
+ },
"trackCursorAxis": {
"type": "'none' | 'x' | 'y' | 'both'",
"default": "'none'",
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..3afe6e6010
--- /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 actionRef = React.useRef({ unmount: () => {} });
+
+ return (
+
+ Trigger
+
+ {open && (
+
+
+ {
+ if (!open) {
+ actionRef.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..4cd251cbee 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 `action` passed to the `Root`:
+
+```jsx title="manual-unmount.tsx" "action"
+function App() {
+ const [open, setOpen] = React.useState(false);
+ const actionRef = React.useRef({ unmount: () => {} });
+
+ return (
+
+ Trigger
+
+ {open && (
+
+
+ {
+ if (!open) {
+ action.current.unmount();
+ }
+ }}
+ />
+ }
+ >
+ Popup
+
+
+
+ )}
+
+
+ );
+}
+```
diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
index c4e0093131..a2658c7422 100644
--- a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
+++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
@@ -11,8 +11,8 @@ import { useDialogRoot } from '../../dialog/root/useDialogRoot';
*
* Documentation: [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog)
*/
-const AlertDialogRoot: React.FC = function AlertDialogRoot(props) {
- const { children, defaultOpen = false, onOpenChange, open } = props;
+const AlertDialogRoot = function AlertDialogRoot(props: AlertDialogRoot.Props) {
+ const { children, defaultOpen = false, onOpenChange, open, action } = props;
const parentDialogRootContext = React.useContext(AlertDialogRootContext);
@@ -20,6 +20,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo
open,
defaultOpen,
onOpenChange,
+ action,
modal: true,
dismissible: false,
onNestedDialogClose: parentDialogRootContext?.onNestedDialogClose,
@@ -41,7 +42,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo
};
namespace AlertDialogRoot {
- export type Props = Omit;
+ export interface Props extends Omit {}
}
AlertDialogRoot.propTypes /* remove-proptypes */ = {
@@ -49,6 +50,14 @@ AlertDialogRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx
index 3f8a0f8a3e..790c1365a2 100644
--- a/packages/react/src/dialog/root/DialogRoot.test.tsx
+++ b/packages/react/src/dialog/root/DialogRoot.test.tsx
@@ -610,4 +610,42 @@ describe('', () => {
});
});
});
+
+ describe('prop: action', () => {
+ it('unmounts the dialog when the `unmount` method is called', async () => {
+ const actionRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
+
+ const { user } = await render(
+
+ Open
+
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.to.equal(null);
+ });
+
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.to.equal(null);
+ });
+
+ act(() => actionRef.current.unmount());
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/react/src/dialog/root/DialogRoot.tsx b/packages/react/src/dialog/root/DialogRoot.tsx
index 66930017c6..5b2425f150 100644
--- a/packages/react/src/dialog/root/DialogRoot.tsx
+++ b/packages/react/src/dialog/root/DialogRoot.tsx
@@ -19,6 +19,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) {
modal = true,
onOpenChange,
open,
+ action,
} = props;
const parentDialogRootContext = useOptionalDialogRootContext();
@@ -29,6 +30,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) {
onOpenChange,
modal,
dismissible,
+ action,
onNestedDialogClose: parentDialogRootContext?.onNestedDialogClose,
onNestedDialogOpen: parentDialogRootContext?.onNestedDialogOpen,
});
@@ -58,6 +60,14 @@ DialogRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/dialog/root/useDialogRoot.ts b/packages/react/src/dialog/root/useDialogRoot.ts
index 7d1c5240ca..c82703b96d 100644
--- a/packages/react/src/dialog/root/useDialogRoot.ts
+++ b/packages/react/src/dialog/root/useDialogRoot.ts
@@ -59,14 +59,19 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
},
);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ });
+
useAfterExitAnimation({
+ enabled: !params.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
useScrollLock(open && modal, popupElement);
const handleFloatingUIOpenChange = (
@@ -198,10 +203,15 @@ export interface SharedParameters {
* @default true
*/
dismissible?: boolean;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export namespace useDialogRoot {
- export interface Parameters extends RequiredExcept {
+ export interface Parameters
+ extends RequiredExcept {
/**
* Callback to invoke when a nested dialog is opened.
*/
@@ -210,6 +220,10 @@ export namespace useDialogRoot {
* Callback to invoke when a nested dialog is closed.
*/
onNestedDialogClose?: () => void;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx
index 662609a273..aa79ac3f69 100644
--- a/packages/react/src/menu/root/MenuRoot.test.tsx
+++ b/packages/react/src/menu/root/MenuRoot.test.tsx
@@ -876,4 +876,46 @@ describe('', () => {
expect(positioner.previousElementSibling).to.equal(null);
});
});
+
+ describe('prop: action', () => {
+ it('unmounts the menu when the `unmount` method is called', async () => {
+ const actionRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
+
+ await render(
+
+ Open
+
+
+
+ 1
+
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).not.to.equal(null);
+ });
+
+ act(() => actionRef.current.unmount());
+
+ await waitFor(() => {
+ expect(screen.queryByRole('menu')).to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index ce482cfe37..41a0aff1bc 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -13,7 +13,7 @@ import type { OpenChangeReason } from '../../utils/translateOpenChangeReason';
*
* Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
*/
-const MenuRoot: React.FC = function MenuRoot(props) {
+const MenuRoot = function MenuRoot(props: MenuRoot.Props) {
const {
children,
defaultOpen = false,
@@ -26,6 +26,7 @@ const MenuRoot: React.FC = function MenuRoot(props) {
orientation = 'vertical',
delay = 100,
openOnHover: openOnHoverProp,
+ action,
} = props;
const direction = useDirection();
@@ -54,6 +55,7 @@ const MenuRoot: React.FC = function MenuRoot(props) {
delay,
onTypingChange,
modal,
+ action,
});
const context: MenuRootContext = React.useMemo(
@@ -141,6 +143,10 @@ namespace MenuRoot {
* Defaults to `true` for nested menus.
*/
openOnHover?: boolean;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
}
@@ -149,6 +155,14 @@ MenuRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/menu/root/useMenuRoot.ts b/packages/react/src/menu/root/useMenuRoot.ts
index 058d927800..4df9ceafaf 100644
--- a/packages/react/src/menu/root/useMenuRoot.ts
+++ b/packages/react/src/menu/root/useMenuRoot.ts
@@ -89,17 +89,22 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret
},
);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ setOpenReason(null);
+ setHoverEnabled(true);
+ setStickIfOpen(true);
+ });
+
useAfterExitAnimation({
+ enabled: !parameters.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- setOpenReason(null);
- setHoverEnabled(true);
- setStickIfOpen(true);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(parameters.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
const clearStickIfOpenTimeout = useEventCallback(() => {
clearTimeout(stickIfOpenTimeoutRef.current);
});
@@ -347,6 +352,10 @@ export namespace useMenuRoot {
*/
onTypingChange: (typing: boolean) => void;
modal: boolean;
+ /**
+ * A ref to imperative actions.
+ */
+ action: React.RefObject<{ unmount: () => void }> | undefined;
}
export interface ReturnValue {
diff --git a/packages/react/src/popover/root/PopoverRoot.test.tsx b/packages/react/src/popover/root/PopoverRoot.test.tsx
index be770578a0..05441b9c92 100644
--- a/packages/react/src/popover/root/PopoverRoot.test.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.test.tsx
@@ -535,4 +535,44 @@ describe('', () => {
expect(lastInput).toHaveFocus();
});
});
+
+ describe('prop: action', () => {
+ it('unmounts the popover when the `unmount` method is called', async () => {
+ const actionRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
+
+ const { user } = await render(
+
+ Open
+
+
+ Content
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.to.equal(null);
+ });
+
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.to.equal(null);
+ });
+
+ act(() => actionRef.current.unmount());
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx
index 7a03624101..bf45b8c3cb 100644
--- a/packages/react/src/popover/root/PopoverRoot.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.tsx
@@ -11,7 +11,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 PopoverRoot = function PopoverRoot(props: PopoverRoot.Props) {
const {
defaultOpen = false,
onOpenChange,
@@ -19,6 +19,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
openOnHover = false,
delay,
closeDelay = 0,
+ action,
} = props;
const delayWithDefault = delay ?? OPEN_DELAY;
@@ -31,6 +32,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
openOnHover,
delay: delayWithDefault,
closeDelay,
+ action,
});
const contextValue: PopoverRootContext = React.useMemo(
@@ -51,7 +53,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
namespace PopoverRoot {
export interface State {}
- export interface Props extends Omit {
+ export interface Props extends usePopoverRoot.Parameters {
children?: React.ReactNode;
}
}
@@ -61,6 +63,14 @@ PopoverRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/popover/root/usePopoverRoot.ts b/packages/react/src/popover/root/usePopoverRoot.ts
index a7a0950ba8..ba857cdad8 100644
--- a/packages/react/src/popover/root/usePopoverRoot.ts
+++ b/packages/react/src/popover/root/usePopoverRoot.ts
@@ -76,15 +76,20 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo
},
);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ setOpenReason(null);
+ });
+
useAfterExitAnimation({
+ enabled: !params.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- setOpenReason(null);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
React.useEffect(() => {
return () => {
clearTimeout(clickEnabledTimeoutRef.current);
@@ -230,6 +235,10 @@ export namespace usePopoverRoot {
* @default 0
*/
closeDelay?: number;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
index 70aebc605f..38c4a17f34 100644
--- a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
+++ b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
@@ -495,4 +495,44 @@ describe('', () => {
expect(screen.queryByText('Content')).to.equal(null);
});
});
+
+ describe('prop: action', () => {
+ it('unmounts the preview card when the `unmount` method is called', async () => {
+ const actionRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
+
+ const { user } = await render(
+
+ Open
+
+
+ Content
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole('link');
+ await user.hover(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('positioner')).not.to.equal(null);
+ });
+
+ await user.unhover(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('positioner')).not.to.equal(null);
+ });
+
+ act(() => actionRef.current.unmount());
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('positioner')).to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
index 347e735f16..5eef9d24fa 100644
--- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx
+++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
@@ -11,8 +11,8 @@ import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants';
*
* Documentation: [Base UI Preview Card](https://base-ui.com/react/components/preview-card)
*/
-const PreviewCardRoot: React.FC = function PreviewCardRoot(props) {
- const { delay, closeDelay } = props;
+const PreviewCardRoot = function PreviewCardRoot(props: PreviewCardRoot.Props) {
+ const { delay, closeDelay, action } = props;
const delayWithDefault = delay ?? OPEN_DELAY;
const closeDelayWithDefault = closeDelay ?? CLOSE_DELAY;
@@ -20,6 +20,7 @@ const PreviewCardRoot: React.FC = function PreviewCardRoo
const previewCardRoot = usePreviewCardRoot({
delay,
closeDelay,
+ action,
open: props.open,
onOpenChange: props.onOpenChange,
defaultOpen: props.defaultOpen,
@@ -54,6 +55,14 @@ PreviewCardRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/preview-card/root/usePreviewCardRoot.ts b/packages/react/src/preview-card/root/usePreviewCardRoot.ts
index a25be98968..1ff26e67fb 100644
--- a/packages/react/src/preview-card/root/usePreviewCardRoot.ts
+++ b/packages/react/src/preview-card/root/usePreviewCardRoot.ts
@@ -59,14 +59,19 @@ export function usePreviewCardRoot(
},
);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ });
+
useAfterExitAnimation({
+ enabled: !params.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
const context = useFloatingRootContext({
elements: { reference: triggerElement, floating: positionerElement },
open,
@@ -172,6 +177,10 @@ export namespace usePreviewCardRoot {
* @default 300
*/
closeDelay?: number;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx
index 98cf1c40b3..0064ff5096 100644
--- a/packages/react/src/select/root/SelectRoot.test.tsx
+++ b/packages/react/src/select/root/SelectRoot.test.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Select } from '@base-ui-components/react/select';
-import { fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
+import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
import { createRenderer, isJSDOM } from '#test-utils';
import { expect } from 'chai';
import { spy } from 'sinon';
@@ -430,4 +430,46 @@ describe('', () => {
expect(positioner.previousElementSibling).to.equal(null);
});
});
+
+ describe('prop: action', () => {
+ it('unmounts the select when the `unmount` method is called', async () => {
+ const actionRef = {
+ current: {
+ unmount: spy(),
+ },
+ };
+
+ const { user } = await render(
+
+ Open
+
+
+
+ 1
+
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox')).not.to.equal(null);
+ });
+
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox')).not.to.equal(null);
+ });
+
+ act(() => actionRef.current.unmount());
+
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox')).to.equal(null);
+ });
+ });
+ });
});
diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx
index 2f48d6498d..3643062399 100644
--- a/packages/react/src/select/root/SelectRoot.tsx
+++ b/packages/react/src/select/root/SelectRoot.tsx
@@ -29,6 +29,7 @@ const SelectRoot: SelectRoot = function SelectRoot(
readOnly = false,
required = false,
modal = true,
+ action,
} = props;
const selectRoot = useSelectRoot({
@@ -44,6 +45,7 @@ const SelectRoot: SelectRoot = function SelectRoot(
readOnly,
required,
modal,
+ action,
});
const { setDirty, validityData } = useFieldRootContext();
@@ -113,6 +115,14 @@ SelectRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* Determines if the selected item inside the popup should align to the trigger element.
* @default true
@@ -178,8 +188,6 @@ SelectRoot.propTypes /* remove-proptypes */ = {
value: PropTypes.any,
} as any;
-export { SelectRoot };
-
namespace SelectRoot {
export interface Props extends useSelectRoot.Parameters {
children?: React.ReactNode;
@@ -192,3 +200,5 @@ interface SelectRoot {
(props: SelectRoot.Props): React.JSX.Element;
propTypes?: any;
}
+
+export { SelectRoot };
diff --git a/packages/react/src/select/root/useSelectRoot.ts b/packages/react/src/select/root/useSelectRoot.ts
index cdada0d008..0dd6a6e386 100644
--- a/packages/react/src/select/root/useSelectRoot.ts
+++ b/packages/react/src/select/root/useSelectRoot.ts
@@ -124,15 +124,20 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect
}
});
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ setActiveIndex(null);
+ });
+
useAfterExitAnimation({
+ enabled: !params.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- setActiveIndex(null);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
const setValue = useEventCallback((nextValue: any, event?: Event) => {
params.onValueChange?.(nextValue, event);
setValueUnwrapped(nextValue);
@@ -405,6 +410,10 @@ export namespace useSelectRoot {
* @default true
*/
modal?: boolean;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx
index dc48a0fae3..d99c5a6d6b 100644
--- a/packages/react/src/tooltip/root/TooltipRoot.tsx
+++ b/packages/react/src/tooltip/root/TooltipRoot.tsx
@@ -11,7 +11,7 @@ import { OPEN_DELAY } from '../utils/constants';
*
* Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip)
*/
-const TooltipRoot: React.FC = function TooltipRoot(props) {
+const TooltipRoot = function TooltipRoot(props: TooltipRoot.Props) {
const {
defaultOpen = false,
onOpenChange,
@@ -20,6 +20,7 @@ const TooltipRoot: React.FC = function TooltipRoot(props) {
closeDelay,
hoverable = true,
trackCursorAxis = 'none',
+ action,
} = props;
const delayWithDefault = delay ?? OPEN_DELAY;
@@ -34,6 +35,7 @@ const TooltipRoot: React.FC = function TooltipRoot(props) {
trackCursorAxis,
delay,
closeDelay,
+ action,
});
const contextValue: TooltipRootContext = React.useMemo(
@@ -64,6 +66,14 @@ TooltipRoot.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A ref to imperative actions.
+ */
+ action: PropTypes.shape({
+ current: PropTypes.shape({
+ unmount: PropTypes.func.isRequired,
+ }).isRequired,
+ }),
/**
* @ignore
*/
diff --git a/packages/react/src/tooltip/root/useTooltipRoot.ts b/packages/react/src/tooltip/root/useTooltipRoot.ts
index c9fbd7dc4b..ce6b9a3ecb 100644
--- a/packages/react/src/tooltip/root/useTooltipRoot.ts
+++ b/packages/react/src/tooltip/root/useTooltipRoot.ts
@@ -62,14 +62,19 @@ export function useTooltipRoot(params: useTooltipRoot.Parameters): useTooltipRoo
const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
+ const handleUnmount = useEventCallback(() => {
+ setMounted(false);
+ });
+
useAfterExitAnimation({
+ enabled: !params.action,
open,
animatedElementRef: popupRef,
- onFinished() {
- setMounted(false);
- },
+ onFinished: handleUnmount,
});
+ React.useImperativeHandle(params.action, () => ({ unmount: handleUnmount }), [handleUnmount]);
+
const context = useFloatingRootContext({
elements: { reference: triggerElement, floating: positionerElement },
open,
@@ -203,6 +208,10 @@ export namespace useTooltipRoot {
* @default 0
*/
closeDelay?: number;
+ /**
+ * A ref to imperative actions.
+ */
+ action?: React.RefObject<{ unmount: () => void }>;
}
export interface ReturnValue {
diff --git a/packages/react/src/utils/mergeReactProps.test.ts b/packages/react/src/utils/mergeReactProps.test.ts
index fa7166711b..cba0e6c098 100644
--- a/packages/react/src/utils/mergeReactProps.test.ts
+++ b/packages/react/src/utils/mergeReactProps.test.ts
@@ -15,9 +15,9 @@ describe('mergeReactProps', () => {
};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);
- mergedProps.onClick?.({} as any);
- mergedProps.onKeyDown?.({} as any);
- mergedProps.onPaste?.({} as any);
+ mergedProps.onClick?.({ nativeEvent: {} } as any);
+ mergedProps.onKeyDown?.({ nativeEvent: {} } as any);
+ mergedProps.onPaste?.({ nativeEvent: {} } as any);
expect(theirProps.onClick.calledBefore(ourProps.onClick)).to.equal(true);
expect(theirProps.onClick.callCount).to.equal(1);
@@ -47,7 +47,7 @@ describe('mergeReactProps', () => {
},
);
- mergedProps.onClick?.({} as any);
+ mergedProps.onClick?.({ nativeEvent: {} } as any);
expect(log).to.deep.equal(['1', '2', '3']);
});
@@ -106,7 +106,7 @@ describe('mergeReactProps', () => {
},
);
- mergedProps.onClick?.({} as any);
+ mergedProps.onClick?.({ nativeEvent: {} } as any);
expect(ran).to.equal(true);
});
@@ -132,7 +132,7 @@ describe('mergeReactProps', () => {
},
);
- mergedProps.onClick?.({} as any);
+ mergedProps.onClick?.({ nativeEvent: {} } as any);
expect(ran).to.equal(false);
});
@@ -159,7 +159,7 @@ describe('mergeReactProps', () => {
},
);
- mergedProps.onClick?.({} as any);
+ mergedProps.onClick?.({ nativeEvent: {} } as any);
expect(log).to.deep.equal(['0', '1']);
});
diff --git a/packages/react/src/utils/mergeReactProps.ts b/packages/react/src/utils/mergeReactProps.ts
index 62539d511f..758e8db112 100644
--- a/packages/react/src/utils/mergeReactProps.ts
+++ b/packages/react/src/utils/mergeReactProps.ts
@@ -49,9 +49,12 @@ function merge(
const baseUIEvent = event as BaseUIEvent;
- baseUIEvent.preventBaseUIHandler = () => {
- isPrevented = true;
- };
+ // The event is a real React event, not e.g. a `motion` event
+ if (baseUIEvent.nativeEvent) {
+ baseUIEvent.preventBaseUIHandler = () => {
+ isPrevented = true;
+ };
+ }
const result = theirHandler(baseUIEvent);
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 5a55980646..420ed8b84c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,15 +540,15 @@ importers:
chai:
specifier: ^4.5.0
version: 4.5.0
- framer-motion:
- specifier: ^11.15.0
- version: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
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(react@19.0.0))(react@19.0.0)
prettier:
specifier: ^3.4.2
version: 3.4.2
@@ -6765,6 +6765,20 @@ packages:
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'}
@@ -16484,6 +16498,15 @@ snapshots:
motion-utils@11.14.3: {}
+ motion@11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ framer-motion: 11.15.0(@emotion/is-prop-valid@1.3.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@emotion/is-prop-valid': 1.3.0
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
mri@1.2.0: {}
mrmime@2.0.0: {}