Skip to content

Commit eddd934

Browse files
authored
feat(Spinner): add support for synchronized animations (#7157)
1 parent 4a1c9a5 commit eddd934

File tree

4 files changed

+166
-3
lines changed

4 files changed

+166
-3
lines changed

.changeset/afraid-buckets-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add feature flag to control whether Spinner animations are synchronized

packages/react/src/FeatureFlags/DefaultFeatureFlags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({
77
primer_react_select_panel_fullscreen_on_narrow: false,
88
primer_react_select_panel_order_selected_at_top: false,
99
primer_react_select_panel_remove_active_descendant: false,
10+
primer_react_spinner_synchronize_animations: false,
1011
})

packages/react/src/Spinner/Spinner.examples.stories.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {useEffect, useState} from 'react'
22
import type {Meta} from '@storybook/react-vite'
33
import Spinner from './Spinner'
44
import {Button} from '..'
@@ -95,3 +95,32 @@ export const FullLifecycleVisibleLoadingText = () => {
9595
</div>
9696
)
9797
}
98+
99+
export const SynchronizedSpinners = () => (
100+
<>
101+
<Spinner />
102+
<Delay ms={250}>
103+
<Spinner />
104+
</Delay>
105+
<Delay ms={750}>
106+
<Spinner />
107+
</Delay>
108+
<Delay ms={1500}>
109+
<Spinner />
110+
</Delay>
111+
<Delay ms={2350}>
112+
<Spinner />
113+
</Delay>
114+
</>
115+
)
116+
117+
function Delay({children, ms}: {children: React.ReactNode; ms: number}) {
118+
const [show, setShow] = useState(false)
119+
120+
useEffect(() => {
121+
const timeout = setTimeout(() => setShow(true), ms)
122+
return () => clearTimeout(timeout)
123+
}, [ms])
124+
125+
return show ? <>{children}</> : null
126+
}

packages/react/src/Spinner/Spinner.tsx

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import {clsx} from 'clsx'
12
import type React from 'react'
2-
import {useState, useEffect} from 'react'
3+
import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react'
34
import {VisuallyHidden} from '../VisuallyHidden'
45
import type {HTMLDataAttributes} from '../internal/internal-types'
56
import {useId} from '../hooks'
67
import classes from './Spinner.module.css'
7-
import {clsx} from 'clsx'
8+
import {useMedia} from '../hooks/useMedia'
9+
import {useFeatureFlag} from '../FeatureFlags'
810

911
const sizeMap = {
1012
small: '16px',
@@ -34,6 +36,8 @@ function Spinner({
3436
delay = false,
3537
...props
3638
}: SpinnerProps) {
39+
const syncAnimationsEnabled = useFeatureFlag('primer_react_spinner_synchronize_animations')
40+
const animationRef = useSpinnerAnimation()
3741
const size = sizeMap[sizeKey]
3842
const hasHiddenLabel = srText !== null && ariaLabel === undefined
3943
const labelId = useId()
@@ -58,6 +62,7 @@ function Spinner({
5862
/* inline-flex removes the extra line height */
5963
<span className={classes.Box}>
6064
<svg
65+
ref={syncAnimationsEnabled ? animationRef : undefined}
6166
height={size}
6267
width={size}
6368
viewBox="0 0 16 16"
@@ -93,4 +98,127 @@ function Spinner({
9398

9499
Spinner.displayName = 'Spinner'
95100

101+
type Subscriber = () => void
102+
103+
type AnimationTimingValue = {
104+
startTime: CSSNumberish | null
105+
}
106+
107+
type AnimationTimingStore = {
108+
subscribers: Set<Subscriber>
109+
value: AnimationTimingValue
110+
update(startTime: CSSNumberish): void
111+
subscribe(subscriber: Subscriber): () => void
112+
getSnapshot(): AnimationTimingValue
113+
getServerSnapshot(): AnimationTimingValue
114+
}
115+
116+
const animationTimingStore: AnimationTimingStore = {
117+
subscribers: new Set<() => void>(),
118+
value: {
119+
startTime: null,
120+
},
121+
update(startTime) {
122+
const value = {
123+
startTime,
124+
}
125+
animationTimingStore.value = value
126+
for (const subscriber of animationTimingStore.subscribers) {
127+
subscriber()
128+
}
129+
},
130+
subscribe(subscriber) {
131+
animationTimingStore.subscribers.add(subscriber)
132+
return () => {
133+
animationTimingStore.subscribers.delete(subscriber)
134+
}
135+
},
136+
getSnapshot() {
137+
return animationTimingStore.value
138+
},
139+
getServerSnapshot() {
140+
return animationTimingStore.value
141+
},
142+
}
143+
144+
/**
145+
* A utility hook for reading a common `startTime` value so that all animations
146+
* are in sync. This is a global value and is coordinated through `useSyncExternalStore`.
147+
*/
148+
function useAnimationTiming() {
149+
return useSyncExternalStore(
150+
animationTimingStore.subscribe,
151+
animationTimingStore.getSnapshot,
152+
animationTimingStore.getServerSnapshot,
153+
)
154+
}
155+
156+
/**
157+
* Uses a technique from Spectrum to coordinate animations:
158+
* @see https://github.com/adobe/react-spectrum/blob/ab5e6f3dba4235dafab9f81f8b5c506ce5f11230/packages/%40react-spectrum/s2/src/Skeleton.tsx#L21
159+
*/
160+
function useSpinnerAnimation() {
161+
const ref = useRef<Animation | null>(null)
162+
const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', false)
163+
const animationTiming = useAnimationTiming()
164+
return useCallback(
165+
(element: HTMLElement | SVGSVGElement | null) => {
166+
if (!element) {
167+
return
168+
}
169+
170+
if (ref.current !== null) {
171+
return
172+
}
173+
174+
if (noMotionPreference) {
175+
const cssAnimation = element.getAnimations().find((animation): animation is CSSAnimation => {
176+
if (animation instanceof CSSAnimation) {
177+
return animation.animationName.startsWith('Spinner') && animation.animationName.endsWith('rotate-keyframes')
178+
}
179+
return false
180+
})
181+
// If we can find a CSS Animation, pause it and we will use the Web
182+
// Animations API to pick up from where it left off
183+
cssAnimation?.pause()
184+
185+
ref.current = element.animate(
186+
[
187+
{
188+
transform: 'rotate(0deg)',
189+
},
190+
{
191+
transform: 'rotate(360deg)',
192+
},
193+
],
194+
{
195+
// var(--base-duration-1000)
196+
duration: 1000,
197+
// var(--base-easing-linear)
198+
easing: 'cubic-bezier(0,0,1,1)',
199+
iterations: Infinity,
200+
},
201+
)
202+
203+
// When the `startTime` value from `animationTimingStore` is `null` we
204+
// are currently hydrating on the client. In this case, the first
205+
// spinner to mount will set the `startTime` for all other spinners.
206+
if (animationTiming.startTime === null) {
207+
const startTime = cssAnimation?.startTime ?? 0
208+
209+
animationTimingStore.update(startTime)
210+
211+
// We use `startTime` to sync different animations. When all animations
212+
// have the same startTime they will be in sync.
213+
// @see https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime#syncing_different_animations
214+
ref.current.startTime = startTime
215+
} else {
216+
ref.current.startTime = animationTiming.startTime
217+
}
218+
}
219+
},
220+
[noMotionPreference, animationTiming],
221+
)
222+
}
223+
96224
export default Spinner

0 commit comments

Comments
 (0)