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

Conversation

layershifter
Copy link
Member

@layershifter layershifter commented Nov 27, 2024

Previous Behavior

No way to customize motions' behavior when reduced motion is enabled.

New Behavior

  • Adds reducedMotion option to AtomMotion type.
  • Tests added
  • Story added

By default, when reduced motion is enabled the duration of the animation is set to 1ms (current behavior).

reducedMotion allows to customize a reduced motion version of the animation:

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

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

💡Note, if keyframes are provided, they will be used instead of the regular keyframes.

This will also works with arrays of atoms as reducedMotion definitions are collocated to its atoms:

const Motion = createPresenceComponent({
  enter: [
    /** Atom A
        Has a definition for "reducedMotion" */
    {
      keyframes: [
        /* ... */
      ],
      reducedMotion: {
        /* some options or keyframes*/
      },
    },
    /** Atom B
        This atom _may_ declare "reducedMotion" (it's optional) */
    {
      keyframes: [
        /* ... */
      ],
    },
  ],
  exit: [
    /* ... */
  ],
});

Rejected options

Second argument to createMotionComponent() & createPresenceComponent()

Note: examples are based on createPresenceComponent() as its syntax is more verbose and highlights challenges better.

We have already an array syntax for multiple atoms (see createPresenceComponent() and arrays):

// 💡 We already have this API implemented
createPresenceComponent({
  enter: [
    { keyframes: [] /* Atom A: enter */},
    { keyframes: [] /* Atom B: enter */},
  ],
  exit: [/* ... */],
});

The proposal is two have a second argument:

// ⚠️ Proposed API (rejected option)
createPresenceComponent([
  {
    enter: [
      { keyframes: [] /* Atom A: enter */ },
      { keyframes: [] /* Atom B: enter */ },
    ],
    exit: [/* ... */],
  },
  {
    enter: [
      { keyframes: [] /* Atom A: enter & reduced motion */ },
      { keyframes: [] /* Atom B: enter & reduced motion */ },
    ],
    exit: [/* ... */],
  },
]);

However, it will be hard to distinguish between atoms and reduced motion declarations - you have to know the order of arguments in the functions to understand it.

This implementation also brings a challenge of collocation: reduced options should be optional, however we will need to maintain the order to make it work. The example below shows the requirement:

// ⚠️ Proposed API (rejected option)
createPresenceComponent([
  {
    enter: [
      { keyframes: [] /* Atom A: enter */ },
      { keyframes: [] /* Atom B: enter */ },
    ],
    exit: [/* ... */],
  },
  {
    enter: [
      { keyframes: [] /* Atom B: enter & reduced motion */ },
      // 💥 💥 💥 
      // There are two atoms in the motion declaration, however only one in reduced options
      // The order will break and options will be wrongly applied
    ],
    exit: [/* ... */],
  },
]);

Consumers will be forced to pass null (or something else?) in this case:

// ⚠️ Proposed API (rejected option)
createPresenceComponent([
  {
    enter: [
      { keyframes: [] /* Atom A: enter */ },
      { keyframes: [] /* Atom B: enter */ },
    ],
    exit: [
      { keyframes: [] /* Atom A: exit */ },
      { keyframes: [] /* Atom B: enter */ },
    ],
  },
  {
    enter: [
      null, /* Atom A: enter & reduced motion */,
      { keyframes: [] /* Atom B: enter & reduced motion */ },
    ],
    exit: [/* ... */],
  },
]);

Nest atoms

That's very similar to previous option, but instead we will have syntax similar to fallback values in Griffel.

// ⚠️ Proposed API (rejected option)
createPresenceComponent([
  {
    enter: [
      [
        { keyframes: [], duration: 1000 /* Atom A: enter */ },
        { keyframes: [], duration: 300 /* Atom A: enter, reduced options */ },
        // 👆 reduced options are collocated there
      ],
      [{ keyframes: [] /* Atom B: enter */ }],
    ],
    exit: [/* ... */],
  },
]);

In this case we collocate reduced options with the atom they are related to, so we don't need to maintain the order like in the previous option 👍

However, this option has the same issue with being implicit and hard to understand: which array stands for atoms and which for reduced options?

Related issues

Fixes #33358.

Copy link

