Skip to content

Commit b57531d

Browse files
Updated – Switching from @react-spring/web to motion (#2646)
* Switch from `react-spring` to `motion` * Handle reduced motion automatically * Improved close icon alignment and hover state * actually lazy load the animation code. 18kb gz savings --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent b55e161 commit b57531d

File tree

16 files changed

+329
-348
lines changed

16 files changed

+329
-348
lines changed

app/components/RoundedSector.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { useReducedMotion } from 'motion/react'
89
import { useEffect, useMemo, useState } from 'react'
910

10-
import { useReducedMotion } from '~/hooks/use-reduce-motion'
11-
1211
export function RoundedSector({
1312
angle,
1413
size,

app/components/ToastStack.tsx

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,39 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { animated, useTransition } from '@react-spring/web'
8+
import { AnimatePresence } from 'motion/react'
9+
import * as m from 'motion/react-m'
910

1011
import { removeToast, useToastStore } from '~/stores/toast'
1112
import { Toast } from '~/ui/lib/Toast'
1213

1314
export function ToastStack() {
1415
const toasts = useToastStore((state) => state.toasts)
1516

16-
const transition = useTransition(toasts, {
17-
keys: (toast) => toast.id,
18-
from: { opacity: 0, y: 10, scale: 95 },
19-
enter: { opacity: 1, y: 0, scale: 100 },
20-
leave: { opacity: 0, y: 10, scale: 95 },
21-
config: { duration: 100 },
22-
})
23-
2417
return (
2518
<div
2619
className="pointer-events-auto fixed bottom-4 left-4 z-toast flex flex-col items-end space-y-2"
2720
data-testid="Toasts"
2821
>
29-
{transition((style, item) => (
30-
<animated.div
31-
style={{
32-
opacity: style.opacity,
33-
y: style.y,
34-
transform: style.scale.to((val) => `scale(${val}%, ${val}%)`),
35-
}}
36-
>
37-
<Toast
38-
key={item.id}
39-
{...item.options}
40-
onClose={() => {
41-
removeToast(item.id)
42-
item.options.onClose?.()
43-
}}
44-
/>
45-
</animated.div>
46-
))}
22+
<AnimatePresence>
23+
{toasts.map((toast) => (
24+
<m.div
25+
key={toast.id}
26+
initial={{ opacity: 0, y: 20, scale: 0.95 }}
27+
animate={{ opacity: 1, y: 0, scale: 1 }}
28+
exit={{ opacity: 0, y: 20, scale: 0.95 }}
29+
transition={{ type: 'spring', duration: 0.2, bounce: 0 }}
30+
>
31+
<Toast
32+
{...toast.options}
33+
onClose={() => {
34+
removeToast(toast.id)
35+
toast.options.onClose?.()
36+
}}
37+
/>
38+
</m.div>
39+
))}
40+
</AnimatePresence>
4741
</div>
4842
)
4943
}

app/hooks/use-reduce-motion.tsx

Lines changed: 0 additions & 38 deletions
This file was deleted.

app/main.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { QueryClientProvider } from '@tanstack/react-query'
9+
import { LazyMotion, MotionConfig } from 'motion/react'
910
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
1011
import { StrictMode } from 'react'
1112
import { createRoot } from 'react-dom/client'
@@ -16,7 +17,6 @@ import { queryClient } from '@oxide/api'
1617

1718
import { ConfirmActionModal } from './components/ConfirmActionModal'
1819
import { ErrorBoundary } from './components/ErrorBoundary'
19-
import { ReduceMotion } from './hooks/use-reduce-motion'
2020
// stripped out by rollup in production
2121
import { startMockAPI } from './msw-mock-api'
2222
import { routes } from './routes'
@@ -33,6 +33,8 @@ if (process.env.SHA) {
3333
)
3434
}
3535

36+
const loadFeatures = () => import('./util/motion-features').then((res) => res.domAnimation)
37+
3638
const root = createRoot(document.getElementById('root')!)
3739

3840
function render() {
@@ -46,12 +48,15 @@ function render() {
4648
root.render(
4749
<StrictMode>
4850
<QueryClientProvider client={queryClient}>
49-
<ErrorBoundary>
50-
<ConfirmActionModal />
51-
<SkipLink id="skip-nav" />
52-
<ReduceMotion />
53-
<RouterProvider router={router} />
54-
</ErrorBoundary>
51+
<LazyMotion strict features={loadFeatures}>
52+
<MotionConfig reducedMotion="user">
53+
<ErrorBoundary>
54+
<ConfirmActionModal />
55+
<SkipLink id="skip-nav" />
56+
<RouterProvider router={router} />
57+
</ErrorBoundary>
58+
</MotionConfig>
59+
</LazyMotion>
5560
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
5661
</QueryClientProvider>
5762
</StrictMode>

app/ui/lib/Button.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import cn from 'classnames'
9+
import * as m from 'motion/react-m'
910
import { forwardRef, type MouseEventHandler, type ReactNode } from 'react'
1011

1112
import { Spinner } from '~/ui/lib/Spinner'
@@ -90,9 +91,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
9091
with={<Tooltip content={disabledReason} ref={ref} placement="bottom" />}
9192
>
9293
<button
93-
className={cn(buttonStyle({ size, variant }), className, {
94-
'visually-disabled': isDisabled,
95-
})}
94+
className={cn(
95+
buttonStyle({ size, variant }),
96+
className,
97+
{
98+
'visually-disabled': isDisabled,
99+
},
100+
'overflow-hidden'
101+
)}
96102
ref={ref}
97103
/* eslint-disable-next-line react/button-has-type */
98104
type={type}
@@ -101,10 +107,26 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
101107
aria-disabled={isDisabled}
102108
{...rest}
103109
>
104-
{loading && <Spinner className="absolute" variant={variant} />}
105-
<span className={cn('flex items-center', innerClassName, { invisible: loading })}>
110+
{loading && (
111+
<m.span
112+
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
113+
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
114+
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
115+
className="absolute left-1/2 top-1/2"
116+
>
117+
<Spinner variant={variant} />
118+
</m.span>
119+
)}
120+
<m.span
121+
className={cn('flex items-center', innerClassName)}
122+
animate={{
123+
opacity: loading ? 0 : 1,
124+
y: loading ? 25 : 0,
125+
}}
126+
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
127+
>
106128
{children}
107-
</span>
129+
</m.span>
108130
</button>
109131
</Wrap>
110132
)

app/ui/lib/CopyToClipboard.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
import { animated, config, useTransition } from '@react-spring/web'
109
import cn from 'classnames'
10+
import { AnimatePresence } from 'motion/react'
11+
import * as m from 'motion/react-m'
1112
import { useState } from 'react'
1213

1314
import { Copy12Icon, Success12Icon } from '@oxide/design-system/icons/react'
@@ -20,6 +21,11 @@ type Props = {
2021
className?: string
2122
}
2223

24+
const variants = {
25+
hidden: { opacity: 0, scale: 0.75 },
26+
visible: { opacity: 1, scale: 1 },
27+
}
28+
2329
export const CopyToClipboard = ({
2430
ariaLabel = 'Click to copy',
2531
text,
@@ -35,14 +41,14 @@ export const CopyToClipboard = ({
3541
})
3642
}
3743

38-
const transitions = useTransition(hasCopied, {
39-
from: { opacity: 0, transform: 'scale(0.8)' },
40-
enter: { opacity: 1, transform: 'scale(1)' },
41-
leave: { opacity: 0, transform: 'scale(0.8)' },
42-
config: config.stiff,
43-
trail: 100,
44-
initial: null,
45-
})
44+
const animateProps = {
45+
className: 'absolute inset-0 flex items-center justify-center',
46+
variants,
47+
initial: 'hidden',
48+
animate: 'visible',
49+
exit: 'hidden',
50+
transition: { type: 'spring', duration: 0.2, bounce: 0 },
51+
}
4652

4753
return (
4854
<button
@@ -58,14 +64,17 @@ export const CopyToClipboard = ({
5864
type="button"
5965
aria-label={hasCopied ? 'Copied' : ariaLabel}
6066
>
61-
{transitions((styles, item) => (
62-
<animated.div
63-
style={styles}
64-
className="absolute inset-0 flex items-center justify-center"
65-
>
66-
{item ? <Success12Icon /> : <Copy12Icon />}
67-
</animated.div>
68-
))}
67+
<AnimatePresence mode="wait" initial={false}>
68+
{hasCopied ? (
69+
<m.span key="checkmark" {...animateProps}>
70+
<Success12Icon />
71+
</m.span>
72+
) : (
73+
<m.span key="copy" {...animateProps}>
74+
<Copy12Icon />
75+
</m.span>
76+
)}
77+
</AnimatePresence>
6978
</button>
7079
)
7180
}

app/ui/lib/DialogOverlay.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import * as m from 'motion/react-m'
910
import { forwardRef } from 'react'
1011

1112
export const DialogOverlay = forwardRef<HTMLDivElement>((_, ref) => (
12-
<div
13+
<m.div
1314
ref={ref}
1415
aria-hidden
1516
className="fixed inset-0 z-modalOverlay overflow-auto bg-scrim"
17+
initial={{ opacity: 0 }}
18+
animate={{ opacity: 1 }}
19+
exit={{ opacity: 0 }}
20+
transition={{ duration: 0.15, ease: 'easeOut' }}
1621
/>
1722
))

0 commit comments

Comments
 (0)