Add new Toast component

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-27 08:57:02 +00:00
committed by Patrik Oldsberg
parent 672b97278a
commit 8abbe73519
15 changed files with 2016 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': minor
---
Added Toast component with React Aria integration. The Toast component displays brief, temporary notifications for actions, errors, or other events. It supports multiple status variants (info, success, warning, danger), flexible positioning, auto-dismiss with timeout, programmatic control, and deep stacking of multiple toasts. The component uses a global queue system for triggering toasts from anywhere in the application.
@@ -0,0 +1,308 @@
'use client';
import { useState } from 'react';
import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
import type { ToastContent } from '@backstage/ui';
export function Default() {
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add({
title: 'Files uploaded',
description: '3 files uploaded successfully.',
})
}
>
Show Toast
</Button>
</>
);
}
export function StatusVariants() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'Informational message',
description: 'Here is some helpful information.',
status: 'info',
})
}
>
Info Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success!',
description: 'Your changes have been saved.',
status: 'success',
})
}
>
Success Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Warning',
description: 'This action may have consequences.',
status: 'warning',
})
}
>
Warning Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Error',
description: 'Something went wrong.',
status: 'danger',
})
}
>
Danger Toast
</Button>
</Flex>
</>
);
}
export function WithDescription() {
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add({
title: 'Update available',
description: 'A new version is ready to install.',
status: 'info',
})
}
>
Show Toast
</Button>
</>
);
}
export function WithoutDescription() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'File saved',
status: 'success',
})
}
>
Simple Success
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Check for updates',
status: 'info',
})
}
>
Simple Info
</Button>
</Flex>
</>
);
}
export function Positions() {
const [currentPosition, setCurrentPosition] = useState<
'top' | 'bottom' | null
>(null);
const [currentPlacement, setCurrentPlacement] = useState<
'start' | 'center' | 'end' | null
>(null);
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end',
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: `${position} - ${placement}`,
description: `Toast positioned at ${position} ${placement}`,
status: 'info',
});
};
return (
<>
{currentPosition && currentPlacement && (
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
)}
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>Top Start</Button>
<Button onPress={() => showToast('top', 'center')}>
Top Center
</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>
Bottom Start
</Button>
<Button onPress={() => showToast('bottom', 'center')}>
Bottom Center
</Button>
<Button onPress={() => showToast('bottom', 'end')}>
Bottom End
</Button>
</Flex>
</div>
</Flex>
</>
);
}
export function AutoDismiss() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 3 seconds',
description: 'This toast will disappear automatically.',
status: 'info',
},
{ timeout: 3000 },
)
}
>
3 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 5 seconds',
description: 'Recommended minimum timeout for accessibility.',
status: 'success',
},
{ timeout: 5000 },
)
}
>
5 Second Toast
</Button>
</Flex>
</>
);
}
export function ProgrammaticControl() {
const [toastKey, setToastKey] = useState<string | null>(null);
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() => {
if (!toastKey) {
const key = toastQueue.add(
{
title: 'Processing...',
description: 'Click the button again to dismiss.',
status: 'info',
},
{
onClose: () => setToastKey(null),
},
);
setToastKey(key);
} else {
toastQueue.close(toastKey);
}
}}
>
{toastKey ? 'Dismiss Toast' : 'Show Toast'}
</Button>
</>
);
}
export function QueueManagement() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() => {
toastQueue.add({
title: 'First toast',
description: 'This is the first toast in the queue.',
status: 'info',
});
setTimeout(() => {
toastQueue.add({
title: 'Second toast',
description: 'This is the second toast.',
status: 'success',
});
}, 300);
setTimeout(() => {
toastQueue.add({
title: 'Third toast',
description: 'This is the third toast.',
status: 'warning',
});
}, 600);
}}
>
Show Multiple Toasts
</Button>
<Button
onPress={() => {
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
toastQueue.add({
title: `Toast #${i}`,
description: `This is toast number ${i}.`,
status: ['info', 'success', 'warning', 'danger'][
(i - 1) % 4
] as unknown as ToastContent['status'],
});
}, i * 200);
}
}}
>
Show 5 Toasts
</Button>
<Button onPress={() => toastQueue.clear()}>Clear All Toasts</Button>
</Flex>
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { toastContentPropDefs, toastRegionPropDefs } from './props-definition';
import {
toastUsageSnippet,
defaultSnippet,
statusVariantsSnippet,
withDescriptionSnippet,
withoutDescriptionSnippet,
positionsSnippet,
autoDismissSnippet,
programmaticControlSnippet,
queueManagementSnippet,
} from './snippets';
import {
Default,
StatusVariants,
WithDescription,
WithoutDescription,
Positions,
AutoDismiss,
ProgrammaticControl,
QueueManagement,
} from './components';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ToastDefinition, ToastRegionDefinition } from '../../../utils/definitions';
<PageTitle
title="Toast"
description="A Toast displays a brief, temporary notification of actions, errors, or other events in an application."
/>
<Snippet align="center" py={4} preview={<Default />} code={defaultSnippet} />
## Status
The Toast component uses React Aria's unstable Toast API, which is currently in **alpha status**. The API may change in future versions of react-aria-components.
## Usage
The Toast component uses a global queue system. Place the `ToastRegion` once in your app root, then trigger toasts from anywhere using the `queue` API.
<CodeBlock code={toastUsageSnippet} />
## API reference
### ToastContent
The content object passed to `queue.add()`:
<PropsTable data={toastContentPropDefs} />
### ToastRegion
<PropsTable data={toastRegionPropDefs} />
## Examples
### Status Variants
The Toast component supports four status variants, each with its own color theme and icon.
<Snippet
align="center"
py={4}
open
preview={<StatusVariants />}
code={statusVariantsSnippet}
/>
### With Description
Add a description to provide additional context or details.
<Snippet
align="center"
py={4}
open
preview={<WithDescription />}
code={withDescriptionSnippet}
/>
### Without Description
Toasts can display a title only for simpler notifications.
<Snippet
align="center"
py={4}
open
preview={<WithoutDescription />}
code={withoutDescriptionSnippet}
/>
### Positions and Placements
Control where toasts appear on the screen with `position` (top/bottom) and `placement` (start/center/end).
<Snippet
align="center"
py={4}
open
preview={<Positions />}
code={positionsSnippet}
/>
### Auto Dismiss
Toasts can automatically dismiss after a timeout. For accessibility, a minimum of 5 seconds is recommended. Timers automatically pause when users hover or focus on a toast.
<Snippet
align="center"
py={4}
open
preview={<AutoDismiss />}
code={autoDismissSnippet}
/>
### Programmatic Control
You can programmatically dismiss toasts using the key returned from `queue.add()`.
<Snippet
align="center"
py={4}
open
preview={<ProgrammaticControl />}
code={programmaticControlSnippet}
/>
### Queue Management
The toast queue supports multiple toasts with deep stacking. When multiple toasts are visible, they stack with the most recent toast fully visible and others slightly visible behind it with reduced opacity and scale. You can show multiple toasts at once or clear all toasts programmatically.
<Snippet
align="center"
py={4}
open
preview={<QueueManagement />}
code={queueManagementSnippet}
/>
## Accessibility
- **Landmark Region**: Toast regions are ARIA landmark regions that can be navigated using F6 (forward) and Shift+F6 (backward).
- **Focus Management**: When a toast is closed, focus moves to the next toast if any. When the last toast is closed, focus is restored.
- **Timer Pausing**: Timers automatically pause when users focus or hover over a toast.
- **Minimum Timeout**: For accessibility, toasts should have a minimum timeout of 5 seconds.
- **Manual Dismissal**: Always include a close button for users who need more time to read the content.
## Theming
<Theming definition={ToastDefinition} />
<Theming definition={ToastRegionDefinition} />
## Changelog
<ChangelogComponent component="toast" />
@@ -0,0 +1,51 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const toastContentPropDefs: Record<string, PropDef> = {
title: {
type: 'enum',
values: ['React.ReactNode'],
responsive: false,
},
description: {
type: 'enum',
values: ['React.ReactNode'],
responsive: false,
},
status: {
type: 'enum',
values: ['info', 'success', 'warning', 'danger'],
responsive: false,
default: 'info',
},
icon: {
type: 'enum',
values: ['boolean', 'React.ReactElement'],
responsive: false,
default: 'true',
},
};
export const toastRegionPropDefs: Record<string, PropDef> = {
queue: {
type: 'enum',
values: ['ToastQueue<ToastContent>'],
responsive: false,
},
position: {
type: 'enum',
values: ['top', 'bottom'],
responsive: true,
default: 'bottom',
},
placement: {
type: 'enum',
values: ['start', 'center', 'end'],
responsive: true,
default: 'end',
},
...classNamePropDefs,
};
@@ -0,0 +1,321 @@
export const toastUsageSnippet = `import { ToastRegion, toastQueue } from '@backstage/ui';
// Place ToastRegion once in your app root
function App() {
return (
<>
<ToastRegion queue={toastQueue} />
<YourAppContent />
</>
);
}
// Trigger toasts from anywhere
toastQueue.add({ title: 'Success!', status: 'success' });`;
export const defaultSnippet = `import { ToastRegion, toastQueue, Button } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add({
title: 'Files uploaded',
description: '3 files uploaded successfully.',
})
}
>
Show Toast
</Button>
</>
);
}`;
export const statusVariantsSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'Informational message',
description: 'Here is some helpful information.',
status: 'info',
})
}
>
Info Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success!',
description: 'Your changes have been saved.',
status: 'success',
})
}
>
Success Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Warning',
description: 'This action may have consequences.',
status: 'warning',
})
}
>
Warning Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Error',
description: 'Something went wrong.',
status: 'danger',
})
}
>
Danger Toast
</Button>
</Flex>
</>
);
}`;
export const withDescriptionSnippet = `import { ToastRegion, toastQueue, Button } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add({
title: 'Update available',
description: 'A new version is ready to install.',
status: 'info',
})
}
>
Show Toast
</Button>
</>
);
}`;
export const withoutDescriptionSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'File saved',
status: 'success',
})
}
>
Simple Success
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Check for updates',
status: 'info',
})
}
>
Simple Info
</Button>
</Flex>
</>
);
}`;
export const positionsSnippet = `import { useState } from 'react';
import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
const [currentPosition, setCurrentPosition] = useState<'top' | 'bottom' | null>(null);
const [currentPlacement, setCurrentPlacement] = useState<'start' | 'center' | 'end' | null>(null);
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end'
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: \`\${position} - \${placement}\`,
description: \`Toast positioned at \${position} \${placement}\`,
status: 'info',
});
};
return (
<>
{currentPosition && currentPlacement && (
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
)}
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>Top Start</Button>
<Button onPress={() => showToast('top', 'center')}>Top Center</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>Bottom Start</Button>
<Button onPress={() => showToast('bottom', 'center')}>Bottom Center</Button>
<Button onPress={() => showToast('bottom', 'end')}>Bottom End</Button>
</Flex>
</div>
</Flex>
</>
);
}`;
export const autoDismissSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 3 seconds',
description: 'This toast will disappear automatically.',
status: 'info',
},
{ timeout: 3000 }
)
}
>
3 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 5 seconds',
description: 'Recommended minimum timeout for accessibility.',
status: 'success',
},
{ timeout: 5000 }
)
}
>
5 Second Toast
</Button>
</Flex>
</>
);
}`;
export const programmaticControlSnippet = `import { useState } from 'react';
import { ToastRegion, toastQueue, Button } from '@backstage/ui';
export function Example() {
const [toastKey, setToastKey] = useState<string | null>(null);
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() => {
if (!toastKey) {
const key = toastQueue.add(
{
title: 'Processing...',
description: 'Click the button again to dismiss.',
status: 'info',
},
{
onClose: () => setToastKey(null),
}
);
setToastKey(key);
} else {
toastQueue.close(toastKey);
}
}}
>
{toastKey ? 'Dismiss Toast' : 'Show Toast'}
</Button>
</>
);
}`;
export const queueManagementSnippet = `import { ToastRegion, toastQueue, Button, Flex } from '@backstage/ui';
export function Example() {
return (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() => {
toastQueue.add({
title: 'First toast',
description: 'This is the first toast in the queue.',
status: 'info',
});
setTimeout(() => {
toastQueue.add({
title: 'Second toast',
description: 'This is the second toast.',
status: 'success',
});
}, 300);
setTimeout(() => {
toastQueue.add({
title: 'Third toast',
description: 'This is the third toast.',
status: 'warning',
});
}, 600);
}}
>
Show Multiple Toasts
</Button>
<Button
onPress={() => {
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
toastQueue.add({
title: \`Toast #\${i}\`,
description: \`This is toast number \${i}.\`,
status: ['info', 'success', 'warning', 'danger'][(i - 1) % 4],
});
}, i * 200);
}
}}
>
Show 5 Toasts
</Button>
<Button onPress={() => toastQueue.clear()}>Clear All Toasts</Button>
</Flex>
</>
);
}`;
@@ -0,0 +1,314 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@layer tokens, base, components, utilities;
@layer components {
/* Toast Region - Container for all toasts */
.bui-ToastRegion {
position: fixed;
inset-inline-start: 0;
inset-inline-end: 0;
z-index: 100050; /* Above modals */
display: flex;
pointer-events: none;
outline: none;
margin-block-end: var(--bui-space-2);
margin-inline: var(--bui-space-2);
}
/* Position: Top */
.bui-ToastRegion[data-position='top'] {
top: 0;
flex-direction: column;
}
/* Position: Bottom */
.bui-ToastRegion[data-position='bottom'] {
bottom: 0;
flex-direction: column-reverse;
}
/* Placement: Start */
.bui-ToastRegion[data-placement='start'] {
align-items: flex-start;
}
/* Placement: Center */
.bui-ToastRegion[data-placement='center'] {
align-items: center;
}
/* Placement: End */
.bui-ToastRegion[data-placement='end'] {
align-items: flex-end;
}
/* Individual Toast */
.bui-Toast {
--toast-bg: var(--bui-bg-surface-1);
--toast-fg: var(--bui-fg-primary);
--toast-gap: 0.75rem;
--toast-peek: 1.25rem;
--toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05)));
--toast-shrink: calc(1 - var(--toast-scale));
position: relative;
display: flex;
align-items: flex-start;
gap: var(--bui-space-3);
min-width: 320px;
max-width: 480px;
margin: var(--bui-space-2);
padding: var(--bui-space-3);
border-radius: var(--bui-radius-3);
background-color: var(--toast-bg);
color: var(--toast-fg);
font-family: var(--bui-font-regular);
font-size: var(--bui-font-size-3);
line-height: 1.5;
box-shadow: var(--bui-shadow);
outline: none;
z-index: calc(1000 - var(--toast-index));
pointer-events: auto;
transition:
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.4s;
/* Focus ring */
&:focus-visible {
outline: 2px solid var(--bui-border-focus);
outline-offset: 2px;
}
}
/* Bottom position stacking */
.bui-ToastRegion[data-position='bottom'] .bui-Toast:not([data-entering]) {
transform: translateY(
calc(
(var(--toast-index) * var(--toast-peek) * -1) -
(var(--toast-shrink) * 100%)
)
)
scale(var(--toast-scale));
transform-origin: bottom center;
}
/* Top position stacking */
.bui-ToastRegion[data-position='top'] .bui-Toast:not([data-entering]) {
transform: translateY(
calc(
(var(--toast-index) * var(--toast-peek)) +
(var(--toast-shrink) * 100%)
)
)
scale(var(--toast-scale));
transform-origin: top center;
}
/* Reduce opacity and disable interaction for stacked toasts */
.bui-Toast {
opacity: calc(1 - (var(--toast-index) * 0.15));
}
/* Only first toast is interactive */
.bui-ToastRegion li:not(:first-child) .bui-Toast {
pointer-events: none;
}
/* List styling for toast container */
.bui-ToastRegion ol {
display: inherit;
flex-direction: inherit;
align-items: inherit;
list-style-type: none;
margin: 0;
padding: 0;
position: relative;
}
.bui-ToastRegion li {
display: block !important;
position: absolute;
width: 100%;
}
/* Position list items based on toast region position */
.bui-ToastRegion[data-position='bottom'] li {
bottom: 0;
}
.bui-ToastRegion[data-position='top'] li {
top: 0;
}
/* Status variants */
.bui-Toast[data-status='info'] {
--toast-bg: var(--bui-bg-info);
--toast-fg: var(--bui-fg-info);
--toast-border: var(--bui-border-info);
}
.bui-Toast[data-status='success'] {
--toast-bg: var(--bui-bg-success);
--toast-fg: var(--bui-fg-success);
--toast-border: var(--bui-border-success);
}
.bui-Toast[data-status='warning'] {
--toast-bg: var(--bui-bg-warning);
--toast-fg: var(--bui-fg-warning);
--toast-border: var(--bui-border-warning);
}
.bui-Toast[data-status='danger'] {
--toast-bg: var(--bui-bg-danger);
--toast-fg: var(--bui-fg-danger);
--toast-border: var(--bui-border-danger);
}
/* Toast Content */
.bui-ToastContent {
display: flex;
align-items: flex-start;
gap: var(--bui-space-3);
flex: 1;
min-width: 0;
}
/* Icon */
.bui-ToastIcon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.125rem;
svg {
width: 1rem;
height: 1rem;
}
}
/* Title */
.bui-ToastTitle {
font-weight: var(--bui-font-weight-bold);
font-size: var(--bui-font-size-3);
word-wrap: break-word;
}
/* Description */
.bui-ToastDescription {
font-size: var(--bui-font-size-2);
opacity: 0.9;
margin-top: var(--bui-space-1);
word-wrap: break-word;
}
/* Close Button */
.bui-ToastCloseButton {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin: -0.25rem -0.25rem 0 0;
padding: 0;
border: none;
border-radius: var(--bui-radius-2);
background: transparent;
color: var(--toast-fg);
cursor: pointer;
transition: background-color 0.15s ease;
svg {
width: 1rem;
height: 1rem;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid var(--bui-border-focus);
outline-offset: 1px;
}
&:active {
background-color: rgba(0, 0, 0, 0.15);
}
}
/* Animations */
@keyframes bui-toast-slide-in-bottom {
from {
transform: translateY(calc(100% + 2rem)) scale(1);
opacity: 0;
}
}
@keyframes bui-toast-slide-in-top {
from {
transform: translateY(calc(-100% - 2rem)) scale(1);
opacity: 0;
}
}
@keyframes bui-toast-slide-out-bottom {
to {
transform: translateY(calc(100% + 2rem));
opacity: 0;
}
}
@keyframes bui-toast-slide-out-top {
to {
transform: translateY(calc(-100% - 2rem));
opacity: 0;
}
}
/* Apply animations based on position */
.bui-ToastRegion[data-position='bottom'] .bui-Toast[data-entering] {
animation: bui-toast-slide-in-bottom 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.bui-ToastRegion[data-position='bottom'] .bui-Toast[data-exiting] {
animation: bui-toast-slide-out-bottom 0.3s ease-in forwards;
}
.bui-ToastRegion[data-position='top'] .bui-Toast[data-entering] {
animation: bui-toast-slide-in-top 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.bui-ToastRegion[data-position='top'] .bui-Toast[data-exiting] {
animation: bui-toast-slide-out-top 0.3s ease-in forwards;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.bui-Toast {
transition: none;
}
.bui-Toast[data-entering],
.bui-Toast[data-exiting] {
animation: none;
}
}
}
@@ -0,0 +1,395 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState } from 'react';
import preview from '../../../../../.storybook/preview';
import { ToastRegion } from './ToastRegion';
import { toastQueue } from './queue';
import { Flex } from '../Flex';
import { Button } from '../Button';
const meta = preview.meta({
title: 'Backstage UI/Toast',
component: ToastRegion,
argTypes: {
position: {
control: 'select',
options: ['top', 'bottom'],
},
placement: {
control: 'select',
options: ['start', 'center', 'end'],
},
},
});
export const Default = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add({
title: 'Files uploaded',
description: '3 files uploaded successfully.',
})
}
>
Show Toast
</Button>
</>
),
});
export const StatusVariants = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'Informational message',
description: 'Here is some helpful information.',
status: 'info',
})
}
>
Info Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success!',
description: 'Your changes have been saved.',
status: 'success',
})
}
>
Success Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Warning',
description: 'This action may have consequences.',
status: 'warning',
})
}
>
Warning Toast
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Error',
description: 'Something went wrong.',
status: 'danger',
})
}
>
Danger Toast
</Button>
</Flex>
</>
),
});
export const WithoutDescription = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'File saved',
status: 'success',
})
}
>
Simple Success
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Check for updates',
status: 'info',
})
}
>
Simple Info
</Button>
</Flex>
</>
),
});
export const WithoutIcons = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'Toast without icon',
description: 'This toast has no icon displayed.',
icon: false,
})
}
>
No Icon
</Button>
<Button
onPress={() =>
toastQueue.add({
title: 'Success without icon',
status: 'success',
icon: false,
})
}
>
Success No Icon
</Button>
</Flex>
</>
),
});
export const Positions = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} position="top" placement="center" />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add({
title: 'Toast from top center',
description: 'This appears at the top center.',
status: 'info',
})
}
>
Top Center
</Button>
</Flex>
</>
),
});
export const AllPositions = meta.story({
render: () => {
const [currentPosition, setCurrentPosition] = useState<
'top' | 'bottom' | null
>(null);
const [currentPlacement, setCurrentPlacement] = useState<
'start' | 'center' | 'end' | null
>(null);
const showToast = (
position: 'top' | 'bottom',
placement: 'start' | 'center' | 'end',
) => {
setCurrentPosition(position);
setCurrentPlacement(placement);
toastQueue.add({
title: `${position} - ${placement}`,
description: `Toast positioned at ${position} ${placement}`,
status: 'info',
});
};
return (
<>
{currentPosition && currentPlacement && (
<ToastRegion
queue={toastQueue}
position={currentPosition}
placement={currentPlacement}
/>
)}
<Flex direction="column" gap="4">
<div>
<strong>Top Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('top', 'start')}>
Top Start
</Button>
<Button onPress={() => showToast('top', 'center')}>
Top Center
</Button>
<Button onPress={() => showToast('top', 'end')}>Top End</Button>
</Flex>
</div>
<div>
<strong>Bottom Positions:</strong>
<Flex gap="2" style={{ marginTop: '8px' }}>
<Button onPress={() => showToast('bottom', 'start')}>
Bottom Start
</Button>
<Button onPress={() => showToast('bottom', 'center')}>
Bottom Center
</Button>
<Button onPress={() => showToast('bottom', 'end')}>
Bottom End
</Button>
</Flex>
</div>
</Flex>
</>
);
},
});
export const AutoDismiss = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 3 seconds',
description: 'This toast will disappear automatically.',
status: 'info',
},
{ timeout: 3000 },
)
}
>
3 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 5 seconds',
description: 'Recommended minimum timeout for accessibility.',
status: 'success',
},
{ timeout: 5000 },
)
}
>
5 Second Toast
</Button>
<Button
onPress={() =>
toastQueue.add(
{
title: 'Auto dismiss in 10 seconds',
description: 'Longer timeout for more content.',
status: 'warning',
},
{ timeout: 10000 },
)
}
>
10 Second Toast
</Button>
</Flex>
</>
),
});
export const ProgrammaticDismiss = meta.story({
render: () => {
const [toastKey, setToastKey] = useState<string | null>(null);
return (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() => {
if (!toastKey) {
const key = toastQueue.add(
{
title: 'Processing...',
description: 'Click the button again to dismiss.',
status: 'info',
},
{
onClose: () => setToastKey(null),
},
);
setToastKey(key);
} else {
toastQueue.close(toastKey);
}
}}
>
{toastKey ? 'Dismiss Toast' : 'Show Toast'}
</Button>
</>
);
},
});
export const QueueManagement = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Flex gap="3" wrap="wrap">
<Button
onPress={() => {
toastQueue.add({
title: 'First toast',
description: 'This is the first toast in the queue.',
status: 'info',
});
setTimeout(() => {
toastQueue.add({
title: 'Second toast',
description: 'This is the second toast.',
status: 'success',
});
}, 300);
setTimeout(() => {
toastQueue.add({
title: 'Third toast',
description: 'This is the third toast.',
status: 'warning',
});
}, 600);
}}
>
Show Multiple Toasts
</Button>
<Button
onPress={() => {
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
toastQueue.add({
title: `Toast #${i}`,
description: `This is toast number ${i}.`,
status: ['info', 'success', 'warning', 'danger'][
(i - 1) % 4
] as 'info' | 'success' | 'warning' | 'danger',
});
}, i * 200);
}
}}
>
Show 5 Toasts
</Button>
<Button onPress={() => toastQueue.clear()}>Clear All Toasts</Button>
</Flex>
</>
),
});
export default meta;
+149
View File
@@ -0,0 +1,149 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, Ref, isValidElement, ReactElement } from 'react';
import {
UNSTABLE_Toast as RAToast,
UNSTABLE_ToastContent as RAToastContent,
Text,
Button as RAButton,
} from 'react-aria-components';
import {
RiInformationLine,
RiCheckLine,
RiErrorWarningLine,
RiAlertLine,
RiCloseLine,
} from '@remixicon/react';
import type { ToastProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ToastDefinition } from './definition';
/**
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
*
* @remarks
* The Toast component is typically used within a ToastRegion and managed by a ToastQueue.
* It supports multiple status variants (info, success, warning, danger) and can display
* a title, description, and optional icon. Toasts can be dismissed manually or automatically.
*
* This component uses React Aria's unstable Toast API which is currently in alpha.
*
* @example
* Basic usage with queue:
* ```tsx
* import { queue } from '@backstage/ui';
*
* queue.add({ title: 'File saved successfully', status: 'success' });
* ```
*
* @example
* With description and auto-dismiss:
* ```tsx
* queue.add(
* {
* title: 'Update available',
* description: 'A new version is ready to install.',
* status: 'info'
* },
* { timeout: 5000 }
* );
* ```
*
* @public
*/
export const Toast = forwardRef(
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ToastDefinition,
props,
);
const { classes, toast, index = 0, status, icon } = ownProps;
// Get content from toast
const content = toast.content;
const finalStatus = status || content.status || 'info';
const finalIcon = icon !== undefined ? icon : content.icon;
// Determine which icon to render
const getStatusIcon = (): ReactElement | null => {
// If icon is explicitly false, don't render any icon
if (finalIcon === false) {
return null;
}
// If icon is a custom React element, use it
if (isValidElement(finalIcon)) {
return finalIcon;
}
// If icon is true or undefined (default to true for toasts), auto-select based on status
if (finalIcon === true || finalIcon === undefined) {
switch (finalStatus) {
case 'success':
return <RiCheckLine aria-hidden="true" />;
case 'warning':
return <RiErrorWarningLine aria-hidden="true" />;
case 'danger':
return <RiAlertLine aria-hidden="true" />;
case 'info':
default:
return <RiInformationLine aria-hidden="true" />;
}
}
// Default: no icon
return null;
};
const statusIcon = getStatusIcon();
return (
<RAToast
toast={toast}
className={classes.root}
ref={ref}
style={
{
'--toast-index': index,
} as React.CSSProperties
}
{...dataAttributes}
data-status={finalStatus}
{...restProps}
>
<RAToastContent className={classes.content}>
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
<div>
<Text slot="title" className={classes.title}>
{content.title}
</Text>
{content.description && (
<Text slot="description" className={classes.description}>
{content.description}
</Text>
)}
</div>
</RAToastContent>
<RAButton slot="close" className={classes.closeButton}>
<RiCloseLine aria-hidden="true" />
</RAButton>
</RAToast>
);
},
);
Toast.displayName = 'Toast';
@@ -0,0 +1,102 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, Ref, useEffect, useState } from 'react';
import { UNSTABLE_ToastRegion as RAToastRegion } from 'react-aria-components';
import type { ToastRegionProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ToastRegionDefinition } from './definition';
import { Toast } from './Toast';
/**
* A ToastRegion displays one or more toast notifications.
*
* @remarks
* The ToastRegion component should typically be placed once at the root of your application.
* It manages the display and positioning of all toast notifications added to its queue.
* Toast regions are ARIA landmark regions that can be navigated using F6 (forward) and
* Shift+F6 (backward) for keyboard accessibility.
*
* This component uses React Aria's unstable Toast API which is currently in alpha.
*
* @example
* Basic setup in app root:
* ```tsx
* import { ToastRegion, queue } from '@backstage/ui';
*
* function App() {
* return (
* <>
* <ToastRegion queue={queue} />
* <YourAppContent />
* </>
* );
* }
* ```
*
* @example
* Custom positioning:
* ```tsx
* <ToastRegion
* queue={queue}
* position="top"
* placement="center"
* />
* ```
*
* @public
*/
export const ToastRegion = forwardRef(
(props: ToastRegionProps, ref: Ref<HTMLElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ToastRegionDefinition,
props,
);
const { classes, queue, className } = ownProps;
// Track visible toast keys to determine index
const [visibleToasts, setVisibleToasts] = useState<string[]>([]);
useEffect(() => {
const updateToasts = () => {
setVisibleToasts(queue.visibleToasts.map(t => t.key));
};
// Subscribe to queue changes
const unsubscribe = queue.subscribe(updateToasts);
updateToasts(); // Initial update
return unsubscribe;
}, [queue]);
return (
<RAToastRegion
ref={ref}
queue={queue}
className={className || classes.region}
{...dataAttributes}
{...restProps}
>
{({ toast }) => {
const index = visibleToasts.indexOf(toast.key);
return <Toast toast={toast} index={index >= 0 ? index : 0} />;
}}
</RAToastRegion>
);
},
);
ToastRegion.displayName = 'ToastRegion';
@@ -0,0 +1,59 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineComponent } from '../../hooks/useDefinition';
import type { ToastOwnProps, ToastRegionOwnProps } from './types';
import styles from './Toast.module.css';
/**
* Component definition for Toast
* @public
*/
export const ToastDefinition = defineComponent<ToastOwnProps>()({
styles,
classNames: {
root: 'bui-Toast',
content: 'bui-ToastContent',
title: 'bui-ToastTitle',
description: 'bui-ToastDescription',
icon: 'bui-ToastIcon',
closeButton: 'bui-ToastCloseButton',
},
surface: 'container',
propDefs: {
toast: {},
index: {},
status: { dataAttribute: true },
icon: {},
},
});
/**
* Component definition for ToastRegion
* @public
*/
export const ToastRegionDefinition = defineComponent<ToastRegionOwnProps>()({
styles,
classNames: {
region: 'bui-ToastRegion',
},
propDefs: {
queue: {},
position: { dataAttribute: true, default: 'bottom' },
placement: { dataAttribute: true, default: 'end' },
className: {},
},
});
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Toast } from './Toast';
export { ToastRegion } from './ToastRegion';
export * from './types';
export { ToastDefinition, ToastRegionDefinition } from './definition';
export { toastQueue } from './queue';
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components';
import type { ToastContent } from './types';
/**
* Global toast queue for displaying toast notifications throughout the application.
*
* @remarks
* This uses React Aria's unstable Toast API which is currently in alpha.
* The API may change in future versions.
*
* @example
* ```tsx
* import { toastQueue } from '@backstage/ui';
*
* // Show a toast
* toastQueue.add({ title: 'Success!', status: 'success' });
*
* // Show with auto-dismiss
* toastQueue.add({ title: 'Saved' }, { timeout: 5000 });
*
* // Programmatic dismiss
* const key = toastQueue.add({ title: 'Processing...' });
* // Later...
* toastQueue.close(key);
* ```
*
* @public
*/
export const toastQueue = new RAToastQueue<ToastContent>({});
+79
View File
@@ -0,0 +1,79 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactElement, ReactNode } from 'react';
import type { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components';
import type { Responsive } from '../../types';
/**
* Content for a toast notification
* @public
*/
export interface ToastContent {
/** Title of the toast (required) */
title: ReactNode;
/** Optional description text */
description?: ReactNode;
/** Status variant of the toast */
status?: 'info' | 'success' | 'warning' | 'danger';
/** Whether to show an icon */
icon?: boolean | ReactElement;
}
/**
* Own props for the Toast component
* @public
*/
export type ToastOwnProps = {
/** Toast object from the queue */
toast: {
key: string;
content: ToastContent;
};
/** Index of the toast in the stack (0 = frontmost) */
index?: number;
/** Override status from content */
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
/** Override icon from content */
icon?: boolean | ReactElement;
};
/**
* Properties for {@link Toast}
* @public
*/
export interface ToastProps extends ToastOwnProps {}
/**
* Own props for the ToastRegion component
* @public
*/
export type ToastRegionOwnProps = {
/** Toast queue instance */
queue: RAToastQueue<ToastContent>;
/** Position of the toast region (top or bottom) */
position?: Responsive<'top' | 'bottom'>;
/** Horizontal placement of toasts */
placement?: Responsive<'start' | 'center' | 'end'>;
/** Custom class name */
className?: string;
};
/**
* Properties for {@link ToastRegion}
* @public
*/
export interface ToastRegionProps extends ToastRegionOwnProps {}
+4
View File
@@ -53,6 +53,10 @@ export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
export { SwitchDefinition } from './components/Switch/definition';
export {
ToastDefinition,
ToastRegionDefinition,
} from './components/Toast/definition';
export { ToggleButtonDefinition } from './components/ToggleButton/definition';
export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition';
export { TableDefinition } from './components/Table/definition';
+1
View File
@@ -56,6 +56,7 @@ export * from './components/Link';
export * from './components/Select';
export * from './components/Skeleton';
export * from './components/Switch';
export * from './components/Toast';
export * from './components/ToggleButton';
export * from './components/ToggleButtonGroup';
export * from './components/VisuallyHidden';