-
Notifications
You must be signed in to change notification settings - Fork 2.9k
RFC: Component CSS Transitions/Animations on mount/unmount #27328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
marcosmoura
merged 24 commits into
microsoft:master
from
marcosmoura:rfc/mount-unmount-transitions
Aug 23, 2023
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
6307555
add initial proposal
marcosmoura 23ea8d5
add note about rerender
marcosmoura 8d0e21c
add reference to initial implementation
marcosmoura e76de37
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura 48748a6
add griffel mention as a "pro"
marcosmoura 0f8e461
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura b829808
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura 9666773
docs: recreate RFC based on a more refined approach
marcosmoura cd22d10
docs: add example of transition override
marcosmoura 8ca8c19
fix: add mention about animations
marcosmoura 97216b6
fix: update doc to reflect latest changes to implementation
marcosmoura d809b7b
docs: add example of CSS Animations
marcosmoura 9b17a25
fix: improve descriptions
marcosmoura b1ee80c
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura ab4474a
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura 389d72d
feat: simplify API by removing redundant prop
marcosmoura 5f1b41b
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura c700cdf
docs: upgrade description for new spec
marcosmoura ecdcdd2
Merge branch 'master' into rfc/mount-unmount-transitions
marcosmoura f93f841
docs: useMotion now has a much more simplified API to allow overrides
marcosmoura 1a1f7ad
Update rfcs/react-components/convergence/component-transitions-on-mou…
marcosmoura 6c418a1
Update rfcs/react-components/convergence/component-transitions-on-mou…
marcosmoura 9e32987
Update component-transitions-on-mount-or-unmount.md
marcosmoura 17f0406
fix: lint issues with code examples
marcosmoura File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
364 changes: 364 additions & 0 deletions
364
rfcs/react-components/convergence/component-transitions-on-mount-or-unmount.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,364 @@ | ||
| # RFC: Component CSS Animations/Transitions on mount/unmount | ||
|
|
||
| --- | ||
|
|
||
| @marcosmoura | ||
|
|
||
| ## Summary | ||
|
|
||
| This RFC outlines the implementation details for effectively tracking CSS animations/transitions within Fluent UI components, with a particular focus on the mount/unmount state. | ||
|
|
||
| ## Background | ||
|
|
||
| Currently, there is a limitation in incorporating motion effects to components during their mounting or unmounting. This restricts the ability to apply CSS transitions/animations for components featuring an open/close behavior, such as Drawer, Dialog, and various others. | ||
|
|
||
| ## Problem statement | ||
|
|
||
| In order to display that a content is showing or hiding from screen, CSS transitions or animations should be applied. React lacks built-in support for applying CSS animations or transitions specifically during the mount and unmount phases of a component's lifecycle and we need to develop a solution that can determine the on-screen status of a component. Even though there are existing packages available that could solve this issue, they either would increase bundle size (e.g., [Framer Motion](https://www.framer.com/motion/) and [React Spring](https://www.react-spring.dev/)) or lacking flexibility and better integration with how we create styles ([React Transition Group](https://reactcommunity.org/react-transition-group/)). | ||
|
|
||
| ## Detailed Design or Proposal | ||
|
|
||
| ### A `useMotion` hook based solution | ||
|
|
||
| To determine the motion state of a component, a hook could be created as part of the @fluentui/react-utilities package. A preliminary implementation of this hook can be found here: [useMotion](https://github.com/marcosmoura/fluentui/blob/feat/use-motion-presence-hook/packages/react-components/react-motion-preview/src/hooks/useMotion.ts). | ||
|
|
||
| #### What is it? | ||
|
|
||
| A tracker hook, that monitors the state of animations and transitions for a particular element. This hook does not directly create animations but instead synchronizes with CSS properties to determine the rendering status, visibility, entering, leaving, and ongoing animation of a component. If any CSS changes or properties are overridden, this hook will automatically adjust and stay synchronized. | ||
|
|
||
| #### API | ||
|
|
||
| The hook accepts a `MotionShorthand` param and a `MotionOptions`: | ||
|
|
||
| ```tsx | ||
| // Types | ||
| export type MotionType = 'unmounted' | 'entering' | 'entered' | 'idle' | 'exiting' | 'exited'; | ||
|
|
||
| export type MotionState<Element extends HTMLElement = HTMLElement> = { | ||
| /** | ||
| * Ref to the element. | ||
| */ | ||
| ref: React.Ref<Element>; | ||
|
|
||
| /** | ||
| * Current state of the element. | ||
| * | ||
| * - `unmounted` - The element is not yet rendered or can be safely removed from the DOM. | ||
| * - `entering` - The element is performing enter animation. | ||
| * - `entered` - The element has finished enter animation. | ||
| * - `idle` - The element is currently not animating, but rendered on screen. | ||
| * - `exiting` - The element is performing exit animation. | ||
| * - `exited` - The element has finished exit animation. | ||
| */ | ||
| type: MotionType; | ||
|
|
||
| /** | ||
| * Indicates whether the component is currently rendered and visible. | ||
| * Useful to apply CSS transitions only when the element is active. | ||
| */ | ||
| active: boolean; | ||
|
|
||
| /** | ||
| * Indicates whether the component can be rendered. | ||
| * This can be used to avoid rendering the component when it is not visible anymore. | ||
| */ | ||
| canRender: boolean; | ||
| }; | ||
|
|
||
| export type MotionShorthandValue = boolean; | ||
|
|
||
| export type MotionShorthand<Element extends HTMLElement = HTMLElement> = MotionShorthandValue | MotionState<Element>; | ||
|
|
||
| type MotionOptions = { | ||
| /** | ||
| * Whether to animate the element on first mount. Useful when the animation/transition | ||
| * should be played if the element is already rendered on screen. | ||
| * | ||
| * @default false | ||
| */ | ||
| animateOnFirstMount: false; | ||
| }; | ||
|
|
||
| // Usage | ||
| const [open, setOpen] = React.useState(false); | ||
| const options = { | ||
| animateOnFirstMount: false, | ||
| }; | ||
| const { ref, type, active, canRender } = useMotion(open, options); | ||
| ``` | ||
|
|
||
| The hook always returns a `MotionState`. The received `MotionShorthand` parameter can be either a `boolean` or a `MotionState`. This flexibility is extremely useful for cases when an override on another component is needed, and with that a double calculation is avoided. See the **Usage** section on this document. | ||
|
|
||
| #### Usage | ||
|
|
||
| ##### State: | ||
|
|
||
| ```ts | ||
| import * as React from 'react'; | ||
| import { getNativeElementProps, slot } from '@fluentui/react-utilities'; | ||
| import { useMotion } from '@fluentui/react-motion-preview'; | ||
|
|
||
| import type { SampleProps, SampleState } from './Sample.types'; | ||
|
|
||
| export const useSample_unstable = ({ open = false }: SampleProps, ref: React.Ref<HTMLElement>): SampleState => { | ||
| const motion = useMotion(open); | ||
|
|
||
| return { | ||
| components: { | ||
| root: 'div', | ||
| }, | ||
|
|
||
| root: slot.always( | ||
| getNativeElementProps('div', { | ||
| ref: useMergedRefs(ref, motion.ref), | ||
| ...props, | ||
| }), | ||
| { elementType: 'div' }, | ||
| ), | ||
|
|
||
| motion, | ||
| }; | ||
| }; | ||
| ``` | ||
|
|
||
| ##### Renderization: | ||
|
|
||
| ```tsx | ||
| import * as React from 'react'; | ||
| import { assertSlots } from '@fluentui/react-utilities'; | ||
| import type { SampleState, SampleSlots } from './Sample.types'; | ||
|
|
||
| /** | ||
| * Render the final JSX of Sample | ||
| */ | ||
| export const renderSample_unstable = (state: SampleState) => { | ||
| if (state.motion.canRender) { | ||
| return null; | ||
| } | ||
|
|
||
| assertSlots<SampleSlots>(state); | ||
|
|
||
| return <state.root />; | ||
| }; | ||
| ``` | ||
|
|
||
| ##### Styles: | ||
|
|
||
| ```ts | ||
| import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; | ||
| import type { SampleSlots, SampleState } from './Sample.types'; | ||
| import type { SlotClassNames } from '@fluentui/react-utilities'; | ||
| import { tokens } from '@fluentui/react-theme'; | ||
|
|
||
| export const SampleClassNames: SlotClassNames<SampleSlots> = { | ||
| root: 'fui-Sample', | ||
| }; | ||
|
|
||
| /** | ||
| * Styles for the root slot | ||
| */ | ||
| const useStyles = makeStyles({ | ||
| root: { | ||
| opacity: 0, | ||
| transitionTimingFunction: 'ease-out', | ||
marcosmoura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| transitionProperty: 'opacity', | ||
| willChange: 'opacity', | ||
| }, | ||
|
|
||
| entering: { | ||
| transitionDuration: '200ms', | ||
| }, | ||
|
|
||
| exiting: { | ||
| transitionDuration: '250ms', | ||
| }, | ||
|
|
||
marcosmoura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| visible: { | ||
| opacity: 1, | ||
| }, | ||
| }); | ||
|
|
||
| export const useSampleStyles_unstable = (state: SampleState): SampleState => { | ||
| const styles = useStyles(); | ||
|
|
||
| state.root.className = mergeClasses( | ||
| SampleClassNames.root, | ||
| state.motion.active && styles.visible, | ||
| state.motion.type === 'entering' && styles.entering, | ||
| state.motion.type === 'exiting' && styles.exiting, | ||
| styles.root, | ||
| ); | ||
|
|
||
| return state; | ||
| }; | ||
| ``` | ||
|
|
||
| ##### Styles, in case of CSS animations: | ||
|
|
||
| ```ts | ||
| import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; | ||
| import type { SampleSlots, SampleState } from './Sample.types'; | ||
| import type { SlotClassNames } from '@fluentui/react-utilities'; | ||
| import { tokens } from '@fluentui/react-theme'; | ||
|
|
||
| export const SampleClassNames: SlotClassNames<SampleSlots> = { | ||
| root: 'fui-Sample', | ||
| }; | ||
|
|
||
| /** | ||
| * Styles for the root slot | ||
| */ | ||
| const visibleKeyframe = { | ||
| ...shorthands.borderRadius(0), | ||
| opacity: 1, | ||
| transform: 'translate3D(0, 0, 0) scale(1)', | ||
| }; | ||
|
|
||
| const hiddenKeyframe = { | ||
| ...shorthands.borderRadius('36px'), | ||
| opacity: 0, | ||
| transform: 'translate3D(-100%, 0, 0) scale(0.9)', | ||
| }; | ||
|
|
||
| const useStyles = makeStyles({ | ||
| root: { | ||
| willChange: 'opacity, transform, border-radius', | ||
| }, | ||
|
|
||
| entering: { | ||
| animationDuration: '500ms', | ||
| animationTimingFunction: tokens.curveDecelerateMid, | ||
| animationName: { | ||
| '0%': hiddenKeyframe, | ||
| '100%': visibleKeyframe, | ||
| }, | ||
| }, | ||
|
|
||
| exiting: { | ||
| animationDuration: '750ms', | ||
| animationTimingFunction: tokens.curveAccelerateMin, | ||
| animationName: { | ||
| '0%': visibleKeyframe, | ||
| '100%': hiddenKeyframe, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| export const useSampleStyles_unstable = (state: SampleState): SampleState => { | ||
| const styles = useStyles(); | ||
|
|
||
| state.root.className = mergeClasses( | ||
| SampleClassNames.root, | ||
| state.motion.type === 'entering' && styles.entering, | ||
| state.motion.type === 'exiting' && styles.exiting, | ||
| styles.root, | ||
| ); | ||
|
|
||
| return state; | ||
| }; | ||
| ``` | ||
|
|
||
| #### Overriding the transition/animation | ||
|
|
||
| To override motion on another component, only a CSS change is needed: | ||
|
|
||
| ```tsx | ||
| import * as React from 'react'; | ||
| import { Drawer } from '@fluentui/react-drawer'; | ||
| import { makeStyles } from '@fluentui/react-components'; | ||
|
|
||
| const useStyles = makeStyles({ | ||
| customDuration: { | ||
| transitionDuration: '500ms', | ||
| }, | ||
| }); | ||
|
|
||
| export const CustomDuration = () => { | ||
| const styles = useStyles(); | ||
|
|
||
| return <Drawer className={styles.customDuration} />; | ||
| }; | ||
| ``` | ||
|
|
||
| In order to enable overrides, Fluent components can also accept a prop that can be used to receive `useMotion` values coming from another component. This is extremely useful for cases when a completely custom animation/transition is needed. In this case, we enable full control to override animations/transitions while improving performance by not computing the values twice. In the following example, the `open` prop for the Drawer can receive either a `boolean` or a `MotionState`: | ||
|
|
||
| ##### Application side: | ||
|
|
||
| ```tsx | ||
| import * as React from 'react'; | ||
| import { makeStyles, mergeClasses, Drawer, Button } from '@fluentui/react-components'; | ||
| import { useMotion } from '@fluentui/react-motion-preview'; | ||
|
|
||
| const useStyles = makeStyles({ | ||
| drawer: { | ||
| opacity: 0, | ||
| transitionDuration: '3s', | ||
| transitionProperty: 'opacity', | ||
| }, | ||
|
|
||
| drawerVisible: { | ||
| opacity: 1, | ||
| }, | ||
| }); | ||
|
|
||
| export const CustomAnimation = () => { | ||
| const styles = useStyles(); | ||
|
|
||
| const [open, setOpen] = React.useState(false); | ||
| const motion = useMotion<HTMLDivElement>(open); | ||
|
|
||
| const onClick = () => setOpen(!open); | ||
|
|
||
| return ( | ||
| <div className={styles.root}> | ||
| <Button appearance="primary" onClick={onClick}> | ||
| Toggle | ||
| </Button> | ||
| <Drawer open={motion} className={mergeClasses(styles.drawer, motion.active && styles.drawerVisible)} />; | ||
| </div> | ||
| ); | ||
| }; | ||
| ``` | ||
|
|
||
| ##### Fluent Component: | ||
|
|
||
| ```tsx | ||
| export const useDrawer_unstable = ({ open }: DrawerInlineProps, ref: React.Ref<HTMLDivElement>): DrawerInlineState => { | ||
| // Call useMotion with given motion values | ||
| const motion = useMotion(open); | ||
|
|
||
| return { | ||
| components: { | ||
| root: 'div', | ||
| }, | ||
|
|
||
| root: slot.always( | ||
| getNativeElementProps('div', { | ||
| ref: useMergedRefs(ref, motion.ref), | ||
| ...props, | ||
| }), | ||
| { elementType: 'div' }, | ||
| ), | ||
|
|
||
| motion, | ||
| }; | ||
| }; | ||
| ``` | ||
|
|
||
| #### Background research | ||
|
|
||
| - Vue.js [Transition component](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks). The [implementation](https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/components/Transition.ts) CSS transitions and animations and provides classes to the components based on the detected transitions. | ||
| - Radix UI has an internal package called [Presence](https://www.npmjs.com/package/@radix-ui/react-presence), which addresses the same issue. To handle this, they offer a [`usePresence` hook and a Presence component](https://github.com/radix-ui/primitives/blob/main/packages/react/presence/src/Presence.tsx). The implementation relies on animations only so they only need to determine whether the element should be present or not on the screen. | ||
|
|
||
| #### Pros | ||
|
|
||
| - 👍 Hook based solution. | ||
| - 👍 Griffel styles can be used normally to create motion. | ||
| - 👍 Easy styling of components based on their state. | ||
| - 👍 Offers the flexibility to declare both transitions and animations. | ||
marcosmoura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| - 👍 Can be applied to multiple elements simultaneously. | ||
| - 👍 Users can easily override Animations/Transitions just by changing CSS. | ||
|
|
||
| #### Cons | ||
|
|
||
| - 👎 Lacks support for sequential animation playback, similar to React Transition Group. This can be implemented using a separate solution, while using the `useMotion` hook. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.