docs/react-v9/contributing/rfcs/react-components/convergence/motion-definition-n-apis.md
This RFC proposes the way to define motion animations and APIs to use them in Fluent UI React components (@fluentui/react-components).
There are three main use cases that this RFC is trying to solve:
This problem is interconnected with APIs.
CSS keyframes represent the most common method for defining motion animations in web development. Currently, there are two approaches for their utilization: via CSS @keyframes & via Web Animations API.
We need to decide which option we should go with:
Based on the motion definition, we need to establish APIs for using them in Fluent UI components and for consumers. There are three primary scenarios:
react-transition-group.react-transition-group.With CSS based or Web Animations API based motion definition, we need to define a set of APIs to satisfy all these scenarios.
Accordion, Dialog, Popover, etc.).Dialog component.The APIs should be intuitive, easy to use, and should not negatively affect performance. For example, we should avoid design solutions that increase component re-renders (microsoft/fluentui#29718).
While this RFC advocates for the Web Animations API as the preferred option, we have also explored the CSS option (check discarded option section) and are actively seeking feedback from the community.
CSS option requires us to apply CSS classes to elements to start animations. The reliable way to do it is to use state machines, for example:
const [visible, setVisible] = useState(false);
const [animationState] = useAnimationState(visible);
const className = mergeClasses(
animationState === 'entering' && classes.entering,
animationState === 'exiting' && classes.exiting,
);
This implies that components must be re-rendered to apply classes when the state changes. The typical scenario for "in" transition with CSS option is:
{ visible: false, animationState: 'idle' } => { visible: true, animationState: 'entering' }){ visible: true, animationState: 'entering' } => { visible: true, animationState: 'idle' })On the other hand, the Web Animations API doesn't need to be within the React lifecycle as animations have their own lifecycle & apply styles directly to elements. The typical scenario for "in" transition is:
{ visible: false } => { visible: true })Note: compared to CSS option that we don't need to re-render the component to apply classes
@fluentui/react-motions-preview (Web Animations API), react-transition-group (CSS implementation) & @fluentui/react-motion-preview (CSS implementation) packages were used@fluentui/react-motion-preview (CSS option) - ⚡️ 5 renders (6 due a bug)Note: currently
useMotion()hook does 5 renders (unmounted=>entering({active: false }) =>entering({active: true }) =>entered=>idle)
Note2: currently
useMotion()hook does 6 due a bug, see microsoft/fluentui#29719
react-transition-group (CSS option) - ⚡️ 3 rendersNote:
react-transition-groupdoes actually 3 renders (unmounted=>entering=>entered)
@fluentui/react-motions-preview (Web Animations API) - ⚡️ 1 renderThe Web Animations API is framework-agnostic, allowing motion definitions, such as design tokens for motion, to be re-implemented with other tools, including Web Components.
With the CSS option, we need to apply classes to elements to start animations and remove them to stop animations. This process requires JavaScript code with timers to switch states. However, it's important to note that animation durations are defined in CSS, and values should be synchronized with JavaScript, which can be accomplished using .computedStyleMap() or with .getComputedStyle() for older browsers.
Implementation in @fluentui/react-motion-preview
However, this is not necessary at all with the Web Animations API, as durations are defined in a single place.
As classes should be applied to elements, our API contract would require users to pass and merge them properly in their components.
function Fade() {
const classes = useStyles();
const state = useAnimationState();
return React.cloneElement(props.children, { className: mergeClasses(props.children.className, classes[state]) });
}
The problem comes from our inability to enforce this contract. For instance, we cannot ensure that the child element will apply classes from props. The typical scenario is as follows:
function MyButton() {
return <Button>Hello world!</Button>;
}
function App() {
return (
<>
<Fade>
<Button />
</Fade>
<Fade>
<MyButton />
</Fade>
</>
);
}
To make it work in this scenario, MyButton should handle the className prop:
function MyButton() {
return <Button className={props.className}>Hello world!</Button>;
}
The issue is that there is no way to enforce it in the API contract.
</details>Note: both CSS and Web Animations API options require to pass refs to elements, but that's easier to enforce as we check that a child uses
React.forwardRef().
The proposed solution relies on the Web Animations API and proposes to use the factories pattern to create React components for animating elements.
<details> <summary>Why factories over hooks?</summary>Note: The proposal primarily concentrates on low-level APIs for motion. However, following additional discussions with design teams, we might lean towards exporting prebuilt components with predefined motions, such as
Collapse,Fade,Scale, etc.In any case, the proposed APIs will be exported to enable the creation of custom motions.
To execute animations, we need a motion definition to create an animation. With hooks, this definition can be defined also inside a component, as illustrated below:
// ⚠️ This is not proposed API, it's just an example
function MyComponent() {
const motionRef = useAtomMotion({
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: 2000,
});
return <div ref={motionRef}>Hello world!</div>;
}
This approach presents a design issue: on every render, a new definition object is created and passed to the hook. At the same time, the motion must be a dependency to start the animation. Consequently, a new animation is created and started on every render, as depicted in the example below:
function useAtomMotion(motion) {
const elementRef = React.useRef();
React.useEffect(() => {
const animation = elementRef.current.animate(motion.keyframes, {
duration: motion.duration,
});
return () => {
animation.cancel();
};
}, [motion]);
}
With the factories pattern, the same problem is not possible, as a motion is defined outside a component, as shown below:
function createMotionComponent(motion) {
return React.forwardRef((props, ref) => {
const elementRef = React.useRef();
React.useEffect(() => {
elementRef.current = elementRef.current.animate(motion.keyframes, {
duration: motion.duration,
});
return () => {
elementRef.current.cancel();
};
// `motion` does not need to be a dependency as it's defined outside the component
}, []);
return React.cloneElement(props.children, { ref: elementRef });
});
}
As the Web Animations API is framework-agnostic, we don't need to use Griffel to generate CSS keyframes. Instead, we can define them in JS:
const fadeEnterSlow: AtomMotion = {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: 500,
};
The proposal suggests using JavaScript-based tokens instead of CSS variables, for example:
const fadeEnterSlow: AtomMotion = {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
};
In the same time, CSS variables can be used inside keyframes directly:
import { tokens } from '@fluentui/react-components';
const flash: AtomMotion = {
keyframes: [
{ backgroundColor: 'white' },
// 💡`tokens.colorBrandBackground` is a CSS variable i.e. `var(--colorBrandBackground)`
{ backgroundColor: tokens.colorBrandBackground },
{ backgroundColor: 'white' },
],
duration: 500,
};
However, challenges arise with options like duration and easing since they should be plain values, not CSS variables, as shown below:
const atom: AtomMotion = {
// Heads up! `duration` is in milliseconds and `easing` is a string
// ⚠️ We can't use CSS variables there
duration: motionTokens.durationSlow,
easing: motionTokens.accelerateMax,
};
Does this mean that CSS variables can't be used for them at all? No, as motions can also be defined as factories that accept an animated element as an argument:
// ⚠️ This is not proposed API, it's just an example
const motion: AtomMotionFn = element => {
const computedStyle = getComputedStyle(element);
return {
duration: Number(computedStyle.getPropertyValue('--durationUltraSlow').replace('ms', '')),
easing: computedStyle.getPropertyValue('--curveAccelerateMax'),
};
};
While this approach should not be commonly used, it's worth noting that it's possible.
</details>The proposal is to expose factories as they provide more flexibility and can be used with custom motion definitions.
import { type AtomMotion, createMotionComponent, motionTokens } from '@fluentui/react-motions-preview';
// 💡Consumers will have the option to use either predefined motions as objects or as components.
// They won't need to define custom motions unless they specifically want to.
const fade: AtomMotion = {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationUltraFast,
};
const FadeEnterSlow = createMotionComponent(fade);
function MyComponent() {
return (
<FadeEnterSlow iterations={Infinity}>
<div>Hello world!</div>
</FadeEnterSlow>
);
}
<details> <summary>Optional utility components?</summary>Note:
createMotionComponent()returns a React component that clones the child element and applies the provided animation to it.
We can also expose a set of pre-defined components, for example:
import { Collapse } from '@fluentui/react-motions-preview';
function MyComponent() {
return (
<Collapse>
<div>Hello world!</div>
</Collapse>
);
}
Since we need to expose factories regardless for creating custom motions, the proposal suggests exposing them as the API.
</details> <details> <summary>Why not follow `styled-components` / `framer-motion` API?</summary>Another API approach might be to compose existing components with motion factories, for example:
// ⚠️ This is not proposed API, it's just an example
import { Button } from '@fluentui/react-components';
import { type AtomMotion, createMotionElement } from '@fluentui/react-motions-preview';
const fade: AtomMotion = {
/* --- */
};
const FadeEnterDiv = createMotionElement('div', fade);
const FadeEnterButton = createMotionElement(Button, fade);
function MyComponent() {
return (
<>
<FadeEnterDiv>Hello world!</FadeEnterDiv>
<FadeEnterButton>Hello world!</FadeEnterButton>
</>
);
}
This approach does not have obvious pros or cons, but it's worth noting that every animated component will have to be composed, for example:
// ⚠️ This is not proposed API, it's just an example
import { Button, Image } from '@fluentui/react-components';
import { createMotionComponent, createMotionElement, atom } from '@fluentui/react-motions-preview';
const fade: AtomMotion = {
/* --- */
};
const FadeEnter = createMotionComponent(fade);
const FadeEnterButton = createMotionElement(Button, fade);
const FadeEnterImage = createMotionElement(Image, fade);
function MyComponent() {
return (
<>
<FadeEnter>
<div>Hello world!</div>
</FadeEnter>
<FadeEnter>
<Button>Hello world!</Button>
</FadeEnter>
<FadeEnterDiv>Hello world!</FadeEnterDiv>
<FadeEnterButton>Hello world!</FadeEnterButton>
</>
);
}
Compared to the CSS option, we don't need to apply classes to the element, so factories don't need to control state and won't suffer from the same issues as the CSS option with re-rendering.
<details> <summary>Handling unmount using animation events</summary>The Web Animations API has its own state machine by design, so we can subscribe, for example, to the onfinish event to handle unmount.
createPresenceComponent() does this internally, this example is just for illustration purposes on how animation events can be used with React lifecycle.
// ⚠️ Not proposed API, just an example of Web Animations API usage
function MyComponent(props) {
const { visible, motion } = props;
const [mounted, setMounted] = useState(visible);
const elementRef = React.useRef();
// Triggers an animation when `visible` prop becomes `false` and unmounts the component on finish
React.useEffect(() => {
if (!visible) {
const animation = elementRef.current.animate(motion.keyframes, {
duration: motion.duration,
});
animation.onfinish = () => {
setMounted(false);
};
}
}, [visible, motion]);
return mounted ? props.current : null;
}
It's important to understand that createPresenceComponent() relies on PresenceMotion definitions, which are a combination of enter and exit motions:
type PresenceMotion = {
enter: AtomMotion;
exit: AtomMotion;
};
For example, when using fadePresence, it yields a PresenceMotion object containing enter and exit motions:
const fadeEnter: AtomMotion = {
/* --- */
};
const fadeExit: AtomMotion = {
/* --- */
};
const fadePresence: PresenceMotion = {
enter: fadeEnter,
exit: fadeExit,
};
This structure enables the definition of distinct keyframes and options, such as durations and easing, for entering and exiting transitions.
As with createMotionComponent(), the factory returns a React component that clones the child element and applies the provided animation to it:
import { createPresenceComponent, type PresenceMotion } from '@fluentui/react-motions-preview';
// 💡 Consumers will have the option to use either predefined motions as objects or as components.
// They won't need to define custom motions unless they specifically want to.
const fadePresence: PresenceMotion = {
/* --- */
};
const Fade = createPresenceComponent(fadePresence);
function MyComponent() {
const [visible, setVisible] = useState(false);
return (
<Fade visible={visible}>
<div>Hello world!</div>
</Fade>
);
}
Unlike createMotionComponent(), a created component has additional props:
appear - whether the animation should play on mountunmountOnExit - whether the child element should be unmounted on exitonMotionFinish - a callback which is called when a motion is finishedimport { createPresenceComponent, type PresenceMotion } from '@fluentui/react-motions-preview';
// 💡 Consumers will have the option to use either predefined motions as objects or as components.
// They won't need to define custom motions unless they specifically want to.
const fadePresence: PresenceMotion = {
/* --- */
};
const Fade = createPresenceComponent(fadePresence);
function MyComponent() {
const [visible, setVisible] = useState(false);
return (
<Fade
appear
onMotionFinish={(ev, data) => console.log(`A motion was finished (direction: ${data.direction})`)}
visible={visible}
unmountOnExit
>
<div>Hello world!</div>
</Fade>
);
}
We will support more complex motions later, such as grouped and sequential animations. Since matching interfaces are not implemented in the platform, we may fallback to either a polyfill or implement a custom approach. In any case, we will need to schedule multiple animations to satisfy this use case. Consequently, the finish event will be called multiple times. For example:
<DialogSurface
onAnimationFinish={() => console.log('onAnimationFinish()')}
motion={{
element: ComplexMotion,
onMotionFinish: () => console.log('onMotionFinish()'),
}}
/>
// 🖥️ Console output
// onAnimationFinish()
// onAnimationFinish()
// onAnimationFinish()
// onMotionFinish()
onAnimationFinish() is called for every animation upon finishing.onMotionFinish() is called when all animations defined by ComplexMotion have finished.The suggested approach is to create APIs similar to the react-transition-group package, for example:
import { createPresenceComponent, type PresenceMotion, PresenceGroup } from '@fluentui/react-motions-preview';
// 💡 Consumers will have the option to use either predefined motions as objects or as components.
// They won't need to define custom motions unless they specifically want to.
const fadePresence: PresenceMotion = {
/* --- */
};
const Fade = createPresenceComponent(fadePresence);
function App() {
return (
<PresenceGroup>
<Fade>
<div>Hello world!</div>
</Fade>
<Fade>
<div>Hello world!</div>
</Fade>
</PresenceGroup>
);
}
The PresenceGroup component handles the cloning and passing of states to children via React Context. Additionally, the PresenceGroup component manages the unmounting of children.
Primarily, a component in React can exist in two states: mounted and unmounted. For instance:
function App() {
return (
<>
{true && <div>Hello world!</div>}
{false && <div>Hello world!</div>}
</>
);
}
Once a component is mounted, an animation will play. However, if a component is unmounted - it gets unmounted, and there is no way to delay it 🙁 The simplest example is usage of Dialog component:
import { Dialog } from '@fluentui/react-components';
function App() {
const [open, setOpen] = useState(false);
return (
<>
<Dialog open={open}>Hello world!</Dialog>
{open && <div>Hello world!</div>}
</>
);
}
The TransitionGroup component from the react-transition-group package resolves this issue by keeping children mounted for a while to play an animation. For example:
TransitionGroup clones it, starts the animation, and keeps it mountedThe proposed solution suggests introducing a motion prop to override the motion used in a component, functioning similarly to the existing Slots API. For example:
import { Dialog, DialogSurface } from '@fluentui/react-components';
import { createPresenceComponent } from '@fluentui/react-motions-preview';
// 💡 Consumers will have the option to use either predefined motions as objects or as components.
// They won't need to define custom motions unless they specifically want to.
const FadeSlow = createPresenceComponent(/* --- */);
function MyComponent() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open}>
<DialogSurface motion={FadeSlow}>
<div>Hello world!</div>
</DialogSurface>
</Dialog>
);
}
<details> <summary>Why not composition?</summary>In this case,
FadeSlowshould be created withcreatePresenceComponent()or adhere to a specific API contract i.e. accept specific props.
It seems reasonable to consider composing components, as illustrated in the following example:
// ⚠️ This is not proposed API, it's just an example
import { Dialog, DialogSurface } from '@fluentui/react-components';
import { createPresenceComponent } from '@fluentui/react-motions-preview';
const FadeSlow = createPresenceComponent(/* --- */);
function MyComponent() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open}>
<FadeSlow visible={visible}>
<DialogSurface>
<div>Hello world!</div>
</DialogSurface>
</FadeSlow>
</Dialog>
);
}
However, this approach comes with a few drawbacks:
FadeSlow should be controlled.
State needs to be lifted up to MyComponent to control FadeSlow. This introduces issues with re-renders, as discussed in microsoft/fluentui#29719. It also makes uncontrolled state pattern unusable.
The original motion will not be automatically replaced. As an alternative, we could export a DialogSurfaceWithoutMotion component that lacks a built-in motion, but this is not an intuitive solution.
// ⚠️ This is not proposed API, it's just an example
import { Dialog, DialogSurfaceWithoutMotion } from '@fluentui/react-components';
import { createPresenceComponent } from '@fluentui/react-motions-preview';
const FadeSlow = createPresenceComponent(/* --- */);
function MyComponent() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open}>
<FadeSlow visible={visible}>
<DialogSurfaceWithoutMotion>
<div>Hello world!</div>
</DialogSurfaceWithoutMotion>
</FadeSlow>
</Dialog>
);
}
To disable motion null can be passed similarly to Slots API:
<DialogSurface motion={null}>
motion also will have a longhand form to support motion callbacks:
<DialogSurface
motion={{
element: FadeSlow,
onMotionFinish: () => console.log('Motion ended!'),
}}
/>
To allow the integration of Fluent UI React components with third-party motion systems, the motion prop will also support a render callback, similar to the Slots API:
<DialogSurface
motion={{
children: (
Element /* 1️⃣ default element defined in a component */,
props /* 2️⃣ props required to control motion */,
) => {
return (
// 💡CSSTransition comes from "react-transition-group"
<CSSTransition classNames="my-css-fade" nodeRef={props.ref} timeout={500} in={props.visible}>
{props.children}
</CSSTransition>
);
},
}}
/>
To maintain consistency between components, the implementation of the motion prop will be provided as a function:
import { motionSlot, Collapse } from '@fluentui/react-motions-preview';
import { mergeCallbacks } from '@fluentui/react-utilities';
import * as React from 'react';
function useComponentState(props, ref) {
const { motion } = props;
const [open, setOpen] = React.useState(false);
const state = {
motion: motionSlot(motion, {
element: Collapse,
visible: open,
}),
};
// Heads up! Like other events callbacks on slots, we should merge them
state.motion.onMotionFinish = mergeCallbacks(state.motion.onMotionFinish, () => {
/* do something */
});
}
Note:
onMotionStart()support is not implemented, but could be added in the future based on requests.
This approach relies on CSS keyframes and utilizes Griffel to generate them. To animate elements, CSS classes need to be applied to them.
Given our use of CSS-in-JS, specifically Griffel, we can define CSS keyframes in JS. For example, we can create a fadeEnter animation as follows:
import { type GriffelStyle } from '@griffel/react';
const fadeEnterSlow: GriffelStyle = {
animationName: {
from: { opacity: 0 },
to: { opacity: 1 },
},
animationFillMode: 'forwards',
animationDuration: tokens.durationSlow,
animationTimingFunction: tokens.accelerateMax,
};
<details> <summary>Custom type for motion definitions?</summary>Note: We can use CSS variables in the definition, so the existing tokens can be used.
Alternatively, we could use a different type for animation definition. For example, MotionStyle to restrict the usage of CSS properties:
import { type GriffelStyle } from '@griffel/react';
type MotionStyle = Pick<
GriffelStyle,
'animationName' | 'animationFillMode' | 'animationDuration' | 'animationTimingFunction'
>;
An alternative approach is to design a universal type for motion definitions that can be employed across various CSS-in-JS engines and the Web Animations API.
Note: Portability would introduce restrictions on the usage of CSS variables in definitions, as they cannot be used with the Web Animations API.
Also, implementing this method would involve creating a helper function to convert it into a Griffel definition. For instance:
import { makeStyles, tokens } from '@fluentui/react-components';
import { type CSSMotionAtom, toGriffelStyle } from 'some-pkg';
const fadeEnterSlow: CSSMotionAtom = {
keyframes: {
from: { opacity: 0 },
to: { opacity: 1 },
},
fillMode: 'forwards',
duration: tokens.durationSlow,
timingFunction: tokens.accelerateMax,
};
const useStyles = makeStyles({
root: {
...toGriffelStyle(fadeEnterSlow),
},
});
While this approach introduces additional complexity, it doesn't offer any significant benefits. Different CSS-in-JS engines provide different APIs, necessitating the creation of a helper function for each.
import styled, { keyframes } from 'styled-components';
import { type fadeEnterSlow, toStyledComponents } from 'some-pkg';
const [keyframesCSS, css] = toStyledComponents(fadeEnterSlow);
const fadeIn = keyframes`${keyframesCSS}`;
const FadeInButton = styled.button`
animation-name: ${fadeIn};
${css}
`;
However, it's worth noting that this method won't work with plain CSS, as the definitions are created using JS objects.
</details>With Web Animations API, we can apply animations to elements directly. However, with CSS keyframes, we need to apply classes to elements to start animations and there are two ways to do it.
This can be accomplished by using it as a CSS class:
import { makeStyles } from '@griffel/react';
import { fadeEnterSlow } from 'some-pkg';
const useStyles = makeStyles({
root: {
...fadeEnterSlow,
},
});
function MyComponent() {
const styles = useStyles();
return <div className={styles.root}>Hello world!</div>;
}
Alternatively, a factory function can be utilized to create a React component, similar to the approach with the Web Animations API:
import { createMotionComponent, fadeEnterSlow } from 'some-pkg';
const FadeEnterSlow = createMotionComponent(fadeEnterSlow);
function MyComponent() {
return (
<FadeEnterSlow>
<div>Hello world!</div>
</FadeEnterSlow>
);
}
However, as previously highlighted, this approach has a notable drawback: it necessitates the child element to apply classes from props. While this contract is suitable for Fluent UI components, it may encounter issues with custom components.
The typical scenario when factories and utility components would fail is below:
import { createMotionComponent, fadeEnterSlow } from 'some-pkg';
const FadeEnterSlow = createMotionComponent(fadeEnterSlow);
const CustomComponent = React.forwardRef((props, ref) => {
// 💥 This breaks the animation
// To fix it, we need to merge classes i.e. do `mergeClasses(props.className, "my-classname")`
return <div className="my-classname" ref={ref} />;
});
function MyComponent() {
return (
<FadeEnterSlow>
<CustomComponent />
</FadeEnterSlow>
);
}
It's hard to enforce this contract for custom & third-party components.
The process is analogous to the previous one, but classes need to be applied to the element based on the visible prop and a state machine. The typical set of states includes:
unmounted - the element is not mountedidle - the element is mounted and does nothingentering - the element is mounted and enteringexiting - the element is mounted and exiting⚠️ Heads up!
Having a state implies the existence of state transitions, for example:
unmounted=>enteringentering=>idleState transitions enforce state updates, i.e. re-renders of the component to apply classes. This is the primary challenge with this approach.
We have implemented this state machine in the @fluentui/react-motion-preview package as the useMotion() hook. An example is provided below:
import { fadeEnterSlow, fadeExitSlow } from 'some-pkg';
import { useMotion } from '@fluentui/react-motion-preview';
const useClasses = makeStyles({
root: {
/* some CSS */
},
entering: {
...fadeEnterSlow,
},
exiting: {
...fadeExitSlow,
},
});
function MyComponent() {
const classes = useClasses();
const [visible, setVisible] = useState(false);
const { state, ref } = useMotion(visible);
return (
<div ref={ref} className={mergeClasses(classes.root, classes[state])}>
Hello world!
</div>
);
}
An alternative approach is to utilize factories for creating React components. However, a similar issue to atom motions arises: the child element should follow the API contract.
That would be similar to the Web Animations API approach.
That could be done in the same way as with the Web Animations API or with additional approaches.
motion & className propsThe approach proposed in microsoft/fluentui#27328 requires lifting the state controller up in the React tree to manage the motion state.
import { makeStyles, Drawer } from '@fluentui/react-components';
import { useMotion } from '@fluentui/react-motion-preview';
const useClasses = makeStyles({
/* some CSS */
});
function App() {
const classes = useClasses();
const [visible, setVisible] = useState(false);
const motion = useMotion(visible);
const className = mergeClasses(classes.root, classes[motion.state] /* simplied example */);
return (
<>
<Drawer className={className} open={motion} />
</>
);
}
The drawback of this approach is described in microsoft/fluentui#29718. As the state is lifted up, components inside <App /> will re-render on every motion state change. Although this issue can be mitigated, this API doesn't inherently guide consumers towards success.
Since this approach relies on merging classes, there isn't an intuitive way to remove the motion from the component.
motion prop as objectTo avoid lifting up the state, an option is to pass the motion configuration as an object to the component. This approach is similar to the positioning prop in the @fluentui/react-positioning package.
import { makeStyles, Drawer } from '@fluentui/react-components';
const useClasses = makeStyles({
/* some CSS */
});
function App() {
const classes = useClasses();
const [visible, setVisible] = useState(false);
return (
<>
<Drawer open={visible} motion={{ classes }} />
</>
);
}
Motion could then be removed (disabled) by passing null:
function App() {
const classes = useClasses();
const [visible, setVisible] = useState(false);
return (
<>
<Drawer open={visible} motion={null} />
</>
);
}
Currently, we have three approaches to motion in Fluent UI React components:
react-transition-group - CSS based, used in Dialog, Toast, MessageBar.@fluentui/react-motion-preview - CSS based i.e. useMotion(), used in Drawer.@fluentui/react-motions-preview - Web Animations API based i.e. createMotionComponent(), not used yet.We will use the following steps to migrate to the new motion system:
@fluentui/react-motions-preview.@fluentui/react-motion-preview should be deprecated and removed from docs.
useMotion() from @fluentui/react-motion-preview can be moved to Contrib repo.motion prop in components for overrides.It's hard to say if it's a pro or con, but the Web Animations API has higher priority in applying styles of CSS properties. This means that users cannot apply CSS to elements that are animated with the Web Animations API and replace motion.
However, they could be combined using composite property.
Currently, @fluentui/react-motions-preview does not have any motions defined (i.e. fade, collapse, etc.) as there is no final decision on their shape from design team. We can iterate on motion definitions themselves and APIs separately.
While this RFC is focused on usage of @keyframes, simple transitions could be done using transition CSS property. For example:
import { makeStyles } from '@fluentui/react-components';
const useClasses = makeStyles({
root: {
transition: 'opacity 200ms ease-in-out',
'&:hover': {
opacity: 0.5,
},
},
});
This RFC does not cover this scenario, but we can consider it in the future.
Both the CSS and Web Animations API options offer basic support for reduced motion:
useMotion()), animations are skipped if @media (prefers-reduced-motion: reduce) is true.
Note: this approach is not ideal as animation events won't be fired.
createMotionComponent(), createPresenceComponent()) animations are forced to a duration of 1ms if @media (prefers-reduced-motion: reduce) is trueIt's essential to understand that reduced motion doesn't necessarily imply a reduction in the duration of the animation. For example, in an animation where an element's size and color change, reducing motion might involve retaining the color change while minimizing movement.
For the Web Animations API, it might be feasible to enhance the motion definition with a reducedMotion option, as shown in the example below:
// ⚠️ This is not proposed API, it's just an example
const fade: AtomMotion = {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: 500,
reducedMotion: {
duration: 100,
},
};
const grow: AtomMotion = {
keyframes: [
{ height: 0, opacity: 0 },
{ height: 100, opacity: 1 },
],
duration: 500,
reducedMotion: {
keyframes: [{ height: 0 }, { height: 100 }],
},
};
Factories (createMotionComponent(), createPresenceComponent()) could utilize this prop to apply different keyframes and options based on the media query.
Motions can be combined into groups or sequences, for example:
gantt
title Balloon movement motion
dateFormat YYYY
axisFormat %Yms
section Group 1
opacity, 0 => 1 (1000ms) : 0000, 1000y
transformX, 0 => 100px (500ms) : :a1, 0000, 500y
section Group 2
transformX, 100px => 200px (500ms) : :a1, 1001, 500y
transformY, 0 => 200px (1000ms) : :a1, 1001, 1000y
section Group 3
opacity, 1 => 0 (500ms) : :a1, 2000, 500y
This is outside the scope of this RFC, but it could be considered in the future. The Web Animations API makes it easier to implement, and it can be seamlessly integrated into existing factories since changing animations no longer requires modifying CSS classes.
Implementation is tracked in microsoft/fluentui#30547
PoC prototype using existing APIs
Note: the draft of Web Animations Level 2 contains both
GroupEffectandSequenceEffectinterfaces to provide this functionality.