diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx
index a0ec80ef7..d7b3262d0 100644
--- a/app/ui/lib/Button.tsx
+++ b/app/ui/lib/Button.tsx
@@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
-import * as m from 'motion/react-m'
import { type MouseEventHandler, type ReactNode } from 'react'
import { type BadgeColor } from '@oxide/design-system/ui'
@@ -120,25 +119,19 @@ export const Button = ({
{...rest}
>
{loading && (
-
+
-
+
)}
-
{children}
-
+
)
diff --git a/app/ui/styles/components/button.css b/app/ui/styles/components/button.css
index 4e0ba40b6..e9570f8b4 100644
--- a/app/ui/styles/components/button.css
+++ b/app/ui/styles/components/button.css
@@ -8,6 +8,11 @@
.ox-button {
@apply relative;
+ /*
+ * Shared by the spinner keyframes and the .button-content transition below.
+ * Approximates Motion's { type: 'spring', duration: 0.3, bounce: 0 }.
+ */
+ --button-loading-easing: cubic-bezier(0.35, 0.98, 0.35, 1);
&:after {
content: '';
@@ -72,3 +77,31 @@
.active-clicked:active:not(.visually-disabled) {
@apply motion-safe:translate-y-px;
}
+
+/* Animating transform is deliberate: the spinner's centering utilities set the
+ * separate translate property, which composes with transform instead of being
+ * overridden by it. */
+@keyframes button-spinner-in {
+ from {
+ opacity: 0;
+ transform: translateY(-25px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .ox-button .button-spinner-in {
+ animation: button-spinner-in 0.3s var(--button-loading-easing);
+ }
+
+ /* Button label; swaps out as the spinner drops in. Loading state is toggled
+ * with utility classes in Button.tsx. */
+ .ox-button .button-content {
+ transition:
+ opacity 0.3s var(--button-loading-easing),
+ translate 0.3s var(--button-loading-easing);
+ }
+}