Skip to content
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

feat(motion): add extended support for reduced motion #33353

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add extended support for reduced motion",
"packageName": "@fluentui/react-motion",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { renderHook } from '@testing-library/react-hooks';

import type { AtomMotion } from '../types';
import { DEFAULT_ANIMATION_OPTIONS, useAnimateAtoms } from './useAnimateAtoms';

function createElementMock() {
const animate = jest.fn().mockReturnValue({
persist: jest.fn(),
});

return [{ animate } as unknown as HTMLElement, animate] as const;
}

const DEFAULT_KEYFRAMES = [{ transform: 'rotate(0)' }, { transform: 'rotate(180deg)' }];
const REDUCED_MOTION_KEYFRAMES = [{ opacity: 0 }, { opacity: 1 }];

describe('useAnimateAtoms', () => {
beforeEach(() => {
// We set production environment to avoid testing the mock implementation
process.env.NODE_ENV = 'production';
});

it('should return a function', () => {
const { result } = renderHook(() => useAnimateAtoms());

expect(result.current).toBeInstanceOf(Function);
});

describe('reduce motion', () => {
it('calls ".animate()" with regular motion', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };

result.current(element, motion, { isReducedMotion: false });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS });
});

it('calls ".animate()" with shortened duration (1ms) when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = { keyframes: DEFAULT_KEYFRAMES };

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS, duration: 1 });
});

it('calls ".animate()" with specified reduced motion keyframes when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = {
keyframes: DEFAULT_KEYFRAMES,
reducedMotion: { keyframes: REDUCED_MOTION_KEYFRAMES },
};

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(REDUCED_MOTION_KEYFRAMES, { ...DEFAULT_ANIMATION_OPTIONS });
});

