Skip to content

feat(motion): add Slide motion component #33878

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

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Copy link
Collaborator

@fabricteam fabricteam Mar 6, 2025

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 1 screenshots
Image Name Diff(in Pixels) Image Type
Avatar Converged.badgeMask.normal.chromium.png 1 Changed
Drawer 1 screenshots
Image Name Diff(in Pixels) Image Type
Drawer.overlay drawer full - RTL.chromium.png 1172 Changed

"type": "minor",
"comment": "Add Slide motion component",
"packageName": "@fluentui/react-motion-components-preview",
"email": "robertpenner@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const createFadePresence: PresenceMotionCreator<FadeVariantParams>;
// @public
export const createScalePresence: PresenceMotionFnCreator<ScaleVariantParams_unstable, ScaleRuntimeParams_unstable>;

// @public
export const createSlidePresence: PresenceMotionFnCreator<SlideVariantParams_unstable, SlideRuntimeParams_unstable>;

// @public
export const Fade: PresenceComponent< {}>;

Expand All @@ -57,6 +60,15 @@ export const ScaleRelaxed: PresenceComponent<ScaleRuntimeParams_unstable>;
// @public (undocumented)
export const ScaleSnappy: PresenceComponent<ScaleRuntimeParams_unstable>;

// @public
export const Slide: PresenceComponent<SlideRuntimeParams_unstable>;

// @public (undocumented)
export const SlideRelaxed: PresenceComponent<SlideRuntimeParams_unstable>;

// @public (undocumented)
export const SlideSnappy: PresenceComponent<SlideRuntimeParams_unstable>;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PresenceDirection, AtomMotion } from '@fluentui/react-motion/src/types';
import { SlideOrientation } from '../components/Slide/Slide.types';

