Skip to content

Commit

Permalink
[popups] actionsRef prop (#1236)
Browse files Browse the repository at this point in the history
Co-authored-by: Michał Dudak <[email protected]>
  • Loading branch information
atomiks and michaldudak authored Feb 18, 2025
1 parent 7db5bed commit 961fd3e
Show file tree
Hide file tree
Showing 34 changed files with 832 additions and 71 deletions.
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@
"@types/react-dom": "^19.0.2",
"@types/unist": "^3.0.3",
"chai": "^4.5.0",
"framer-motion": "^11.18.2",
"fs-extra": "^11.3.0",
"mdast-util-mdx-jsx": "^3.2.0",
"motion": "^11.15.0",
"prettier": "^3.4.2",
"rimraf": "^5.0.10",
"serve": "^14.2.4",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/alert-dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the dialog is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/dialog-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/generated/menu-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -27,7 +31,7 @@
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the menu is opened or closed."
"description": "Event handler called after any animations complete when the menu is closed."
},
"disabled": {
"type": "boolean",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/popover-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the popover is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/preview-card-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the preview card is opened or closed."
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/select-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"type": "(open, event) => void",
"description": "Event handler called when the select menu is opened or closed."
},
"actionsRef": {
"type": "{ current: { unmount: func } }",
"description": "A ref to imperative actions."
},
"alignItemToTrigger": {
"type": "boolean",
"default": "true",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/generated/tooltip-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
"onOpenChangeComplete": {
"type": "(open) => void",
"description": "Event handler called after any animations complete when the tooltip is opened or closed."
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
106 changes: 106 additions & 0 deletions docs/src/app/(private)/experiments/motion.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}

function AlwaysMounted() {
const [open, setOpen] = React.useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={false}
animate={{
scale: open ? 1 : 0,
opacity: open ? 1 : 0,
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}

function NoOpacity() {
const [open, setOpen] = React.useState(false);
const actionsRef = React.useRef<Popover.Root.Actions>({ unmount: () => {} });

return (
<Popover.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onAnimationComplete={() => {
if (!open) {
actionsRef.current.unmount();
}
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}

export default function Page() {
return (
<div>
<h2>Conditionally mounted</h2>
<ConditionallyMounted />
<h2>Always mounted</h2>
<AlwaysMounted />
<h2>No opacity</h2>
<NoOpacity />
</div>
);
}
2 changes: 1 addition & 1 deletion docs/src/app/(private)/experiments/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
127 changes: 125 additions & 2 deletions docs/src/app/(public)/(content)/react/handbook/animation/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,128 @@ 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" {12-18} "keepMounted"
function App() {
const [open, setOpen] = React.useState(false);

return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
```

### 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" {12-20}
function App() {
const [open, setOpen] = React.useState(false);

return (
<Select.Root open={open} onOpenChange={setOpen}>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup
render={
<motion.div
initial={false}
animate={{
opacity: open ? 1 : 0,
scale: open ? 1 : 0.8,
}}
/>
}
>
Popup
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}
```

### Manual unmounting

For full control, you can manually unmount the component when it's closed once animations have finished using an `actionsRef` passed to the `Root`:

```jsx title="manual-unmount.tsx" "actionsRef"
function App() {
const [open, setOpen] = React.useState(false);
const actionsRef = React.useRef({ unmount: () => {} });

return (
<Popover.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onAnimationComplete={() => {
if (!open) {
action.current.unmount();
}
}}
/>
}
>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
```
Loading

0 comments on commit 961fd3e

Please sign in to comment.