diff --git a/.changeset/true-taxes-cover.md b/.changeset/true-taxes-cover.md
new file mode 100644
index 0000000000..ec204084d0
--- /dev/null
+++ b/.changeset/true-taxes-cover.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations.
diff --git a/docs-ui/src/content/button-icon.mdx b/docs-ui/src/content/button-icon.mdx
index e1f27e0601..fbc0c8c54c 100644
--- a/docs-ui/src/content/button-icon.mdx
+++ b/docs-ui/src/content/button-icon.mdx
@@ -9,6 +9,7 @@ import {
buttonIconVariantsSnippet,
buttonIconSizesSnippet,
buttonIconDisabledSnippet,
+ buttonIconLoadingSnippet,
buttonIconResponsiveSnippet,
buttonIconAsLinkSnippet,
} from './button-icon.props';
@@ -74,6 +75,18 @@ Here's a view when buttons are disabled.
code={buttonIconDisabledSnippet}
/>
+### Loading
+
+Here's a view when buttons are in a loading state.
+
+}
+ code={buttonIconLoadingSnippet}
+/>
+
### Responsive
Here's a view when buttons are responsive.
diff --git a/docs-ui/src/content/button-icon.props.ts b/docs-ui/src/content/button-icon.props.ts
index 2ba7372a9c..b063484d27 100644
--- a/docs-ui/src/content/button-icon.props.ts
+++ b/docs-ui/src/content/button-icon.props.ts
@@ -19,6 +19,7 @@ export const buttonIconPropDefs: Record = {
},
icon: { type: 'enum', values: ['ReactNode'], responsive: false },
isDisabled: { type: 'boolean', default: 'false', responsive: false },
+ loading: { type: 'boolean', default: 'false', responsive: false },
type: {
type: 'enum',
values: ['button', 'submit', 'reset'],
@@ -50,6 +51,8 @@ export const buttonIconSizesSnippet = `
export const buttonIconDisabledSnippet = `} isDisabled />`;
+export const buttonIconLoadingSnippet = `} variant="primary" loading={isLoading} onPress={handleClick} />`;
+
export const buttonIconResponsiveSnippet = `} variant={{ initial: 'primary', lg: 'secondary' }} />`;
export const buttonIconAsLinkSnippet = `import { ButtonLink } from '@backstage/ui';
diff --git a/docs-ui/src/content/button.mdx b/docs-ui/src/content/button.mdx
index a25115012b..b60f32ff07 100644
--- a/docs-ui/src/content/button.mdx
+++ b/docs-ui/src/content/button.mdx
@@ -9,6 +9,7 @@ import {
buttonSizesSnippet,
buttonIconsSnippet,
buttonDisabledSnippet,
+ buttonLoadingSnippet,
buttonResponsiveSnippet,
buttonAsLinkSnippet,
} from './button.props';
@@ -86,6 +87,18 @@ Here's a view when buttons are disabled.
code={buttonDisabledSnippet}
/>
+### Loading
+
+Here's a view when buttons are in a loading state.
+
+}
+ code={buttonLoadingSnippet}
+/>
+
### Responsive
Here's a view when buttons are responsive.
diff --git a/docs-ui/src/content/button.props.ts b/docs-ui/src/content/button.props.ts
index a3bb2659bf..e650a9f2e9 100644
--- a/docs-ui/src/content/button.props.ts
+++ b/docs-ui/src/content/button.props.ts
@@ -17,6 +17,7 @@ export const buttonPropDefs: Record = {
iconStart: { type: 'enum', values: ['ReactNode'], responsive: false },
iconEnd: { type: 'enum', values: ['ReactNode'], responsive: false },
isDisabled: { type: 'boolean', default: 'false', responsive: false },
+ loading: { type: 'boolean', default: 'false', responsive: false },
children: { type: 'enum', values: ['ReactNode'], responsive: false },
type: {
type: 'enum',
@@ -65,6 +66,10 @@ export const buttonResponsiveSnippet = `
),
};
+
+export const Loading: Story = {
+ render: () => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = () => {
+ setIsLoading(true);
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 3000);
+ };
+
+ return (
+
+ Load more items
+
+ );
+ },
+};
+
+export const LoadingVariants: Story = {
+ render: () => (
+
+ Primary
+
+
+ Small Loading
+
+
+ Medium Loading
+
+ }>
+ With Icon
+
+
+
+ Secondary
+
+
+ Small Loading
+
+
+ Medium Loading
+
+ }>
+ With Icon
+
+
+
+ Tertiary
+
+
+ Small Loading
+
+
+ Medium Loading
+
+ }>
+ With Icon
+
+
+
+ Loading vs Disabled
+
+
+ Loading
+
+
+ Disabled
+
+
+ Both (Disabled Wins)
+
+
+
+ ),
+};
diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx
index d22a3036e6..45228ff6ef 100644
--- a/packages/ui/src/components/Button/Button.tsx
+++ b/packages/ui/src/components/Button/Button.tsx
@@ -16,7 +16,8 @@
import clsx from 'clsx';
import { forwardRef, Ref } from 'react';
-import { Button as RAButton } from 'react-aria-components';
+import { Button as RAButton, ProgressBar } from 'react-aria-components';
+import { RiLoader4Line } from '@remixicon/react';
import type { ButtonProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import styles from './Button.module.css';
@@ -30,18 +31,38 @@ export const Button = forwardRef(
...props,
});
- const { children, className, iconStart, iconEnd, ...rest } = cleanedProps;
+ const { children, className, iconStart, iconEnd, loading, ...rest } =
+ cleanedProps;
return (
- {iconStart}
- {children}
- {iconEnd}
+ {({ isPending }) => (
+ <>
+
+ {iconStart}
+ {children}
+ {iconEnd}
+
+
+ {isPending && (
+
+
+
+ )}
+ >
+ )}
);
},
diff --git a/packages/ui/src/components/Button/types.ts b/packages/ui/src/components/Button/types.ts
index cbe5bd3608..daeba7653f 100644
--- a/packages/ui/src/components/Button/types.ts
+++ b/packages/ui/src/components/Button/types.ts
@@ -33,4 +33,5 @@ export interface ButtonProps extends RAButtonProps {
iconStart?: ReactElement;
iconEnd?: ReactElement;
children?: ReactNode;
+ loading?: boolean;
}
diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
index c1a908119f..97072c3664 100644
--- a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
+++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx
@@ -17,7 +17,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ButtonIcon } from './ButtonIcon';
import { Flex } from '../Flex';
+import { Text } from '../Text';
import { RiCloudLine } from '@remixicon/react';
+import { useState } from 'react';
const meta = {
title: 'Backstage UI/ButtonIcon',
@@ -83,3 +85,91 @@ export const Responsive: Story = {
},
render: args => } />,
};
+
+export const Loading: Story = {
+ render: () => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClick = () => {
+ setIsLoading(true);
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 3000);
+ };
+
+ return (
+ }
+ loading={isLoading}
+ onPress={handleClick}
+ />
+ );
+ },
+};
+
+export const LoadingVariants: Story = {
+ render: () => (
+
+ Primary
+
+ }
+ loading
+ />
+ }
+ loading
+ />
+
+
+ Secondary
+
+ }
+ loading
+ />
+ }
+ loading
+ />
+
+
+ Tertiary
+
+ }
+ loading
+ />
+ }
+ loading
+ />
+
+
+ Loading vs Disabled
+
+ } loading />
+ } isDisabled />
+ }
+ loading
+ isDisabled
+ />
+
+
+ ),
+};
diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx
index 2e6a96c4c1..fecccd6a37 100644
--- a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx
+++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx
@@ -16,7 +16,8 @@
import clsx from 'clsx';
import { forwardRef, Ref } from 'react';
-import { Button as RAButton } from 'react-aria-components';
+import { Button as RAButton, ProgressBar } from 'react-aria-components';
+import { RiLoader4Line } from '@remixicon/react';
import type { ButtonIconProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import stylesButtonIcon from './ButtonIcon.module.css';
@@ -33,7 +34,7 @@ export const ButtonIcon = forwardRef(
const { classNames: classNamesButtonIcon } = useStyles('ButtonIcon');
- const { className, icon, ...rest } = cleanedProps;
+ const { className, icon, loading, ...rest } = cleanedProps;
return (
- {icon}
+ {({ isPending }) => (
+ <>
+
+ {icon}
+
+
+ {isPending && (
+
+
+
+ )}
+ >
+ )}
);
},
diff --git a/packages/ui/src/components/ButtonIcon/types.ts b/packages/ui/src/components/ButtonIcon/types.ts
index bc358b6d7d..6af3d12361 100644
--- a/packages/ui/src/components/ButtonIcon/types.ts
+++ b/packages/ui/src/components/ButtonIcon/types.ts
@@ -31,4 +31,5 @@ export interface ButtonIconProps extends RAButtonProps {
| 'tertiary'
| Partial>;
icon?: ReactElement;
+ loading?: boolean;
}
diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx
index 70d216b3d8..28a67105bb 100644
--- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx
+++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx
@@ -41,47 +41,38 @@ export const ButtonLink = forwardRef(
const isExternal = isExternalLink(href);
- // If it's an external link, render RALink without RouterProvider
- if (isExternal) {
- return (
-
+
{iconStart}
{children}
{iconEnd}
-
- );
+
+
+ );
+
+ // If it's an external link, render RALink without RouterProvider
+ if (isExternal) {
+ return linkButton;
}
// For internal links, use RouterProvider
return (
-
- {iconStart}
- {children}
- {iconEnd}
-
+ {linkButton}
);
},
diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts
index 526340e4be..cc45dbefe1 100644
--- a/packages/ui/src/utils/componentDefinitions.ts
+++ b/packages/ui/src/utils/componentDefinitions.ts
@@ -63,15 +63,20 @@ export const componentDefinitions = {
Button: {
classNames: {
root: 'bui-Button',
+ content: 'bui-ButtonContent',
+ spinner: 'bui-ButtonSpinner',
},
dataAttributes: {
size: ['small', 'medium', 'large'] as const,
variant: ['primary', 'secondary', 'tertiary'] as const,
+ loading: [true, false] as const,
},
},
ButtonIcon: {
classNames: {
root: 'bui-ButtonIcon',
+ content: 'bui-ButtonIconContent',
+ spinner: 'bui-ButtonIconSpinner',
},
},
ButtonLink: {