github-actions bot commented Nov 27, 2024

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-accordion
Accordion (including children components)
106.859 kB
32.724 kB
106.931 kB
32.749 kB
72 B
25 B
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
221.351 kB
64.017 kB
221.423 kB
64.064 kB
72 B
47 B
react-components
react-components: entire library
1.164 MB
291.555 kB
1.164 MB
291.573 kB
72 B
18 B
react-dialog
Dialog (including children components)
100.196 kB
30.032 kB
100.268 kB
30.059 kB
72 B
27 B
react-motion
@fluentui/react-motion - createMotionComponent()
4.213 kB
1.846 kB
4.289 kB
1.876 kB
76 B
30 B
react-motion
@fluentui/react-motion - createPresenceComponent()
4.944 kB
2.181 kB
5.014 kB
2.211 kB
70 B
30 B
react-toast
Toast (including Toaster)
101.009 kB
30.355 kB
101.081 kB
30.394 kB
72 B
39 B
react-tree
FlatTree
145.241 kB
41.708 kB
145.313 kB
41.742 kB
72 B
34 B
react-tree
PersonaFlatTree
145.927 kB
41.819 kB
145.999 kB
41.853 kB
72 B
34 B
react-tree
PersonaTree
142.16 kB
40.64 kB
142.232 kB
40.671 kB
72 B
31 B
react-tree
Tree
141.472 kB
40.539 kB
141.544 kB
40.557 kB
72 B
18 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Button, FluentProvider & webLightTheme
69.21 kB
20.174 kB
react-components
react-components: FluentProvider & webLightTheme
44.447 kB
14.59 kB
react-motion
@fluentui/react-motion - PresenceGroup
1.714 kB
819 B
react-portal-compat
PortalCompatProvider
8.39 kB
2.64 kB
react-timepicker-compat
TimePicker
107.39 kB
35.763 kB
🤖 This report was generated against 592b73f62e471a2e4668a0949583790a5ef17ef8

Copy link

Pull request demo site: URL

@@ -6,5 +6,6 @@ import * as React from 'react';
export type MotionBehaviourType = 'skip' | 'default';
Copy link
Collaborator

@fabricteam fabricteam Nov 27, 2024

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual regressions to review in the fluentuiv9 Visual Regression Report

Avatar Converged 2 screenshots
Image Name Diff(in Pixels) Image Type
Avatar Converged.badgeMask.chromium.png 2 Changed
Avatar Converged.Badge Mask RTL.chromium.png 5 Changed
Drawer 3 screenshots
Image Name Diff(in Pixels) Image Type
Drawer.Full Overlay High Contrast.chromium.png 2219 Changed
Drawer.Full Overlay Dark Mode.chromium.png 4157 Changed
Drawer.Full Overlay RTL.chromium.png 1167 Changed

@layershifter layershifter force-pushed the fix/create-motion-reduced branch 3 times, most recently from 2f16fd0 to 07ad3b3 Compare November 27, 2024 16:52
@layershifter layershifter changed the title fix(motion): handle reduced motion in createMotionComponent() feat(motion): add extended support for reduced motion Nov 27, 2024
@layershifter layershifter force-pushed the fix/create-motion-reduced branch from 07ad3b3 to 7649e34 Compare November 27, 2024 16:54
@layershifter layershifter marked this pull request as ready for review November 27, 2024 17:03
@layershifter layershifter requested a review from a team as a code owner November 27, 2024 17:03
*
* @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.

@robertpenner
Copy link
Collaborator

The feature motivation is solid; the feature design needs to be fleshed out a bit more like an RFC (if it's not going to be an actual RFC). For example:

  • The alternative reducedMotion implementation conceivably could be placed in a number of different spots. How do we know this spot is the best design? Comparing alternatives would be instructive.
  • How might reducedMotion look with other cases, e.g. when enter is an array of atoms?

@layershifter
Copy link
Member Author

the feature design needs to be fleshed out a bit more like an RFC (if it's not going to be an actual RFC).

FYI, it's was mentioned in the original RFC as a proposal: https://github.com/microsoft/fluentui/blob/master/docs/react-v9/contributing/rfcs/react-components/convergence/motion-definition-n-apis.md#advanced-reduced-motion-support


  • The alternative reducedMotion implementation conceivably could be placed in a number of different spots. How do we know this spot is the best design? Comparing alternatives would be instructive.

I updated the PR description to list other two options, is there anything that comes to your mind? Did I miss something?

  • How might reducedMotion look with other cases, e.g. when enter is an array of atoms?

As the atom definition changes, it will work transparently. I added an example to the PR description.

*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
*/
reducedMotion?: { keyframes?: Keyframe[] } & KeyframeEffectOptions;
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.

...(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

@@ -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.

@robertpenner
Copy link
Collaborator

  • The alternative reducedMotion implementation conceivably could be placed in a number of different spots. How do we know this spot is the best design? Comparing alternatives would be instructive.

I updated the PR description to list other two options, is there anything that comes to your mind? Did I miss something?

@layershifter I think you've covered it well.

  • How might reducedMotion look with other cases, e.g. when enter is an array of atoms?

As the atom definition changes, it will work transparently. I added an example to the PR description.

Excellent additions, thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: createMotionComponent() does not respect motion in settings of OS
5 participants