feat(ui): add loading state to Button and ButtonIcon components

Add optional `loading` prop to both Button and ButtonIcon components that displays
a spinner and disables interaction while async operations are in progress.

Implementation details:
- Uses React Aria's ProgressBar with indeterminate state for spinner
- Displays RiLoader4Line icon with smooth rotation animation
- Wraps content in container with opacity transition when loading
- Accessible via aria-label "Loading" on progress indicator
- Component definitions updated with content, spinner class names and loading data attribute

Documentation:
- Added Loading examples to both Button and ButtonIcon docs showing interactive demos
- Updated props tables with loading boolean prop
- Created Storybook stories covering all variants, sizes, and with icons

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-11-07 09:57:23 +01:00
parent 0f43bad993
commit 7839e7bd9e
15 changed files with 392 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations.
+13
View File
@@ -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.
<Snippet
align="center"
py={4}
open
preview={<ButtonIconSnippet story="Loading" />}
code={buttonIconLoadingSnippet}
/>
### Responsive
Here's a view when buttons are responsive.
+3
View File
@@ -19,6 +19,7 @@ export const buttonIconPropDefs: Record<string, PropDef> = {
},
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 = `<Flex align="center">
export const buttonIconDisabledSnippet = `<ButtonIcon icon={<Icon name="cloud" />} isDisabled />`;
export const buttonIconLoadingSnippet = `<ButtonIcon icon={<Icon name="cloud" />} variant="primary" loading={isLoading} onPress={handleClick} />`;
export const buttonIconResponsiveSnippet = `<ButtonIcon icon={<Icon name="cloud" />} variant={{ initial: 'primary', lg: 'secondary' }} />`;
export const buttonIconAsLinkSnippet = `import { ButtonLink } from '@backstage/ui';
+13
View File
@@ -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.
<Snippet
align="center"
py={4}
open
preview={<ButtonSnippet story="Loading" />}
code={buttonLoadingSnippet}
/>
### Responsive
Here's a view when buttons are responsive.
+5
View File
@@ -17,6 +17,7 @@ export const buttonPropDefs: Record<string, PropDef> = {
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 = `<Button variant={{ initial: 'primary', l
Responsive Button
</Button>`;
export const buttonLoadingSnippet = `<Button variant="primary" loading={isLoading} onPress={handleClick}>
Load more items
</Button>`;
export const buttonAsLinkSnippet = `import { ButtonLink } from '@backstage/ui';
<ButtonLink href="https://ui.backstage.io" target="_blank">
+9
View File
@@ -187,6 +187,8 @@ export interface ButtonIconProps extends ButtonProps_2 {
// (undocumented)
icon?: ReactElement;
// (undocumented)
loading?: boolean;
// (undocumented)
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
// (undocumented)
variant?:
@@ -228,6 +230,8 @@ export interface ButtonProps extends ButtonProps_2 {
// (undocumented)
iconStart?: ReactElement;
// (undocumented)
loading?: boolean;
// (undocumented)
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
// (undocumented)
variant?:
@@ -418,15 +422,20 @@ export const componentDefinitions: {
readonly Button: {
readonly classNames: {
readonly root: 'bui-Button';
readonly content: 'bui-ButtonContent';
readonly spinner: 'bui-ButtonSpinner';
};
readonly dataAttributes: {
readonly size: readonly ['small', 'medium', 'large'];
readonly variant: readonly ['primary', 'secondary', 'tertiary'];
readonly loading: readonly [true, false];
};
};
readonly ButtonIcon: {
readonly classNames: {
readonly root: 'bui-ButtonIcon';
readonly content: 'bui-ButtonIconContent';
readonly spinner: 'bui-ButtonIconSpinner';
};
};
readonly ButtonLink: {
@@ -18,22 +18,27 @@
@layer components {
.bui-Button {
border: none;
--loading-duration: 200ms;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
user-select: none;
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-bold);
padding: 0;
cursor: pointer;
border-radius: var(--bui-radius-2);
gap: var(--bui-space-1_5);
flex-shrink: 0;
transition: background-color var(--loading-duration) ease-out,
box-shadow var(--loading-duration) ease-out;
&[data-disabled='true'] {
cursor: not-allowed;
}
&[data-loading='true'] {
cursor: wait;
}
}
.bui-Button[data-variant='primary'] {
@@ -54,7 +59,8 @@
outline-offset: 2px;
}
&[data-disabled='true'] {
&[data-disabled='true'],
&[data-loading='true'] {
background-color: var(--bui-bg-solid-disabled);
color: var(--bui-fg-solid-disabled);
}
@@ -80,7 +86,8 @@
box-shadow: inset 0 0 0 2px var(--bui-ring);
}
&[data-disabled='true'] {
&[data-disabled='true'],
&[data-loading='true'] {
box-shadow: inset 0 0 0 1px var(--bui-border-disabled);
color: var(--bui-fg-disabled);
}
@@ -105,31 +112,92 @@
box-shadow: inset 0 0 0 2px var(--bui-ring);
}
&[data-disabled='true'] {
&[data-disabled='true'],
&[data-loading='true'] {
background-color: transparent;
color: var(--bui-fg-disabled);
}
}
.bui-Button[data-size='small'] {
font-size: var(--bui-font-size-3);
padding: 0 var(--bui-space-2);
height: 2rem;
svg {
width: 1rem;
height: 1rem;
}
}
.bui-Button[data-size='medium'] {
font-size: var(--bui-font-size-4);
padding: 0 var(--bui-space-3);
height: 2.5rem;
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bui-Button[data-size='small'] {
font-size: var(--bui-font-size-3);
padding: 0 var(--bui-space-2);
height: 2rem;
.bui-ButtonContent {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--bui-space-1_5);
height: 100%;
width: 100%;
transition: opacity var(--loading-duration) ease-out;
.bui-Button[data-loading='true'] & {
opacity: 0;
}
}
.bui-Button[data-size='small'] svg {
width: 1rem;
height: 1rem;
.bui-ButtonSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
opacity: 0;
transition: opacity var(--loading-duration) ease-in;
.bui-Button[data-loading='true'] & {
opacity: 1;
}
& svg {
animation: bui-spin 1s linear infinite;
}
}
.bui-Button[data-size='medium'] svg {
width: 1.25rem;
height: 1.25rem;
@media (prefers-reduced-motion: reduce) {
.bui-Button {
transition-duration: 50ms;
}
.bui-ButtonContent {
transition-duration: 50ms;
}
.bui-ButtonSpinner {
transition-duration: 50ms;
}
.bui-ButtonSpinner svg {
animation: none;
}
}
@keyframes bui-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
@@ -19,6 +19,7 @@ import { Button } from './Button';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react';
import { useState } from 'react';
const meta = {
title: 'Backstage UI/Button',
@@ -212,3 +213,80 @@ export const Playground: Story = {
</Flex>
),
};
export const Loading: Story = {
render: () => {
const [isLoading, setIsLoading] = useState(false);
const handleClick = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
return (
<Button variant="primary" loading={isLoading} onPress={handleClick}>
Load more items
</Button>
);
},
};
export const LoadingVariants: Story = {
render: () => (
<Flex direction="column" gap="4">
<Text>Primary</Text>
<Flex align="center" gap="4">
<Button variant="primary" size="small" loading>
Small Loading
</Button>
<Button variant="primary" size="medium" loading>
Medium Loading
</Button>
<Button variant="primary" loading iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Secondary</Text>
<Flex align="center" gap="4">
<Button variant="secondary" size="small" loading>
Small Loading
</Button>
<Button variant="secondary" size="medium" loading>
Medium Loading
</Button>
<Button variant="secondary" loading iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Tertiary</Text>
<Flex align="center" gap="4">
<Button variant="tertiary" size="small" loading>
Small Loading
</Button>
<Button variant="tertiary" size="medium" loading>
Medium Loading
</Button>
<Button variant="tertiary" loading iconStart={<RiCloudLine />}>
With Icon
</Button>
</Flex>
<Text>Loading vs Disabled</Text>
<Flex align="center" gap="4">
<Button variant="primary" loading>
Loading
</Button>
<Button variant="primary" isDisabled>
Disabled
</Button>
<Button variant="primary" loading isDisabled>
Both (Disabled Wins)
</Button>
</Flex>
</Flex>
),
};
+26 -5
View File
@@ -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 (
<RAButton
className={clsx(classNames.root, styles[classNames.root], className)}
ref={ref}
isPending={loading}
{...dataAttributes}
{...rest}
>
{iconStart}
{children}
{iconEnd}
{({ isPending }) => (
<>
<span
className={clsx(classNames.content, styles[classNames.content])}
>
{iconStart}
{children}
{iconEnd}
</span>
{isPending && (
<ProgressBar
aria-label="Loading"
isIndeterminate
className={clsx(classNames.spinner, styles[classNames.spinner])}
>
<RiLoader4Line aria-hidden="true" />
</ProgressBar>
)}
</>
)}
</RAButton>
);
},
@@ -33,4 +33,5 @@ export interface ButtonProps extends RAButtonProps {
iconStart?: ReactElement;
iconEnd?: ReactElement;
children?: ReactNode;
loading?: boolean;
}
@@ -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 => <ButtonIcon {...args} icon={<RiCloudLine />} />,
};
export const Loading: Story = {
render: () => {
const [isLoading, setIsLoading] = useState(false);
const handleClick = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
return (
<ButtonIcon
variant="primary"
icon={<RiCloudLine />}
loading={isLoading}
onPress={handleClick}
/>
);
},
};
export const LoadingVariants: Story = {
render: () => (
<Flex direction="column" gap="4">
<Text>Primary</Text>
<Flex align="center" gap="4">
<ButtonIcon
variant="primary"
size="small"
icon={<RiCloudLine />}
loading
/>
<ButtonIcon
variant="primary"
size="medium"
icon={<RiCloudLine />}
loading
/>
</Flex>
<Text>Secondary</Text>
<Flex align="center" gap="4">
<ButtonIcon
variant="secondary"
size="small"
icon={<RiCloudLine />}
loading
/>
<ButtonIcon
variant="secondary"
size="medium"
icon={<RiCloudLine />}
loading
/>
</Flex>
<Text>Tertiary</Text>
<Flex align="center" gap="4">
<ButtonIcon
variant="tertiary"
size="small"
icon={<RiCloudLine />}
loading
/>
<ButtonIcon
variant="tertiary"
size="medium"
icon={<RiCloudLine />}
loading
/>
</Flex>
<Text>Loading vs Disabled</Text>
<Flex align="center" gap="4">
<ButtonIcon variant="primary" icon={<RiCloudLine />} loading />
<ButtonIcon variant="primary" icon={<RiCloudLine />} isDisabled />
<ButtonIcon
variant="primary"
icon={<RiCloudLine />}
loading
isDisabled
/>
</Flex>
</Flex>
),
};
@@ -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 (
<RAButton
@@ -45,10 +46,41 @@ export const ButtonIcon = forwardRef(
className,
)}
ref={ref}
isPending={loading}
{...dataAttributes}
{...rest}
>
{icon}
{({ isPending }) => (
<>
<span
className={clsx(
classNames.content,
classNamesButtonIcon.content,
stylesButton[classNames.content],
stylesButtonIcon[classNamesButtonIcon.content],
className,
)}
>
{icon}
</span>
{isPending && (
<ProgressBar
aria-label="Loading"
isIndeterminate
className={clsx(
classNames.spinner,
classNamesButtonIcon.spinner,
stylesButton[classNames.spinner],
stylesButtonIcon[classNamesButtonIcon.spinner],
className,
)}
>
<RiLoader4Line aria-hidden="true" />
</ProgressBar>
)}
</>
)}
</RAButton>
);
},
@@ -31,4 +31,5 @@ export interface ButtonIconProps extends RAButtonProps {
| 'tertiary'
| Partial<Record<Breakpoint, 'primary' | 'secondary' | 'tertiary'>>;
icon?: ReactElement;
loading?: boolean;
}
@@ -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 (
<RALink
className={clsx(
classNames.root,
classNamesButtonLink.root,
stylesButton[classNames.root],
className,
)}
ref={ref}
{...dataAttributes}
href={href}
{...rest}
const linkButton = (
<RALink
className={clsx(
classNames.root,
classNamesButtonLink.root,
stylesButton[classNames.root],
className,
)}
ref={ref}
{...dataAttributes}
href={href}
{...rest}
>
<span
className={clsx(classNames.content, stylesButton[classNames.content])}
>
{iconStart}
{children}
{iconEnd}
</RALink>
);
</span>
</RALink>
);
// If it's an external link, render RALink without RouterProvider
if (isExternal) {
return linkButton;
}
// For internal links, use RouterProvider
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<RALink
className={clsx(
classNames.root,
classNamesButtonLink.root,
stylesButton[classNames.root],
className,
)}
ref={ref}
{...dataAttributes}
href={href}
{...rest}
>
{iconStart}
{children}
{iconEnd}
</RALink>
{linkButton}
</RouterProvider>
);
},
@@ -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: {