feat(ui): add danger variant to Button component

Added a `danger` variant for destructive actions like delete or remove.
Uses solid red background with white text in both light and dark modes.

- Added CSS styles with custom properties for future token migration
- Updated Storybook stories to include danger variant examples
- Updated docs-ui documentation to reflect the new variant

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-01-28 16:27:01 +01:00
parent 87da9c08b4
commit 2c219b9817
10 changed files with 322 additions and 12 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `destructive` prop to Button for dangerous actions like delete or remove. Works with all variants (primary, secondary, tertiary).
**Affected components:** Button
@@ -59,6 +59,22 @@ export const Disabled = () => {
);
};
export const Destructive = () => {
return (
<Flex gap="4">
<Button variant="primary" destructive>
Primary
</Button>
<Button variant="secondary" destructive>
Secondary
</Button>
<Button variant="tertiary" destructive>
Tertiary
</Button>
</Flex>
);
};
export const Loading = () => {
return (
<Button variant="primary" loading={true}>
+16 -1
View File
@@ -7,6 +7,7 @@ import {
sizesSnippet,
withIconsSnippet,
disabledSnippet,
destructiveSnippet,
loadingSnippet,
asLinkSnippet,
buttonSnippetUsage,
@@ -22,6 +23,7 @@ import {
Sizes,
WithIcons,
Disabled,
Destructive,
Loading,
AsLink,
} from './components';
@@ -32,7 +34,7 @@ export const reactAriaUrls = {
<PageTitle
title="Button"
description="A button with primary, secondary, and tertiary variants and loading state."
description="A button with primary, secondary, and tertiary variants, destructive styling, and loading state."
/>
<Snippet align="center" py={4} preview={<Variants />} code={variantsSnippet} />
@@ -95,6 +97,19 @@ Icons can appear before or after the label.
layout="side-by-side"
/>
### Destructive
Use the `destructive` prop for dangerous actions like delete or remove.
<Snippet
align="center"
py={4}
open
preview={<Destructive />}
code={destructiveSnippet}
layout="side-by-side"
/>
### Loading
Shows a spinner and disables interaction during async operations.
@@ -16,6 +16,12 @@ export const buttonPropDefs: Record<string, PropDef> = {
</>
),
},
destructive: {
type: 'boolean',
default: 'false',
description:
'Applies destructive styling for dangerous actions like delete or remove.',
},
size: {
type: 'enum',
values: ['small', 'medium'],
@@ -43,6 +43,18 @@ export const disabledSnippet = `<Flex gap="4">
</Button>
</Flex>`;
export const destructiveSnippet = `<Flex gap="4">
<Button variant="primary" destructive>
Primary
</Button>
<Button variant="secondary" destructive>
Secondary
</Button>
<Button variant="tertiary" destructive>
Tertiary
</Button>
</Flex>`;
export const loadingSnippet = `<Button variant="primary" loading={true}>
Load more items
</Button>`;
+4
View File
@@ -330,6 +330,9 @@ export const ButtonDefinition: {
readonly dataAttribute: true;
readonly default: 'primary';
};
readonly destructive: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
@@ -445,6 +448,7 @@ export interface ButtonLinkProps
export type ButtonOwnProps = LeafSurfaceProps & {
size?: Responsive<'small' | 'medium'>;
variant?: Responsive<'primary' | 'secondary' | 'tertiary'>;
destructive?: boolean;
iconStart?: ReactElement;
iconEnd?: ReactElement;
loading?: boolean;
@@ -79,6 +79,39 @@
}
}
.bui-Button[data-variant='primary'][data-destructive='true'] {
/* Custom properties matching future token names (without bui- prefix) */
--bg-solid-danger: #dc2626;
--bg-solid-danger-hover: #b91c1c;
--bg-solid-danger-pressed: #991b1b;
--bg-solid-danger-disabled: #fca5a5;
--fg-solid-danger: var(--bui-white);
[data-theme-mode='dark'] & {
--bg-solid-danger: #ef4444;
--bg-solid-danger-hover: #dc2626;
--bg-solid-danger-pressed: #b91c1c;
--bg-solid-danger-disabled: #7f1d1d;
}
--bg: var(--bg-solid-danger);
--bg-hover: var(--bg-solid-danger-hover);
--bg-active: var(--bg-solid-danger-pressed);
--fg: var(--fg-solid-danger);
&[data-disabled='true'],
&[data-loading='true'] {
--bg: var(--bg-solid-danger-disabled);
--bg-hover: var(--bg-solid-danger-disabled);
--bg-active: var(--bg-solid-danger-disabled);
}
&:focus-visible {
outline: 2px solid var(--bui-border-danger);
outline-offset: 2px;
}
}
.bui-Button[data-variant='secondary'] {
--bg: var(--bui-bg-neutral-on-surface-0);
--bg-hover: var(--bui-bg-neutral-on-surface-0-hover);
@@ -117,6 +150,33 @@
}
}
.bui-Button[data-variant='secondary'][data-destructive='true'] {
/* Custom properties for hover/active states (no tokens exist yet) */
--bg-danger-hover: #fecaca;
--bg-danger-pressed: #fca5a5;
[data-theme-mode='dark'] & {
--bg-danger-hover: #450a0a;
--bg-danger-pressed: #7f1d1d;
}
--bg: var(--bui-bg-danger);
--bg-hover: var(--bg-danger-hover);
--bg-active: var(--bg-danger-pressed);
--fg: var(--bui-fg-danger);
&[data-disabled='true'],
&[data-loading='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
}
&:focus-visible {
box-shadow: inset 0 0 0 2px var(--bui-border-danger);
}
}
.bui-Button[data-variant='tertiary'] {
--bg-hover: var(--bui-bg-neutral-on-surface-0-hover);
--bg-active: var(--bui-bg-neutral-on-surface-0-pressed);
@@ -151,6 +211,32 @@
}
}
.bui-Button[data-variant='tertiary'][data-destructive='true'] {
/* Custom properties for hover/active states (no tokens exist yet) */
--bg-danger-hover: #fecaca;
--bg-danger-pressed: #fca5a5;
[data-theme-mode='dark'] & {
--bg-danger-hover: #450a0a;
--bg-danger-pressed: #7f1d1d;
}
--bg-hover: var(--bui-bg-danger);
--bg-active: var(--bg-danger-pressed);
--fg: var(--bui-fg-danger);
&[data-disabled='true'],
&[data-loading='true'] {
--bg-hover: var(--bg);
--bg-active: var(--bg);
--fg: var(--bui-fg-disabled);
}
&:focus-visible {
box-shadow: inset 0 0 0 2px var(--bui-border-danger);
}
}
.bui-Button[data-size='small'] {
font-size: var(--bui-font-size-3);
padding: 0 var(--bui-space-2);
@@ -31,7 +31,10 @@ const meta = preview.meta({
},
variant: {
control: 'select',
options: ['primary', 'secondary'],
options: ['primary', 'secondary', 'tertiary'],
},
destructive: {
control: 'boolean',
},
},
});
@@ -67,6 +70,15 @@ export const Variants = meta.story({
<Button iconStart={<RiCloudLine />} variant="tertiary">
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
Button
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
@@ -81,6 +93,15 @@ export const Variants = meta.story({
<Button iconStart={<RiCloudLine />} variant="tertiary">
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
Button
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
@@ -95,6 +116,15 @@ export const Variants = meta.story({
<Button iconStart={<RiCloudLine />} variant="tertiary">
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
Button
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
@@ -109,6 +139,15 @@ export const Variants = meta.story({
<Button iconStart={<RiCloudLine />} variant="tertiary">
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
Button
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
@@ -123,6 +162,98 @@ export const Variants = meta.story({
<Button iconStart={<RiCloudLine />} variant="tertiary">
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
Button
</Button>
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
Button
</Button>
</Flex>
</Flex>
</Flex>
),
});
export const Destructive = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Flex direction="column" gap="4">
<Text>Primary Destructive</Text>
<Flex align="center" p="4" gap="4">
<Button variant="primary" destructive>
Delete
</Button>
<Button variant="primary" destructive iconStart={<RiCloudLine />}>
Delete
</Button>
<Button variant="primary" destructive isDisabled>
Disabled
</Button>
<Button variant="primary" destructive loading>
Loading
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>Secondary Destructive</Text>
<Flex align="center" p="4" gap="4">
<Button variant="secondary" destructive>
Delete
</Button>
<Button variant="secondary" destructive iconStart={<RiCloudLine />}>
Delete
</Button>
<Button variant="secondary" destructive isDisabled>
Disabled
</Button>
<Button variant="secondary" destructive loading>
Loading
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>Tertiary Destructive</Text>
<Flex align="center" p="4" gap="4">
<Button variant="tertiary" destructive>
Delete
</Button>
<Button variant="tertiary" destructive iconStart={<RiCloudLine />}>
Delete
</Button>
<Button variant="tertiary" destructive isDisabled>
Disabled
</Button>
<Button variant="tertiary" destructive loading>
Loading
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>On Surface 1</Text>
<Flex align="center" surface="1" p="4" gap="4">
<Button variant="primary" destructive>
Primary
</Button>
<Button variant="secondary" destructive>
Secondary
</Button>
<Button variant="tertiary" destructive>
Tertiary
</Button>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>Sizes</Text>
<Flex align="center" p="4" gap="4">
<Button variant="primary" destructive size="small">
Small
</Button>
<Button variant="primary" destructive size="medium">
Medium
</Button>
</Flex>
</Flex>
</Flex>
@@ -181,16 +312,29 @@ export const FullWidth = meta.story({
export const Disabled = meta.story({
render: () => (
<Flex direction="row" gap="4">
<Button variant="primary" isDisabled>
Primary
</Button>
<Button variant="secondary" isDisabled>
Secondary
</Button>
<Button variant="tertiary" isDisabled>
Tertiary
</Button>
<Flex direction="column" gap="4">
<Flex direction="row" gap="4">
<Button variant="primary" isDisabled>
Primary
</Button>
<Button variant="secondary" isDisabled>
Secondary
</Button>
<Button variant="tertiary" isDisabled>
Tertiary
</Button>
</Flex>
<Flex direction="row" gap="4">
<Button variant="primary" destructive isDisabled>
Primary Destructive
</Button>
<Button variant="secondary" destructive isDisabled>
Secondary Destructive
</Button>
<Button variant="tertiary" destructive isDisabled>
Tertiary Destructive
</Button>
</Flex>
</Flex>
),
});
@@ -270,6 +414,24 @@ export const LoadingVariants = meta.story({
</Button>
</Flex>
<Text>Primary Destructive</Text>
<Flex align="center" gap="4">
<Button variant="primary" destructive size="small" loading>
Small Loading
</Button>
<Button variant="primary" destructive size="medium" loading>
Medium Loading
</Button>
<Button
variant="primary"
destructive
loading
iconStart={<RiCloudLine />}
>
With Icon
</Button>
</Flex>
<Text>Loading vs Disabled</Text>
<Flex align="center" gap="4">
<Button variant="primary" loading>
@@ -33,6 +33,7 @@ export const ButtonDefinition = defineComponent<ButtonOwnProps>()({
propDefs: {
size: { dataAttribute: true, default: 'small' },
variant: { dataAttribute: true, default: 'primary' },
destructive: { dataAttribute: true },
loading: { dataAttribute: true },
iconStart: {},
iconEnd: {},
@@ -22,6 +22,7 @@ import type { LeafSurfaceProps, Responsive } from '../../types';
export type ButtonOwnProps = LeafSurfaceProps & {
size?: Responsive<'small' | 'medium'>;
variant?: Responsive<'primary' | 'secondary' | 'tertiary'>;
destructive?: boolean;
iconStart?: ReactElement;
iconEnd?: ReactElement;
loading?: boolean;