Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/afraid-buckets-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Update Spinner animations to be synchronized
37 changes: 36 additions & 1 deletion packages/react/src/Spinner/Spinner.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useEffect, useState} from 'react'
import type {Meta} from '@storybook/react-vite'
import Spinner from './Spinner'
import {Button} from '..'
Expand Down Expand Up @@ -95,3 +95,38 @@ export const FullLifecycleVisibleLoadingText = () => {
</div>
)
}

export const SynchronizedSpinners = () => (
<>
<div>
<Spinner />
</div>
<div>
<Spinner />
</div>
<Delay ms={1500}>
<div>
<Spinner />
</div>
</Delay>
<Delay ms={2500}>
<div>
<Spinner />
</div>
<div>
<Spinner />
</div>
</Delay>
</>
)

function Delay({children, ms}: {children: React.ReactNode; ms: number}) {
const [show, setShow] = useState(false)

useEffect(() => {
const timeout = setTimeout(() => setShow(true), ms)
return () => clearTimeout(timeout)
}, [ms])

return show ? <>{children}</> : null
}
10 changes: 0 additions & 10 deletions packages/react/src/Spinner/Spinner.module.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
.Box {
display: inline-flex;
}

@keyframes rotate-keyframes {
100% {
transform: rotate(360deg);
}
}

.SpinnerAnimation {
animation: rotate-keyframes var(--base-duration-1000) var(--base-easing-linear) infinite;
}
49 changes: 47 additions & 2 deletions packages/react/src/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {VisuallyHidden} from '../VisuallyHidden'
import type {HTMLDataAttributes} from '../internal/internal-types'
import {useId} from '../hooks'
import classes from './Spinner.module.css'
import {clsx} from 'clsx'
import {useCallback, useRef} from 'react'
import {useMedia} from '../hooks/useMedia'

const sizeMap = {
small: '16px',
Expand All @@ -30,6 +31,7 @@ function Spinner({
style,
...props
}: SpinnerProps) {
const animation = useSpinnerAnimation()
const size = sizeMap[sizeKey]
const hasHiddenLabel = srText !== null && ariaLabel === undefined
const labelId = useId()
Expand All @@ -38,14 +40,15 @@ function Spinner({
/* inline-flex removes the extra line height */
<span className={classes.Box}>
<svg
ref={animation}
height={size}
width={size}
viewBox="0 0 16 16"
fill="none"
aria-hidden
aria-label={ariaLabel ?? undefined}
aria-labelledby={hasHiddenLabel ? labelId : undefined}
className={clsx(className, classes.SpinnerAnimation)}
className={className}
style={style}
{...props}
>
Expand Down Expand Up @@ -73,4 +76,46 @@ function Spinner({

Spinner.displayName = 'Spinner'

function useSpinnerAnimation() {
const ref = useRef<Animation | null>(null)
const noMotionPreference = useMedia('(prefers-reduced-motion: no-preference)', true)
return useCallback(
(element: HTMLElement | SVGSVGElement | null) => {
if (!element) {
return
}

if (ref.current !== null) {
return
}

if (noMotionPreference) {
ref.current = element.animate(
[
{
transform: 'rotate(0deg)',
},
{
transform: 'rotate(360deg)',
},
],
{
// var(--base-duration-1000)
duration: 1000,
iterations: Infinity,
// var(--base-easing-linear)
easing: 'cubic-bezier(0,0,1,1)',
},
)

// Used to sync different animations. When all animations have the same
// startTime they will be in sync.
// @see https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime#syncing_different_animations
ref.current.startTime = 0
}
},
[noMotionPreference],
)
}

export default Spinner
Loading