Skip to content

Commit 4e6d1f8

Browse files
committed
feat(react-spinner): migrate Spinner animations to motion components
1 parent 93d6841 commit 4e6d1f8

7 files changed

Lines changed: 146 additions & 53 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "refactor: migrate Spinner from CSS to motion components",
4+
"packageName": "@fluentui/react-spinner",
5+
"email": "robertpenner@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-spinner/library/etc/react-spinner.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export type SpinnerSlots = {
5353
// @public
5454
export type SpinnerState = ComponentState<SpinnerSlots> & Required<Pick<SpinnerProps, 'appearance' | 'delay' | 'labelPosition' | 'size'>> & {
5555
shouldRenderSpinner: boolean;
56+
tailArcClassName?: string;
57+
tailArcRtlClassName?: string;
5658
};
5759

5860
// @public

packages/react-components/react-spinner/library/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"license": "MIT",
1414
"dependencies": {
15+
"@fluentui/react-motion": "^9.11.5",
1516
"@fluentui/react-jsx-runtime": "^9.4.1",
1617
"@fluentui/react-label": "^9.3.15",
1718
"@fluentui/react-shared-contexts": "^9.26.2",

packages/react-components/react-spinner/library/src/components/Spinner/Spinner.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ export type SpinnerState = ComponentState<SpinnerSlots> &
6767
* Should the spinner be rendered in the DOM
6868
*/
6969
shouldRenderSpinner: boolean;
70+
/**
71+
* @internal
72+
* Class name for the arc span elements inside spinnerTail (replaces ::before/::after pseudo-elements).
73+
*/
74+
tailArcClassName?: string;
75+
/**
76+
* @internal
77+
* RTL-specific class name override for the arc span elements.
78+
*/
79+
tailArcRtlClassName?: string;
7080
};
7181

7282
/**

packages/react-components/react-spinner/library/src/components/Spinner/renderSpinner.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,40 @@
22
/** @jsxImportSource @fluentui/react-jsx-runtime */
33

44
import { assertSlots } from '@fluentui/react-utilities';
5+
import { mergeClasses } from '@griffel/react';
56
import type { JSXElement } from '@fluentui/react-utilities';
67
import type { SpinnerBaseState, SpinnerSlots } from './Spinner.types';
8+
import { SpinnerRotation, SpinnerTailMotion, SpinnerArcStartMotion, SpinnerArcEndMotion } from './spinnerMotions';
79

