Skip to content

Commit b2c28c7

Browse files
authored
Fix submit button loading state race (#3344)
Closes #3340 by switching from Motion to a CSS animation. The problem was that state internal to Motion was getting messed up when it got spammed with input. I had Codex figure out a Bezier easing that matches Motion's spring animation. https://github.com/user-attachments/assets/8c2356d8-728b-4dd0-b276-5ca6e9726243
1 parent 950767c commit b2c28c7

2 files changed

Lines changed: 42 additions & 16 deletions

File tree

app/ui/lib/Button.tsx

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

1211
import { type BadgeColor } from '@oxide/design-system/ui'
@@ -120,25 +119,19 @@ export const Button = ({
120119
{...rest}
121120
>
122121
{loading && (
123-
<m.span
124-
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
125-
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
126-
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
127-
className="absolute top-1/2 left-1/2 flex items-center justify-center"
128-
>
122+
<span className="button-spinner-in absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
129123
<Spinner variant={variantToBadgeColorMap[variant || 'primary']} />
130-
</m.span>
124+
</span>
131125
)}
132-
<m.span
133-
className={cn('flex items-center', innerClassName)}
134-
animate={{
135-
opacity: loading ? 0 : 1,
136-
y: loading ? 25 : 0,
137-
}}
138-
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
126+
<span
127+
className={cn(
128+
'button-content flex items-center',
129+
loading ? 'translate-y-[25px] opacity-0' : 'translate-y-0 opacity-100',
130+
innerClassName
131+
)}
139132
>
140133
{children}
141-
</m.span>
134+
</span>
142135
</button>
143136
</Wrap>
144137
)

app/ui/styles/components/button.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
.ox-button {
1010
@apply relative;
11+
/*
12+
* Shared by the spinner keyframes and the .button-content transition below.
13+
* Approximates Motion's { type: 'spring', duration: 0.3, bounce: 0 }.
14+
*/
15+
--button-loading-easing: cubic-bezier(0.35, 0.98, 0.35, 1);
1116

1217
&:after {
1318
content: '';
@@ -72,3 +77,31 @@
7277
.active-clicked:active:not(.visually-disabled) {
7378
@apply motion-safe:translate-y-px;
7479
}
80+
81+
/* Animating transform is deliberate: the spinner's centering utilities set the
82+
* separate translate property, which composes with transform instead of being
83+
* overridden by it. */
84+
@keyframes button-spinner-in {
85+
from {
86+
opacity: 0;
87+
transform: translateY(-25px);
88+
}
89+
to {
90+
opacity: 1;
91+
transform: translateY(0);
92+
}
93+
}
94+
95+
@media (prefers-reduced-motion: no-preference) {
96+
.ox-button .button-spinner-in {
97+
animation: button-spinner-in 0.3s var(--button-loading-easing);
98+
}
99+
100+
/* Button label; swaps out as the spinner drops in. Loading state is toggled
101+
* with utility classes in Button.tsx. */
102+
.ox-button .button-content {
103+
transition:
104+
opacity 0.3s var(--button-loading-easing),
105+
translate 0.3s var(--button-loading-easing);
106+
}
107+
}

0 commit comments

Comments
 (0)