Skip to content

feat(motion): add Rotate presence motion component #34928

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
merged 25 commits into from
Aug 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6cffffe
feat(Rotate): start implementing Rotate motion component
robertpenner Jul 11, 2025
c8153b4
feat(Rotate): enhance Rotate component with 3D axis control and add s…
robertpenner Jul 22, 2025
4fc8b7f
feat(Rotate): implement rotateAtom for single-axis rotation and updat…
robertpenner Jul 22, 2025
c86c9f9
feat(Rotate): refactor Rotate types
robertpenner Jul 22, 2025
9a707b9
refactor(Rotate): rename enterAngle to angle
robertpenner Jul 28, 2025
1519a42
feat(Rotate): update Rotate patterns with new parameters and easing f…
robertpenner Jul 28, 2025
3f3c487
chore(Rotate): yarn change
robertpenner Jul 28, 2025
c305a53
chore(Rotate): update .api.md
robertpenner Jul 28, 2025
eaf9301
feat(Rotate): update RotateCommonPatterns to include exit easing and …
robertpenner Jul 28, 2025
97f4152
test(Rotate): add unit tests
robertpenner Jul 28, 2025
74f7dc8
feat(Rotate): rename story
robertpenner Jul 28, 2025
90f3843
feat(Rotate): adjust perspective settings and enhance card wrapper st…
robertpenner Jul 28, 2025
15939c4
docs(Rotate): remove extra shadow from cards
robertpenner Jul 28, 2025
2b4e706
docs(Rotate): adjust card perspective spring easing
robertpenner Jul 28, 2025
6c9998e
feat(Rotate): enhance design token usage and improve accessibility in…
robertpenner Jul 28, 2025
9b7f4fa
docs(Rotate): improve layout of animation controls
robertpenner Jul 28, 2025
37ee76f
feat(Rotate): remove background shadows for improved 3D rotation effect
robertpenner Jul 28, 2025
f201884
docs(Rotate): update transition effects and toggle button text for im…
robertpenner Jul 28, 2025
40663de
refactor(Rotate): reorganize type exports and improve import structur…
robertpenner Aug 8, 2025
0b82abd
refactor(Rotate): remove autoplay functionality and associated contro…
robertpenner Aug 8, 2025
6850bab
refactor(Rotate): replace Card with CompoundButton
robertpenner Aug 8, 2025
b390132
refactor(Rotate): update slider label styles and introduce slider hea…
robertpenner Aug 8, 2025
29437b7
chore(Rotate): update .api.md
robertpenner Aug 8, 2025
330ab71
refactor(Rotate): remove Axis3D type export and use RotateParams['axi…
robertpenner Aug 11, 2025
b09358f
docs(Rotate): update usage of CompoundButton in RotateCardFlip story
robertpenner Aug 11, 2025
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": "minor",
"comment": "feat(motion): add Rotate presence motion component",
"packageName": "@fluentui/react-motion-components-preview",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { PresenceComponent } from '@fluentui/react-motion';
// @public
export const Blur: PresenceComponent<BlurParams>;

// @public (undocumented)
export type BlurParams = PresenceDuration & PresenceEasing & AnimateOpacity & {
fromRadius?: string;
};

// @public
export const Collapse: PresenceComponent<CollapseParams>;

Expand All @@ -30,6 +35,16 @@ export const FadeRelaxed: PresenceComponent<FadeParams>;
// @public (undocumented)
export const FadeSnappy: PresenceComponent<FadeParams>;

// @public (undocumented)
export const Rotate: PresenceComponent<RotateParams>;

// @public (undocumented)
export type RotateParams = PresenceDuration & PresenceEasing & AnimateOpacity & {
axis?: Axis3D;
angle?: number;
exitAngle?: number;
};

// @public
export const Scale: PresenceComponent<ScaleParams>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { motionTokens } from '@fluentui/react-motion';
import { rotateAtom } from './rotate-atom';

describe('rotateAtom', () => {
it('creates enter keyframes with rotation from angle to 0', () => {
const atom = rotateAtom({
direction: 'enter',
duration: 300,
easing: motionTokens.curveEasyEase,
angle: -90,
});

expect(atom).toMatchObject({
duration: 300,
easing: motionTokens.curveEasyEase,
keyframes: [{ rotate: 'y -90deg' }, { rotate: 'y 0deg' }],
});
});

it('creates exit keyframes with rotation from 0 to exitAngle', () => {
const atom = rotateAtom({
direction: 'exit',
duration: 250,
easing: motionTokens.curveAccelerateMin,
exitAngle: 90,
});

expect(atom).toMatchObject({
duration: 250,
easing: motionTokens.curveAccelerateMin,
keyframes: [{ rotate: 'y 0deg' }, { rotate: 'y 90deg' }],
});
});

it('uses default angle when not provided', () => {
const atom = rotateAtom({
direction: 'enter',
duration: 300,
});

expect(atom.keyframes).toEqual([{ rotate: 'y -90deg' }, { rotate: 'y 0deg' }]);
});

it('uses default easing when not provided', () => {
const atom = rotateAtom({
direction: 'enter',
duration: 300,
angle: 45,
});

expect(atom.easing).toBe(motionTokens.curveLinear);
});

it('uses default axis when not provided', () => {
const atom = rotateAtom({
direction: 'enter',
duration: 300,
angle: 180,
});

expect(atom.keyframes[0]).toEqual({ rotate: 'y 180deg' });
expect(atom.keyframes[1]).toEqual({ rotate: 'y 0deg' });
});

it('handles different rotation axes', () => {
const atomX = rotateAtom({
direction: 'enter',
duration: 300,
axis: 'x',
angle: 45,
});

const atomY = rotateAtom({
direction: 'enter',
duration: 300,
axis: 'y',
angle: 45,
});

const atomZ = rotateAtom({
direction: 'enter',
duration: 300,
axis: 'z',
angle: 45,
});

expect(atomX.keyframes[0]).toEqual({ rotate: 'x 45deg' });
expect(atomY.keyframes[0]).toEqual({ rotate: 'y 45deg' });
expect(atomZ.keyframes[0]).toEqual({ rotate: 'z 45deg' });
});

it('uses exitAngle when direction is exit', () => {
const atom = rotateAtom({
direction: 'exit',
duration: 300,
angle: -90,
exitAngle: 45,
});

expect(atom.keyframes).toEqual([{ rotate: 'y 0deg' }, { rotate: 'y 45deg' }]);
});

it('uses negated angle as default exitAngle', () => {
const atom = rotateAtom({
direction: 'exit',
duration: 300,
angle: -90,
});

expect(atom.keyframes).toEqual([{ rotate: 'y 0deg' }, { rotate: 'y 90deg' }]);
});

it('handles positive and negative angle values', () => {
const atomPositive = rotateAtom({
direction: 'enter',
duration: 300,
angle: 90,
});

const atomNegative = rotateAtom({
direction: 'enter',
duration: 300,
angle: -45,
});

expect(atomPositive.keyframes[0]).toEqual({ rotate: 'y 90deg' });
expect(atomNegative.keyframes[0]).toEqual({ rotate: 'y -45deg' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { AtomMotion, PresenceDirection, motionTokens } from '@fluentui/react-motion';
import type { RotateParams } from '../components/Rotate/rotate-types';

type Axis3D = NonNullable<RotateParams['axis']>;

interface RotateAtomParams {
direction: PresenceDirection;
duration: number;
easing?: string;
axis?: Axis3D;
angle?: number;
exitAngle?: number;
}

const createRotateValue = (axis: Axis3D, angle: number): string => {
return `${axis.toLowerCase()} ${angle}deg`;
};

/**
* Generates a motion atom object for a rotation around a single axis.
* @param direction - The functional direction of the motion: 'enter' or 'exit'.
* @param duration - The duration of the motion in milliseconds.
* @param easing - The easing curve for the motion. Defaults to `motionTokens.curveLinear`.
* @param axis - The axis of rotation: 'x', 'y', or 'z'. Defaults to 'y'.
* @param angle - The starting rotation angle in degrees. Defaults to -90.
* @param exitAngle - The ending rotation angle in degrees. Defaults to the negation of `angle`.
* @returns A motion atom object with rotate keyframes and the supplied duration and easing.
*/
export const rotateAtom = ({
direction,
duration,
easing = motionTokens.curveLinear,
axis = 'y',
angle = -90,
exitAngle = -angle,
}: RotateAtomParams): AtomMotion => {
let fromAngle = angle;
let toAngle = 0;

if (direction === 'exit') {
fromAngle = 0;
toAngle = exitAngle;
}
const keyframes = [{ rotate: createRotateValue(axis, fromAngle) }, { rotate: createRotateValue(axis, toAngle) }];

return {
keyframes,
duration,
easing,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expectPresenceMotionFunction, expectPresenceMotionArray } from '../../testing/testUtils';
import { Rotate } from './Rotate';

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

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

/**
* Define a presence motion for rotate in/out
*
* @param duration - Time (ms) for the enter transition (rotate-in). Defaults to the `durationGentle` value.
* @param easing - Easing curve for the enter transition (rotate-in). Defaults to the `curveDecelerateMax` value.
* @param exitDuration - Time (ms) for the exit transition (rotate-out). Defaults to the `duration` param for symmetry.
* @param exitEasing - Easing curve for the exit transition (rotate-out). Defaults to the `curveAccelerateMax` value.
* @param axis - The axis of rotation: 'x', 'y', or 'z'. Defaults to 'y'.
* @param angle - The starting rotation angle in degrees. Defaults to -90.
* @param exitAngle - The ending rotation angle in degrees. Defaults to the negation of `angle`.
* @param animateOpacity - Whether to animate the opacity during the rotation. Defaults to `true`.
*/
const rotatePresenceFn: PresenceMotionFn<RotateParams> = ({
axis = 'y',
angle = -90,
exitAngle = -angle,
duration = motionTokens.durationGentle,
exitDuration = duration,
easing = motionTokens.curveDecelerateMax,
exitEasing = motionTokens.curveAccelerateMax,
animateOpacity = true,
}: RotateParams) => {
const enterAtoms: AtomMotion[] = [
rotateAtom({
direction: 'enter',
duration,
easing,
axis,
angle,
exitAngle,
}),
];

const exitAtoms: AtomMotion[] = [
rotateAtom({
direction: 'exit',
duration: exitDuration,
easing: exitEasing,
axis,
angle,
exitAngle,
}),
];

if (animateOpacity) {
enterAtoms.push(fadeAtom({ direction: 'enter', duration, easing }));
exitAtoms.push(fadeAtom({ direction: 'exit', duration: exitDuration, easing: exitEasing }));
}

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

// Create a presence motion component to rotate an element around a single axis (x, y, or z).
export const Rotate = createPresenceComponent(rotatePresenceFn);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Rotate } from './Rotate';
export type { RotateParams } from './rotate-types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { PresenceDuration, PresenceEasing, AnimateOpacity } from '../../types';

type Axis3D = 'x' | 'y' | 'z';

export type RotateParams = PresenceDuration &
PresenceEasing &
AnimateOpacity & {
/**
* The axis of rotation: 'x', 'y', or 'z'.
* Defaults to 'y'.
*/
axis?: Axis3D;

/**
* The starting rotation angle in degrees.
* Defaults to -90.
*/
angle?: number;

/**
* The ending rotation angle in degrees.
* Defaults to the negation of `angle`.
*/
exitAngle?: number;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { Collapse, CollapseSnappy, CollapseRelaxed, CollapseDelayed } from './co
export { Fade, FadeSnappy, FadeRelaxed } from './components/Fade';
export { Scale, ScaleSnappy, ScaleRelaxed } from './components/Scale';
export { Slide, SlideSnappy, SlideRelaxed } from './components/Slide';
export { Blur } from './components/Blur';
export { Blur, type BlurParams } from './components/Blur';
export { Rotate, type RotateParams } from './components/Rotate';
Loading
Loading