it('calls ".animate()" with specified reduced motion params when reduced motion is enabled', () => {
const { result } = renderHook(() => useAnimateAtoms());

const [element, animateMock] = createElementMock();
const motion: AtomMotion = {
keyframes: DEFAULT_KEYFRAMES,
reducedMotion: { duration: 100, easing: 'linear' },
};

result.current(element, motion, { isReducedMotion: true });

expect(animateMock).toHaveBeenCalledTimes(1);
expect(animateMock).toHaveBeenCalledWith(DEFAULT_KEYFRAMES, {
...DEFAULT_ANIMATION_OPTIONS,
easing: 'linear',
duration: 100,
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from 'react';
import type { AnimationHandle, AtomMotion } from '../types';

export const DEFAULT_ANIMATION_OPTIONS: KeyframeEffectOptions = {
fill: 'forwards',
};

function useAnimateAtomsInSupportedEnvironment() {
return React.useCallback(
(
Expand All @@ -14,13 +18,20 @@ function useAnimateAtomsInSupportedEnvironment() {
const { isReducedMotion } = options;

const animations = atoms.map(motion => {
const { keyframes, ...params } = motion;
const animation = element.animate(keyframes, {
fill: 'forwards',

const { keyframes, reducedMotion, ...params } = motion;
const { keyframes: reducedMotionKeyframes = keyframes, ...reducedMotionParams } = reducedMotion ?? {
duration: 1,
};

const animationKeyframes: Keyframe[] = isReducedMotion ? reducedMotionKeyframes : keyframes;
const animationParams: KeyframeEffectOptions = {
...DEFAULT_ANIMATION_OPTIONS,
...params,
...(isReducedMotion && { duration: 1 }),
});

...(isReducedMotion && reducedMotionParams),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user has provided reducedMotion params, I think we should stop applying the default duration: 1 value.

It might sound stupid to pass no duration and it defaults to 0, but we should still the user have the full override power in this case

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user has provided reducedMotion params, I think we should stop applying the default duration: 1 value.

I wanted to avoid breaks, that's why I kept it. A user will be still able to override duration, so I don't see any harm with the current behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ling1726 @layershifter What if we forced them to override duration for reducedMotion? After all, if the duration stays at 1 ms, new keyframes aren't going to accomplish much, in most use cases. And there might be someone who adds reducedMotion keyframes, then is puzzled when they don't seem to work, because they don't realize the duration is at 1 ms and they need to change it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ling1726 @layershifter What if we forced them to override duration for reducedMotion? After all, if the duration stays at 1 ms, new keyframes aren't going to accomplish much, in most use cases. And there might be someone who adds reducedMotion keyframes, then is puzzled when they don't seem to work, because they don't realize the duration is at 1 ms and they need to change it.

That's valid concern. As both you (@robertpenner) and @ling1726 raised it, I updated the implementation:

  • reducedMotion is undefined => { duration: 1ms }
  • reducedMotion is defined => we will use whatever user provided

};

const animation = element.animate(animationKeyframes, animationParams);

animation.persist();

Expand Down
13 changes: 12 additions & 1 deletion packages/react-components/react-motion/library/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions;
export type AtomMotion = { keyframes: Keyframe[] } & KeyframeEffectOptions & {
/**
* Allows to specify a reduced motion version of the animation. If provided, the settings will be used when the
* user has enabled the reduced motion setting in the operating system. If not provided, the duration of the
* animation will be overridden to be 1ms.
*
* Note, if keyframes are provided, they will be used instead of the regular keyframes.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
*/
reducedMotion?: { keyframes?: Keyframe[] } & KeyframeEffectOptions;
};
Copy link
Contributor

@bsunderhus bsunderhus Nov 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks to me that reducedMotion is a recursion of AtomMotion 🤔, maybe it would be better to add it somewhere else to avoid repetition? or perhaps we should have a middle type here between PresenceMotion and AtomMotion?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something similar to griffel fallback properties?!

const Motion = createPresenceComponent([
  // default motion
  {
    enter: {
      keyframes: [
        { opacity: 0, transform: 'scale(0)' },
        { opacity: 1, transform: 'scale(1)' },
      ],
      duration: 300,
    },
    exit: {
      /* ... */
    },
  }, 
  {/* reduced motion goes here */}
]);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bsunderhus I also think a middle type would be good, to DRY up
{ keyframes: Keyframe[] } & KeyframeEffectOptions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from @bsunderhus
or perhaps we should have a middle type here between PresenceMotion and AtomMotion?

from @robertpenner
I also think a middle type would be good, to DRY up
{ keyframes: Keyframe[] } & KeyframeEffectOptions.

Do you mean a new type to extract { keyframes?: Keyframe[] } & KeyframeEffectOptions?


Maybe something similar to griffel fallback properties?!

@bsunderhus I updated the PR description with two options and added some arguments against them. Please check and let me know WDYT.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@layershifter

Do you mean a new type to extract { keyframes?: Keyframe[] } & KeyframeEffectOptions?

Is it necessary to make keyframes optional in reducedMotion (could we look at a use case)? Or would it be simpler to say, "If you're going to make a custom reduced motion, you need to define keyframes"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to make keyframes optional in reducedMotion (could we look at a use case)? Or would it be simpler to say, "If you're going to make a custom reduced motion, you need to define keyframes"?

I don't think that we need to force consumers there, especially after the duration change (#33353 (comment)). Also, the media query itself (https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) does not put any restrictions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just throwing out another idea, instead of making the reduced motion options a recursion of the parent motion declaration we use a second argument in the factory and enforce the SRP for motion declarations?

createPresentionComponent(atom, reducedMotionAtom?)

This way we'll avoid TS recursion, and decouple the original motion and its reduced motion alternative. From what I understand even the current reducedMotion property completely overrides all other options anyway

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not recursion though; it's just leaf reuse. There is no atom inside an atom, nor keyframe inside a keyframe.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ling1726 please check "rejected options" in the PR's description, the order issue will be the same here.


export type PresenceDirection = 'enter' | 'exit';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const FadeEnter = createMotionComponent({
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentDefault = (props: MotionComponentProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const DropIn = createMotionComponent({
],
duration: 4000,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentFactory = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ const Scale = createMotionComponent<{ startFrom?: number }>(({ startFrom = 0.5 }
],
duration: motionTokens.durationUltraSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const Grow = createMotionComponent(({ element }) => ({
{ opacity: 0, maxHeight: `${element.scrollHeight / 2}px` },
],
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
}));

export const CreateMotionComponentFunctions = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ const FadeEnter = createMotionComponent({
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationSlow,
iterations: Infinity,

reducedMotion: {
iterations: 1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the value of having a single iteration here? Is it just that this property has a clearer presence in documentation?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With infinite iterations, the reduced motion with 1 ms was looping and flashing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👆 Exactly, either this or we need to complicate examples significantly.

},
});

export const CreateMotionComponentImperativeRefPlayState = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const BackgroundChange = createMotionComponent({
],
duration: 3000,
iterations: Infinity,

reducedMotion: {
iterations: 1,
},
});

export const CreateMotionComponentTokensUsage = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
By default, when [reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) is enabled the duration of the animation is set to `1ms`. `reducedMotion` allows to customize a reduced motion version of the animation:

```ts
const Motion = createPresenceComponent({
enter: {
keyframes: [
{ opacity: 0, transform: 'scale(0)' },
{ opacity: 1, transform: 'scale(1)' },
],
duration: 300,

/* 💡reduced motion will not have scale animation */
reducedMotion: {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: 1000,
},
},
exit: {
/* ... */
},
});
```

> 💡Note, if `keyframes` are provided, they will be used instead of the regular keyframes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
createPresenceComponent,
Field,
makeStyles,
mergeClasses,
type MotionImperativeRef,
motionTokens,
Slider,
Switch,
tokens,
} from '@fluentui/react-components';
import * as React from 'react';

import description from './CreatePresenceComponentReducedMotion.stories.md';

const useClasses = makeStyles({
container: {
display: 'grid',
gridTemplate: `"card card" "controls ." / 1fr 1fr`,
gap: '20px 10px',
},
card: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'end',
gridArea: 'card',

border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`,
borderRadius: tokens.borderRadiusMedium,
boxShadow: tokens.shadow16,
padding: '10px',
},
controls: {
display: 'flex',
flexDirection: 'column',
gridArea: 'controls',

border: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground3}`,
borderRadius: tokens.borderRadiusMedium,
boxShadow: tokens.shadow16,
padding: '10px',
},
field: {
flex: 1,
},
sliderField: {
gridTemplateColumns: 'min-content 1fr',
},
sliderLabel: {
textWrap: 'nowrap',
},

item: {
backgroundColor: tokens.colorBrandBackground,
border: `${tokens.strokeWidthThicker} solid ${tokens.colorTransparentStroke}`,

width: '100px',
height: '100px',
},
});

const FadeAndScale = createPresenceComponent({
enter: {
keyframes: [
{ opacity: 0, transform: 'rotate(0)' },
{ transform: 'rotate(90deg) scale(1.5)' },
{ opacity: 1, transform: 'rotate(0)' },
],
duration: motionTokens.durationGentle,

reducedMotion: {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
duration: motionTokens.durationUltraSlow,
},
},
exit: {
keyframes: [
{ opacity: 1, transform: 'rotate(0)' },
{ transform: 'rotate(-90deg) scale(1.5)' },
{ opacity: 0, transform: 'rotate(0)' },
],
duration: motionTokens.durationGentle,

reducedMotion: {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
duration: motionTokens.durationUltraSlow,
},
},
});

export const CreatePresenceComponentReducedMotion = () => {
const classes = useClasses();
const motionRef = React.useRef<MotionImperativeRef>();

const [playbackRate, setPlaybackRate] = React.useState<number>(30);
const [visible, setVisible] = React.useState<boolean>(true);

// Heads up!
// This is optional and is intended solely to slow down the animations, making motions more visible in the examples.
React.useEffect(() => {
motionRef.current?.setPlaybackRate(playbackRate / 100);
}, [playbackRate, visible]);

return (
<div className={classes.container}>
<div className={classes.card}>
<FadeAndScale imperativeRef={motionRef} visible={visible}>
<div className={classes.item} />
</FadeAndScale>
</div>

<div className={classes.controls}>
<Field className={classes.field}>
<Switch label="Visible" checked={visible} onChange={() => setVisible(v => !v)} />
</Field>
<Field
className={mergeClasses(classes.field, classes.sliderField)}
label={{
children: (
<>
<code>playbackRate</code>: {playbackRate}%
</>
),
className: classes.sliderLabel,
}}
orientation="horizontal"
>
<Slider
aria-valuetext={`Value is ${playbackRate}%`}
className={mergeClasses(classes.field, classes.sliderField)}
value={playbackRate}
onChange={(ev, data) => setPlaybackRate(data.value)}
min={0}
max={100}
step={5}
/>
</Field>
</div>
</div>
);
};

CreatePresenceComponentReducedMotion.parameters = {
docs: {
description: {
story: description,
},
},
};
Loading
Loading