810
/**
911
* Render the final JSX of Spinner
1012
*/
1113
export const renderSpinner_unstable = (state: SpinnerBaseState): JSXElement => {
1214
assertSlots<SpinnerSlots>(state);
13-
const { labelPosition, shouldRenderSpinner } = state;
15+
const { labelPosition, shouldRenderSpinner, tailArcClassName, tailArcRtlClassName } = state;
16+
const arcClassName = mergeClasses(tailArcClassName, tailArcRtlClassName);
1417
return (
1518
<state.root>
1619
{state.label && shouldRenderSpinner && (labelPosition === 'above' || labelPosition === 'before') && (
1720
<state.label />
1821
)}
1922
{state.spinner && shouldRenderSpinner && (
20-
<state.spinner>{state.spinnerTail && <state.spinnerTail />}</state.spinner>
23+
<SpinnerRotation>
24+
<state.spinner>
25+
{state.spinnerTail && (
26+
<SpinnerTailMotion>
27+
<state.spinnerTail>
28+
<SpinnerArcStartMotion>
29+
<span className={arcClassName} />
30+
</SpinnerArcStartMotion>
31+
<SpinnerArcEndMotion>
32+
<span className={arcClassName} />
33+
</SpinnerArcEndMotion>
34+
</state.spinnerTail>
35+
</SpinnerTailMotion>
36+
)}
37+
</state.spinner>
38+
</SpinnerRotation>
2139
)}
2240
{state.label && shouldRenderSpinner && (labelPosition === 'below' || labelPosition === 'after') && (
2341
<state.label />
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createMotionComponent, motionTokens } from '@fluentui/react-motion';
2+
3+
/**
4+
* Motion component for the Spinner root 360° rotation animation.
5+
* In reduced motion mode, the duration is slightly longer (1.8s) per the original CSS fallback.
6+
*/
7+
export const SpinnerRotation = createMotionComponent({
8+
keyframes: [{ rotate: '0deg' }, { rotate: '360deg' }],
9+
duration: 1500,
10+
iterations: Infinity,
11+
easing: motionTokens.curveLinear,
12+
reducedMotion: {
13+
duration: 1800,
14+
},
15+
});
16+
17+
// --- Tail arc animations ---
18+
// The spinner tail uses a 105deg arc mask with two 135deg arc segments that rotate
19+
// in and out from behind the mask to create a pulsing arc effect (30deg min → 255deg max).
20+
// All three animations share the same timing: 1.5s, curveEasyEase, infinite.
21+
22+
const TAIL_ARC_DURATION = 1500;
23+
const TAIL_ARC_EASING = motionTokens.curveEasyEase;
24+
25+
/**
26+
* Motion component for the spinnerTail container rotation.
27+
* Rotates from -135deg → 0deg → 225deg to sweep the masked arc window.
28+
*/
29+
export const SpinnerTailMotion = createMotionComponent({
30+
keyframes: [{ rotate: '-135deg' }, { rotate: '0deg' }, { rotate: '225deg' }],
31+
duration: TAIL_ARC_DURATION,
32+
iterations: Infinity,
33+
easing: TAIL_ARC_EASING,
34+
reducedMotion: {
35+
duration: 1,
36+
iterations: 1,
37+
},
38+
});
39+
40+
/**
41+
* Motion component for the first arc segment (was ::before).
42+
* Expands from 0deg → 105deg then collapses back to 0deg.
43+
*/
44+
export const SpinnerArcStartMotion = createMotionComponent({
45+
keyframes: [{ rotate: '0deg' }, { rotate: '105deg' }, { rotate: '0deg' }],
46+
duration: TAIL_ARC_DURATION,
47+
iterations: Infinity,
48+
easing: TAIL_ARC_EASING,
49+
reducedMotion: {
50+
duration: 1,
51+
iterations: 1,
52+
},
53+
});
54+
55+
/**
56+
* Motion component for the second arc segment (was ::after).
57+
* Expands from 0deg → 225deg then collapses back to 0deg.
58+
*/
59+
export const SpinnerArcEndMotion = createMotionComponent({
60+
keyframes: [{ rotate: '0deg' }, { rotate: '225deg' }, { rotate: '0deg' }],
61+
duration: TAIL_ARC_DURATION,
62+
iterations: Infinity,
63+
easing: TAIL_ARC_EASING,
64+
reducedMotion: {
65+
duration: 1,
66+
iterations: 1,
67+
},
68+
});

packages/react-components/react-spinner/library/src/components/Spinner/useSpinnerStyles.styles.ts

Lines changed: 38 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export const spinnerClassNames: SlotClassNames<SpinnerSlots> = {
1313
label: 'fui-Spinner__label',
1414
};
1515

16+
/**
17+
* @internal Class names for the tail arc span elements (replacing ::before/::after).
18+
*/
19+
export const spinnerTailArcClassNames = {
20+
arc: 'fui-Spinner__spinnerTailArc',
21+
};
22+
1623
/**
1724
* CSS variables used internally by Spinner
1825
*/
@@ -55,68 +62,41 @@ const useSpinnerBaseClassName = makeResetStyles({
5562
forcedColorAdjust: 'none',
5663
},
5764

58-
animationDuration: '1.5s',
59-
animationIterationCount: 'infinite',
60-
animationTimingFunction: 'linear',
61-
animationName: {
62-
'0%': { transform: 'rotate(0deg)' },
63-
'100%': { transform: 'rotate(360deg)' },
64-
},
65-
66-
'@media screen and (prefers-reduced-motion: reduce)': {
67-
animationDuration: '1.8s',
68-
},
65+
// CSS rotation animation removed — now handled by SpinnerRotation motion component in renderSpinner
6966
});
7067

71-
// The spinner tail is rendered using two 135deg arc segments, behind a 105deg arc mask.
68+
// The spinner tail uses a 105deg arc mask with two arc segment child spans (replacing ::before/::after).
7269
// The segments are rotated out from behind the mask to expand the visible arc from
7370
// 30deg (min) to 255deg (max), and then back behind the mask again to shrink the arc.
74-
// The tail and spinner itself also have 360deg rotation animations for the spin.
71+
// All animations (tail rotation + arc expand/collapse) are handled by WAAPI motion components.
7572
const useSpinnerTailBaseClassName = makeResetStyles({
7673
position: 'absolute',
7774
display: 'block',
7875
width: '100%',
7976
height: '100%',
8077
maskImage: 'conic-gradient(transparent 105deg, white 105deg)',
8178

82-
'&::before, &::after': {
83-
content: '""',
84-
position: 'absolute',
85-
display: 'block',
86-
width: '100%',
87-
height: '100%',
88-
animation: 'inherit',
89-
backgroundImage: 'conic-gradient(currentcolor 135deg, transparent 135deg)',
90-
},
79+
// CSS animations removed — now handled by SpinnerTailMotion in renderSpinner
9180

92-
animationDuration: '1.5s',
93-
animationIterationCount: 'infinite',
94-
animationTimingFunction: tokens.curveEasyEase,
95-
animationName: {
96-
'0%': { transform: 'rotate(-135deg)' },
97-
'50%': { transform: 'rotate(0deg)' },
98-
'100%': { transform: 'rotate(225deg)' },
99-
},
100-
'&::before': {
101-
animationName: {
102-
'0%': { transform: 'rotate(0deg)' },
103-
'50%': { transform: 'rotate(105deg)' },
104-
'100%': { transform: 'rotate(0deg)' },
105-
},
106-
},
107-
'&::after': {
108-
animationName: {
109-
'0%': { transform: 'rotate(0deg)' },
110-
'50%': { transform: 'rotate(225deg)' },
111-
'100%': { transform: 'rotate(0deg)' },
112-
},
113-
},
11481
'@media screen and (prefers-reduced-motion: reduce)': {
115-
animationIterationCount: '0',
82+
// Show a static arc directly on the tail element
11683
backgroundImage: 'conic-gradient(transparent 120deg, currentcolor 360deg)',
117-
'&::before, &::after': {
118-
content: 'none',
119-
},
84+
},
85+
});
86+
87+
// Styles for the arc span elements (replacing the ::before/::after pseudo-elements).
88+
// Both arc elements share identical base styles; their different rotation animations
89+
// are handled by SpinnerArcStartMotion and SpinnerArcEndMotion respectively.
90+
const useSpinnerTailArcBaseClassName = makeResetStyles({
91+
position: 'absolute',
92+
display: 'block',
93+
width: '100%',
94+
height: '100%',
95+
backgroundImage: 'conic-gradient(currentcolor 135deg, transparent 135deg)',
96+
97+
'@media screen and (prefers-reduced-motion: reduce)': {
98+
// Hide arc elements in reduced motion — static arc is shown on the tail container
99+
display: 'none',
120100
},
121101
});
122102

@@ -128,14 +108,15 @@ const useSpinnerStyles = makeStyles({
128108

129109
rtlTail: {
130110
maskImage: 'conic-gradient(white 255deg, transparent 255deg)',
131-
'&::before, &::after': {
132-
backgroundImage: 'conic-gradient(transparent 225deg, currentcolor 225deg)',
133-
},
134111
'@media screen and (prefers-reduced-motion: reduce)': {
135112
backgroundImage: 'conic-gradient(currentcolor 0deg, transparent 240deg)',
136113
},
137114
},
138115

116+
rtlTailArc: {
117+
backgroundImage: 'conic-gradient(transparent 225deg, currentcolor 225deg)',
118+
},
119+
139120
'extra-tiny': {
140121
height: '16px',
141122
width: '16px',
@@ -237,6 +218,7 @@ export const useSpinnerStyles_unstable = (state: SpinnerState): SpinnerState =>
237218
const spinnerBaseClassName = useSpinnerBaseClassName();
238219
const spinnerStyles = useSpinnerStyles();
239220
const spinnerTailBaseClassName = useSpinnerTailBaseClassName();
221+
const spinnerTailArcBaseClassName = useSpinnerTailArcBaseClassName();
240222
const labelStyles = useLabelStyles();
241223

242224
state.root.className = mergeClasses(
@@ -262,6 +244,11 @@ export const useSpinnerStyles_unstable = (state: SpinnerState): SpinnerState =>
262244
state.spinnerTail.className,
263245
);
264246
}
247+
248+
// Set arc element classNames for use in renderSpinner
249+
state.tailArcClassName = mergeClasses(spinnerTailArcClassNames.arc, spinnerTailArcBaseClassName);
250+
state.tailArcRtlClassName = dir === 'rtl' ? spinnerStyles.rtlTailArc : undefined;
251+
265252
if (state.label) {
266253
state.label.className = mergeClasses(
267254
spinnerClassNames.label,

0 commit comments

Comments
 (0)