diff --git a/packages/@react-aria/utils/src/animation.ts b/packages/@react-aria/utils/src/animation.ts index b48a0db97af..d01e83cdf58 100644 --- a/packages/@react-aria/utils/src/animation.ts +++ b/packages/@react-aria/utils/src/animation.ts @@ -11,39 +11,60 @@ */ import {flushSync} from 'react-dom'; -import {RefObject, useCallback, useRef, useState} from 'react'; +import {RefObject, useCallback, useState} from 'react'; import {useLayoutEffect} from './useLayoutEffect'; export function useEnterAnimation(ref: RefObject, isReady: boolean = true) { let [isEntering, setEntering] = useState(true); - useAnimation(ref, isEntering && isReady, useCallback(() => setEntering(false), [])); - return isEntering && isReady; + let isAnimationReady = isEntering && isReady; + + // There are two cases for entry animations: + // 1. CSS @keyframes. The `animation` property is set during the isEntering state, and it is removed after the animation finishes. + // 2. CSS transitions. The initial styles are applied during the isEntering state, and removed immediately, causing the transition to occur. + // + // In the second case, cancel any transitions that were triggered prior to the isEntering = false state (when the transition is supposed to start). + // This can happen when isReady starts as false (e.g. popovers prior to placement calculation). + useLayoutEffect(() => { + if (isAnimationReady && ref.current && 'getAnimations' in ref.current) { + for (let animation of ref.current.getAnimations()) { + if (animation instanceof CSSTransition) { + animation.cancel(); + } + } + } + }, [ref, isAnimationReady]); + + useAnimation(ref, isAnimationReady, useCallback(() => setEntering(false), [])); + return isAnimationReady; } export function useExitAnimation(ref: RefObject, isOpen: boolean) { - // State to trigger a re-render after animation is complete, which causes the element to be removed from the DOM. - // Ref to track the state we're in, so we don't immediately reset isExiting to true after the animation. - let [isExiting, setExiting] = useState(false); - let [exitState, setExitState] = useState('idle'); + let [exitState, setExitState] = useState<'closed' | 'open' | 'exiting'>(isOpen ? 'open' : 'closed'); - // If isOpen becomes false, set isExiting to true. - if (!isOpen && ref.current && exitState === 'idle') { - isExiting = true; - setExiting(true); - setExitState('exiting'); - } - - // If we exited, and the element has been removed, reset exit state to idle. - if (!ref.current && exitState === 'exited') { - setExitState('idle'); + switch (exitState) { + case 'open': + // If isOpen becomes false, set the state to exiting. + if (!isOpen) { + setExitState('exiting'); + } + break; + case 'closed': + case 'exiting': + // If we are exiting and isOpen becomes true, the animation was interrupted. + // Reset the state to open. + if (isOpen) { + setExitState('open'); + } + break; } + let isExiting = exitState === 'exiting'; useAnimation( ref, isExiting, useCallback(() => { - setExitState('exited'); - setExiting(false); + // Set the state to closed, which will cause the element to be unmounted. + setExitState(state => state === 'exiting' ? 'closed' : state); }, []) ); @@ -51,35 +72,32 @@ export function useExitAnimation(ref: RefObject, isOpen: boo } function useAnimation(ref: RefObject, isActive: boolean, onEnd: () => void) { - let prevAnimation = useRef(null); - if (isActive && ref.current) { - // This is ok because we only read it in the layout effect below, immediately after the commit phase. - // We could move this to another effect that runs every render, but this would be unnecessarily slow. - // We only need the computed style right before the animation becomes active. - // eslint-disable-next-line rulesdir/pure-render - prevAnimation.current = window.getComputedStyle(ref.current).animation; - } - useLayoutEffect(() => { if (isActive && ref.current) { - // Make sure there's actually an animation, and it wasn't there before we triggered the update. - let computedStyle = window.getComputedStyle(ref.current); - if (computedStyle.animationName && computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) { - let onAnimationEnd = (e: AnimationEvent) => { - if (e.target === ref.current) { - element.removeEventListener('animationend', onAnimationEnd); - flushSync(() => {onEnd();}); - } - }; - - let element = ref.current; - element.addEventListener('animationend', onAnimationEnd); - return () => { - element.removeEventListener('animationend', onAnimationEnd); - }; - } else { + if (!('getAnimations' in ref.current)) { + // JSDOM onEnd(); + return; } + + let animations = ref.current.getAnimations(); + if (animations.length === 0) { + onEnd(); + return; + } + + let canceled = false; + Promise.all(animations.map(a => a.finished)).then(() => { + if (!canceled) { + flushSync(() => { + onEnd(); + }); + } + }).catch(() => {}); + + return () => { + canceled = true; + }; } }, [ref, isActive, onEnd]); } diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index 441ed0cc027..8c4fdefb665 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -19,24 +19,13 @@ import {DOMRef, DOMRefValue, Key} from '@react-types/shared'; import {FocusScope, useKeyboard} from 'react-aria'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {keyframes} from '../style/style-macro' with {type: 'macro'}; import {style} from '../style' with {type: 'macro'}; import {useControlledState} from '@react-stately/utils'; import {useDOMRef} from '@react-spectrum/utils'; -import {useExitAnimation, useResizeObserver} from '@react-aria/utils'; +import {useEnterAnimation, useExitAnimation, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -const slideIn = keyframes(` - from { transform: translateY(100%); opacity: 0 } - to { transform: translateY(0px); opacity: 1 } -`); - -const slideOut = keyframes(` - from { transform: translateY(0px); opacity: 1 } - to { transform: translateY(100%); opacity: 0 } -`); - const actionBarStyles = style({ borderRadius: 'lg', '--s2-container-bg': { @@ -77,11 +66,16 @@ const actionBarStyles = style({ }, marginX: 'auto', maxWidth: 960, - animation: { - isInContainer: slideIn, - isExiting: slideOut + transition: 'default', + transitionDuration: 200, + translateY: { + isEntering: 'full', + isExiting: 'full' }, - animationDuration: 200 + opacity: { + isEntering: 0, + isExiting: 0 + } }); export interface ActionBarProps extends SlotProps { @@ -158,12 +152,15 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps } }, [stringFormatter, scrollRef]); + let objectRef = useObjectRef(ref); + let isEntering = useEnterAnimation(objectRef, !!scrollRef); + return (
({ ...colorScheme(), justifyContent: 'center', @@ -82,42 +69,38 @@ const tooltip = style {section.title} - {section.title === 'Components' && } {contents} diff --git a/packages/react-aria-components/docs/Modal.mdx b/packages/react-aria-components/docs/Modal.mdx index b7a6be26df5..bb558a21fc6 100644 --- a/packages/react-aria-components/docs/Modal.mdx +++ b/packages/react-aria-components/docs/Modal.mdx @@ -346,7 +346,7 @@ A custom `className` can also be specified on any component. This overrides the ``` -In addition, modals support entry and exit animations, which are exposed as states using DOM attributes that you can target with CSS selectors. `Modal` and `ModalOverlay` will automatically wait for any exit animations to complete before removing the element from the DOM. +In addition, modals support entry and exit animations, which are exposed as states using DOM attributes that you can target with CSS selectors. `Modal` and `ModalOverlay` will automatically wait for any exit animations to complete before removing the element from the DOM. See the [animation guide](styling.html#animation) for more details. ```css render=false .react-aria-Modal[data-entering] { diff --git a/packages/react-aria-components/docs/Popover.mdx b/packages/react-aria-components/docs/Popover.mdx index a8ca7bcccde..01d51c5988d 100644 --- a/packages/react-aria-components/docs/Popover.mdx +++ b/packages/react-aria-components/docs/Popover.mdx @@ -89,6 +89,7 @@ import {DialogTrigger, Popover, Dialog, Button, OverlayArrow, Heading, Switch} f color: var(--text-color); outline: none; max-width: 250px; + transition: transform 200ms, opacity 200ms; .react-aria-OverlayArrow svg { display: block; @@ -97,6 +98,12 @@ import {DialogTrigger, Popover, Dialog, Button, OverlayArrow, Heading, Switch} f stroke-width: 1px; } + &[data-entering], + &[data-exiting] { + transform: var(--origin); + opacity: 0; + } + &[data-placement=top] { --origin: translateY(8px); @@ -140,26 +147,6 @@ import {DialogTrigger, Popover, Dialog, Button, OverlayArrow, Heading, Switch} f transform: rotate(-90deg); } } - - &[data-entering] { - animation: popover-slide 200ms; - } - - &[data-exiting] { - animation: popover-slide 200ms reverse ease-in; - } -} - -@keyframes popover-slide { - from { - transform: var(--origin); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } } ``` @@ -421,19 +408,16 @@ The `className` and `style` props also accept functions which receive states for ``` -Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. +Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. See the [animation guide](styling.html#animation) for more details. ```css render=false -.react-aria-Popover[data-entering] { - animation: slide 300ms; -} - -.react-aria-Popover[data-exiting] { - animation: slide 300ms reverse; -} +.react-aria-Popover { + transition: opacity 300ms; -@keyframes slide { - /* ... */ + &[data-entering], + &[data-exiting] { + opacity: 0; + } } ``` diff --git a/packages/react-aria-components/docs/Tooltip.mdx b/packages/react-aria-components/docs/Tooltip.mdx index dc7c8900279..3fabdc7f046 100644 --- a/packages/react-aria-components/docs/Tooltip.mdx +++ b/packages/react-aria-components/docs/Tooltip.mdx @@ -75,6 +75,13 @@ import {TooltipTrigger, Tooltip, OverlayArrow, Button} from 'react-aria-componen max-width: 150px; /* fixes FF gap */ transform: translate3d(0, 0, 0); + transition: transform 200ms, opacity 200ms; + + &[data-entering], + &[data-exiting] { + transform: var(--origin); + opacity: 0; + } &[data-placement=top] { margin-bottom: 8px; @@ -109,26 +116,6 @@ import {TooltipTrigger, Tooltip, OverlayArrow, Button} from 'react-aria-componen display: block; fill: var(--highlight-background); } - - &[data-entering] { - animation: slide 200ms; - } - - &[data-exiting] { - animation: slide 200ms reverse ease-in; - } -} - -@keyframes slide { - from { - transform: var(--origin); - opacity: 0; - } - - to { - transform: translateY(0); - opacity: 1; - } } ``` @@ -376,19 +363,16 @@ The `className` and `style` props also accept functions which receive states for ``` -Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. +Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. See the [animation guide](styling.html#animation) for more details. ```css render=false -.react-aria-Tooltip[data-entering] { - animation: slide 300ms; -} - -.react-aria-Tooltip[data-exiting] { - animation: slide 300ms reverse; -} +.react-aria-Tooltip { + transition: opacity 300ms; -@keyframes slide { - /* ... */ + &[data-entering], + &[data-exiting] { + opacity: 0; + } } ``` diff --git a/packages/react-aria-components/docs/styling.mdx b/packages/react-aria-components/docs/styling.mdx index 74d44f83302..cb7f3e92c8d 100644 --- a/packages/react-aria-components/docs/styling.mdx +++ b/packages/react-aria-components/docs/styling.mdx @@ -240,11 +240,51 @@ With this configured, all states for React Aria Components can be accessed with ## Animation -React Aria Components support [CSS keyframe animations](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes), and work with JavaScript animation libraries like [Framer Motion](https://www.framer.com/motion/). +React Aria Components supports both [CSS transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions) and [keyframe animations](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes), and works with JavaScript animation libraries like [Framer Motion](https://www.framer.com/motion/). + +### CSS transitions + +Overlay components such as [Popover](Popover.html) and [Modal](Modal.html) support entry and exit animations via the `[data-entering]` and `[data-exiting]` states, or via the corresponding render prop functions. + +* `[data-entering]` represents the starting state of the entry animation. The component will transition from the entering state to the default state when it opens. +* `[data-exiting]` represents the ending state of the exit animation. The component will transition from the default state to the exiting state and wait for any animations to complete before being removed from the DOM. + +```css render=false +.react-aria-Popover { + transition: opacity 300ms; + + &[data-entering], + &[data-exiting] { + opacity: 0; + } +} +``` + +Note that the `[data-entering]` state is only applied for one frame when using CSS transitions. The transition itself should be assigned in the default state. To create a different exit animation, assign the transition in the `[data-exiting]` state. + +```css render=false +.react-aria-Popover { + /* entry transition */ + transition: transform 300ms, opacity 300ms; + + /* starting state of the entry transition */ + &[data-entering] { + opacity: 0; + transform: scale(0.8); + } + + &[data-exiting] { + /* exit transition */ + transition: opacity 150ms; + /* ending state of the exit transition */ + opacity: 0; + } +} +``` ### CSS animations -Overlay components such as [Popover](Popover.html) and [Modal](Modal.html) support entry and exit animations via the `[data-entering]` and `[data-exiting]` states, or via the corresponding render prop functions. You can use these states to apply CSS keyframe animations. These components will automatically wait for any exit animations to complete before they are removed from the DOM. +For more complex animations, you can also apply CSS keyframe animations using the same `[data-entering]` and `[data-exiting]` states. ```css render=false .react-aria-Popover[data-entering] { @@ -268,6 +308,8 @@ Overlay components such as [Popover](Popover.html) and [Modal](Modal.html) suppo } ``` +Note that unlike CSS transitions, keyframe animations are not interruptible. If the user opens and closes an overlay quickly, the animation may appear to jump to the ending state before the next animation starts. + ### Tailwind CSS If you are using Tailwind CSS, we recommend using the [tailwindcss-animate](https://github.com/jamiebuilds/tailwindcss-animate) plugin. This includes utilities for building common animations such as fading, sliding, and zooming.