/**
* Generates a motion atom object for a horizontal or vertical translation, from a specified distance to zero.
* @param direction - The functional direction of the motion: 'enter' or 'exit'.
* @param orientation - The axis of the translation: 'X' or 'Y'.
* @param distance - The distance of the slide relative to the natural position. It can be pixels or other length unit.
* @param duration - The duration of the motion in milliseconds.
* @param easing - The easing curve for the motion.
*/
export const slideAtom = ({
direction,
orientation,
distance,
duration,
easing,
}: {
direction: PresenceDirection;
orientation: SlideOrientation;
distance: string;
duration: number;
easing: string;
}): AtomMotion => {
const axis: 'X' | 'Y' = orientation === 'horizontal' ? 'X' : 'Y';
const keyframes = [{ transform: `translate${axis}(${distance})` }, { transform: `translate${axis}(0)` }];
if (direction === 'exit') {
keyframes.reverse();
}
return {
keyframes,
duration,
easing,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AtomMotion, PresenceDirection } from '@fluentui/react-motion';

/**
* Generates a motion atom object for visibility, toggling between 'visible' and 'hidden'.
* This is useful for making elements disappear when there is no fade-out.
* @param direction - The functional direction of the motion: 'enter' or 'exit'.
* @param duration - The duration of the motion in milliseconds.
* @returns A motion atom object with visibility keyframes and the supplied duration.
*/
export const visibilityAtom = ({
direction,
duration,
}: {
direction: PresenceDirection;
duration: number;
}): AtomMotion => {
const visibility = direction === 'enter' ? 'visible' : 'hidden';
// For the exit animation, offset is 1 so the keyframe to set visibility to 'hidden' is at the end of the animation.
const offset = direction === 'enter' ? 0 : 1;
return {
keyframes: [{ visibility, offset }],
duration,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expectPresenceMotionFunction, expectPresenceMotionArray } from '../../testing/testUtils';
import { Slide } from './Slide';

describe('Slide', () => {
it('stores its motion definition as a static function', () => {
expectPresenceMotionFunction(Slide);
});

it('generates a motion definition from the static function', () => {
expectPresenceMotionArray(Slide);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
motionTokens,
createPresenceComponent,
AtomMotion,
PresenceMotionFn,
MotionParam,
} from '@fluentui/react-motion';
import { SlideRuntimeParams } from './Slide.types';
import { fadeAtom } from '../../atoms/fade-atom';
import { slideAtom } from '../../atoms/slide-atom';

const slidePresenceFn: PresenceMotionFn<SlideRuntimeParams> = ({
duration = motionTokens.durationNormal,
easing = motionTokens.curveDecelerateMid,
exitDuration = duration, // defaults to the enter duration for symmetry
exitEasing = motionTokens.curveAccelerateMid,
orientation = 'vertical',
distance = '20px',
animateOpacity = true,
}) => {
// ----- ENTER -----
const enterAtoms: AtomMotion[] = [
slideAtom({
direction: 'enter',
orientation,
distance,
duration,
easing,
}),
];
if (animateOpacity) {
enterAtoms.push(
fadeAtom({
direction: 'enter',
duration,
easing,
}),
);
} else {
// TODO: need to test visibility behavior further
// Since there is no fade-in, use visibility to show the element
// enterAtoms.push(visibilityAtom({ direction: 'enter', duration }));
}

// ----- EXIT -----
const exitAtoms: AtomMotion[] = [
slideAtom({
direction: 'exit',
orientation,
distance,
duration: exitDuration,
easing: exitEasing,
}),
];
if (animateOpacity) {
exitAtoms.push(
fadeAtom({
direction: 'exit',
duration: exitDuration,
easing: exitEasing,
}),
);
} else {
// TODO: need to test visibility behavior further
// Since there is no fade-out, use visibility to hide the element
// enterAtoms.push(visibilityAtom({ direction: 'exit', duration: exitDuration }));
}

return {
enter: enterAtoms,
exit: exitAtoms,
};
};

// TODO: move to createPresenceComponentVariant file
/**
* Create a variant function that wraps a presence function to customize it.
* The new presence function has the supplied variant params as defaults,
* but these can still be overridden by runtime params when the new function is called.
*/
function createPresenceFnVariant<PresenceParams extends Record<string, MotionParam> = {}>(
presenceFn: PresenceMotionFn<PresenceParams>,
variantParams: SlideRuntimeParams,
): typeof presenceFn {
return runtimeParams => presenceFn({ ...variantParams, ...runtimeParams });
}

/** A React component that applies slide in/out transitions to its children. */
export const Slide = createPresenceComponent(slidePresenceFn);

// TODO: use new createPresenceComponentVariant implementation when available
export const SlideSnappy = createPresenceComponent(
createPresenceFnVariant(slidePresenceFn, {
duration: motionTokens.durationNormal,
easing: motionTokens.curveDecelerateMax,
exitEasing: motionTokens.curveAccelerateMax,
}),
);

export const SlideRelaxed = createPresenceComponent(
createPresenceFnVariant(slidePresenceFn, {
duration: motionTokens.durationGentle,
easing: motionTokens.curveDecelerateMid,
exitEasing: motionTokens.curveAccelerateMid,
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export type SlideOrientation = 'horizontal' | 'vertical';

export type SlideRuntimeParams = {
/**
* The orientation of the slide animation: 'horizontal' or 'vertical'
* @default 'vertical'
*/
orientation?: SlideOrientation;

/**
* The distance of the slide, relative to the content's natural position.
* Can be positive or negative, in pixels or other length units.
* @default '10px'
*/
distance?: string;

/** Time (ms) for the enter transition. Defaults to the `durationNormal` value (200 ms). */
duration?: number;

/** Easing curve for the enter transition. Defaults to the `curveDecelerateMid` value. */
easing?: string;

/** Time (ms) for the exit transition. Defaults to the `enterDuration` param for symmetry. */
exitDuration?: number;

/** Easing curve for the exit transition. Defaults to the `curveAccelerateMid` value. */
exitEasing?: string;

/** Whether to animate the opacity. Defaults to `true`. */
animateOpacity?: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Slide, SlideRelaxed, SlideSnappy, createSlidePresence } from './Slide';
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
export type { CollapseRuntimeParams } from './components/Collapse';
export { Fade, FadeSnappy, FadeRelaxed, createFadePresence } from './components/Fade';
export { Scale, ScaleSnappy, ScaleRelaxed, createScalePresence } from './components/Scale';
export { Slide, SlideSnappy, SlideRelaxed, createSlidePresence } from './components/Slide';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';
import { PresenceComponentProps } from '@fluentui/react-components';
import { Slide } from '@fluentui/react-motion-components-preview';

const LoremIpsum = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>((props, ref) => (
<div ref={ref} {...props}>
{'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '.repeat(
10,
)}
</div>
));

export const DefaultSlide = (props: PresenceComponentProps) => {
return (
<Slide {...props}>
<LoremIpsum />
</Slide>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
- The predefined fade transition can be disabled by setting `animateOpacity` to `false`.
- A scale variant can be created with the factory function `createSlidePresence()`, then converting the result to a React component using `createPresenceComponent()`:

```tsx
import { motionTokens, createPresenceComponentVariant } from '@fluentui/react-components';
import { createSlidePresence } from '@fluentui/react-motion-components-preview';

const CustomSlideVariant = createPresenceComponent(
createSlidePresence({
enterDuration: motionTokens.durationSlow,
enterEasing: motionTokens.curveEasyEaseMax,
exitDuration: motionTokens.durationNormal,
exitEasing: motionTokens.curveEasyEaseMax,
}),
);

const CustomSlide = ({ visible }) => (
<CustomSlideVariant animateOpacity={false} unmountOnExit visible={visible}>
{/* Content */}
</CustomSlideVariant>
);
```
Loading
Loading