1+ import { clsx } from 'clsx'
12import type React from 'react'
2- import { useState , useEffect } from 'react'
3+ import { useCallback , useEffect , useRef , useState , useSyncExternalStore } from 'react'
34import { VisuallyHidden } from '../VisuallyHidden'
45import type { HTMLDataAttributes } from '../internal/internal-types'
56import { useId } from '../hooks'
67import classes from './Spinner.module.css'
7- import { clsx } from 'clsx'
8+ import { useMedia } from '../hooks/useMedia'
9+ import { useFeatureFlag } from '../FeatureFlags'
810
911const 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
9499Spinner . 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+
96224export default Spinner
0 commit comments