Move to motion to animate toast

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-03 16:58:44 +00:00
committed by Patrik Oldsberg
parent 2aec9470cc
commit 6c18cb30b2
8 changed files with 212 additions and 167 deletions
+3 -1
View File
@@ -51,7 +51,9 @@
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"react-aria-components": "^1.14.0"
"motion": "^12.0.0",
"react-aria-components": "^1.14.0",
"react-stately": "^3.35.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
@@ -66,19 +66,10 @@
/* Stacking */
z-index: calc(1000 - var(--toast-index));
transform-origin: bottom center;
transform: translateY(calc(var(--toast-index) * var(--toast-peek) * -1))
scale(var(--toast-scale));
will-change: transform;
/* Shadow + thin line separator */
/* Shadow */
box-shadow: 0 4px 12px -2px rgba(0 0 0 / 0.4);
/* Animation */
transition: transform 3s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 3s ease;
/* View Transitions - name is set dynamically via inline style, class groups them */
view-transition-class: toast;
/* Focus ring */
&:focus-visible {
outline: 2px solid var(--bui-border-focus);
@@ -90,13 +81,6 @@
.bui-ToastRegion:hover .bui-Toast,
.bui-ToastRegion:focus-within .bui-Toast,
.bui-ToastRegion[data-hover-locked] .bui-Toast {
transform: translateY(
calc(
(var(--toast-index) * -100%) -
(var(--toast-index) * var(--bui-space-2))
)
)
scale(1);
pointer-events: auto;
}
@@ -213,53 +197,4 @@
background-color: var(--bui-bg-neutral-on-surface-1-pressed);
}
}
/* Starting state - toast entering (fallback for non-view-transition browsers) */
.bui-Toast[data-starting-style] {
transform: translateY(calc(100% + 2rem)) scale(1) !important;
opacity: 0 !important;
}
}
/* View Transition animations - must be outside @layer for browser support */
@keyframes bui-toast-enter {
from {
opacity: 0;
transform: translateY(100px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bui-toast-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(100px);
}
}
/* Apply exit animation only to the closing toast */
::view-transition-old(toast-exit) {
animation: bui-toast-exit 3s ease-out forwards;
}
::view-transition-new(toast-exit) {
animation: none;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.bui-Toast {
transition: none;
}
::view-transition-old(toast-exit) {
animation: none !important;
}
}
+94 -44
View File
@@ -14,20 +14,10 @@
* limitations under the License.
*/
import {
forwardRef,
Ref,
isValidElement,
ReactElement,
useContext,
useState,
useEffect,
} from 'react';
import {
UNSTABLE_Toast as RAToast,
UNSTABLE_ToastStateContext,
Button as RAButton,
} from 'react-aria-components';
import { forwardRef, Ref, isValidElement, ReactElement, useRef } from 'react';
import { useToast } from '@react-aria/toast';
import { useButton } from 'react-aria';
import { motion } from 'motion/react';
import {
RiInformationLine,
RiCheckLine,
@@ -39,6 +29,10 @@ import type { ToastProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ToastDefinition } from './definition';
// Track which toasts are being manually closed (vs auto-timeout)
// This allows different exit animations for each case
const manuallyClosingToasts = new Set<string>();
/**
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
*
@@ -47,8 +41,6 @@ import { ToastDefinition } from './definition';
* 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
@@ -78,24 +70,53 @@ export const Toast = forwardRef(
ToastDefinition,
props,
);
const { classes, toast, onClose, status, icon } = ownProps;
const {
classes,
toast,
state,
index = 0,
onClose,
status,
icon,
} = ownProps;
// Get state from context
const state = useContext(UNSTABLE_ToastStateContext);
// Use internal ref if none provided
const internalRef = useRef<HTMLDivElement>(null);
const toastRef = (ref as React.RefObject<HTMLDivElement>) || internalRef;
// Calculate index from state
const visibleToasts = state?.visibleToasts || [];
const arrayIndex = visibleToasts.findIndex(t => t.key === toast.key);
const index = arrayIndex >= 0 ? arrayIndex : 0;
// Get ARIA props from useToast hook
const { toastProps, titleProps, closeButtonProps } = useToast(
{ toast },
state,
toastRef,
);
// Track starting state for enter animation
const [isStarting, setIsStarting] = useState(true);
// Extract only ARIA and accessibility props from toastProps to avoid
// conflicts with motion.div's event handler types (motion has its own drag API)
const ariaProps = {
role: toastProps.role,
tabIndex: toastProps.tabIndex,
'aria-label': toastProps['aria-label'],
'aria-labelledby': toastProps['aria-labelledby'],
'aria-describedby': toastProps['aria-describedby'],
'aria-posinset': toastProps['aria-posinset'],
'aria-setsize': toastProps['aria-setsize'],
};
useEffect(() => {
// Remove starting state after brief delay to trigger animation
const timer = setTimeout(() => setIsStarting(false), 50);
return () => clearTimeout(timer);
}, []);
// Close button ref and props
const closeButtonRef = useRef<HTMLButtonElement>(null);
const { buttonProps } = useButton(
{
...closeButtonProps,
onPress: () => {
// Mark this toast as manually closed for exit animation
manuallyClosingToasts.add(toast.key);
onClose?.();
state.close(toast.key);
},
},
closeButtonRef,
);
// Get content from toast
const content = toast.content;
@@ -135,42 +156,71 @@ export const Toast = forwardRef(
const statusIcon = getStatusIcon();
// Calculate stacking values based on index
// Each toast behind scales down 5% and moves up 12px
const stackScale = Math.max(0, 1 - index * 0.05);
const stackY = -index * 12;
const stackZIndex = 1000 - index;
// Check if this toast is being manually closed
const isManualClose = manuallyClosingToasts.has(toast.key);
// Different exit animations for manual close vs auto-timeout
// Manual close: slide down from front, stay on top
// Auto-timeout: fade out in place, stay in stack position
const exitAnimation = isManualClose
? { opacity: 0, y: 100, scale: 1, zIndex: 2000 }
: { opacity: 0, y: stackY + 50, scale: stackScale, zIndex: stackZIndex };
return (
<RAToast
toast={toast}
ref={ref}
<motion.div
{...ariaProps}
ref={toastRef}
className={classes.root}
style={
{
'--toast-index': index,
} as React.CSSProperties
}
layout
initial={{ opacity: 0, y: 100, scale: 1 }}
animate={{
opacity: 1,
y: stackY,
scale: stackScale,
zIndex: stackZIndex,
}}
exit={exitAnimation}
onAnimationComplete={definition => {
// Clean up the manual close tracking after exit animation
if (definition === 'exit') {
manuallyClosingToasts.delete(toast.key);
}
}}
transition={{ type: 'tween', duration: 2, ease: 'easeOut' }}
{...dataAttributes}
data-toast-key={toast.key}
data-status={finalStatus}
data-starting-style={isStarting ? '' : undefined}
{...restProps}
>
<div className={classes.content}>
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
<div>
<div className={classes.title}>{content.title}</div>
<div {...titleProps} className={classes.title}>
{content.title}
</div>
{content.description && (
<div className={classes.description}>{content.description}</div>
)}
</div>
</div>
<RAButton
slot="close"
<button
{...buttonProps}
ref={closeButtonRef}
className={classes.closeButton}
onPress={() => {
onClose?.();
state?.close(toast.key);
}}
>
<RiCloseLine aria-hidden="true" />
</RAButton>
</RAToast>
</button>
</motion.div>
);
},
);
+4 -42
View File
@@ -14,18 +14,15 @@
* limitations under the License.
*/
import { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components';
import { flushSync } from 'react-dom';
import { ToastQueue } from 'react-stately';
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.
*
* Uses the View Transitions API for smooth enter/exit animations when supported.
* This uses React Stately's ToastQueue for state management with motion/react
* for smooth enter/exit animations.
*
* @example
* ```tsx
@@ -45,41 +42,6 @@ import type { ToastContent } from './types';
*
* @public
*/
// Track which toast element is being closed for view transition
let closingToastElement: HTMLElement | null = null;
export const toastQueue = new RAToastQueue<ToastContent>({
export const toastQueue = new ToastQueue<ToastContent>({
maxVisibleToasts: 5,
// Wrap state updates in a CSS view transition for smooth animations
wrapUpdate(fn) {
if (closingToastElement && 'startViewTransition' in document) {
// Set view-transition-name on the element BEFORE capturing old state
closingToastElement.style.viewTransitionName = 'toast-exit';
const element = closingToastElement;
closingToastElement = null;
(
document as Document & {
startViewTransition: (cb: () => void) => void;
}
).startViewTransition(() => {
flushSync(fn);
// Clean up (element may be removed, but just in case)
element.style.viewTransitionName = '';
});
} else {
fn();
}
},
});
// Override close to capture the toast element before transition
const originalClose = toastQueue.close.bind(toastQueue);
toastQueue.close = (key: string) => {
// Find the toast element by our custom data attribute
const toastElement = document.querySelector(
`[data-toast-key="${key}"]`,
) as HTMLElement | null;
closingToastElement = toastElement;
originalClose(key);
};
@@ -15,7 +15,9 @@
*/
import { forwardRef, Ref, useState, useRef } from 'react';
import { UNSTABLE_ToastRegion as RAToastRegion } from 'react-aria-components';
import { useToastRegion } from '@react-aria/toast';
import { useToastQueue } from 'react-stately';
import { AnimatePresence } from 'motion/react';
import type { ToastRegionProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode';
@@ -32,8 +34,6 @@ import { Toast } from './Toast';
* 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
@@ -59,6 +59,16 @@ export const ToastRegion = forwardRef(
);
const { classes, queue, className } = ownProps;
// Subscribe to the toast queue state
const state = useToastQueue(queue);
// Use internal ref if none provided
const internalRef = useRef<HTMLDivElement>(null);
const regionRef = (ref as React.RefObject<HTMLDivElement>) || internalRef;
// Get ARIA props for the toast region
const { regionProps } = useToastRegion({}, state, regionRef);
// Lock hover state after close to prevent stack collapse during DOM updates
const [isHoverLocked, setIsHoverLocked] = useState(false);
const unlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -82,18 +92,27 @@ export const ToastRegion = forwardRef(
};
return (
<RAToastRegion
ref={ref}
queue={queue}
<div
{...regionProps}
ref={regionRef}
className={className || classes.region}
aria-label="Notifications"
data-theme-mode={invertedThemeMode}
data-hover-locked={isHoverLocked ? '' : undefined}
{...dataAttributes}
{...restProps}
>
{({ toast }) => <Toast toast={toast} onClose={handleClose} />}
</RAToastRegion>
<AnimatePresence>
{state.visibleToasts.map((toast, index) => (
<Toast
key={toast.key}
toast={toast}
state={state}
index={index}
onClose={handleClose}
/>
))}
</AnimatePresence>
</div>
);
},
);
@@ -35,6 +35,8 @@ export const ToastDefinition = defineComponent<ToastOwnProps>()({
surface: 'container',
propDefs: {
toast: {},
state: {},
index: {},
onClose: {},
status: { dataAttribute: true },
icon: {},
+6 -3
View File
@@ -15,8 +15,7 @@
*/
import type { ReactElement, ReactNode } from 'react';
import type { UNSTABLE_ToastQueue as RAToastQueue } from 'react-aria-components';
import type { QueuedToast } from 'react-stately';
import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
import type { Responsive, ContainerSurfaceProps } from '../../types';
/**
@@ -41,6 +40,10 @@ export interface ToastContent {
export type ToastOwnProps = ContainerSurfaceProps & {
/** Toast object from the queue */
toast: QueuedToast<ToastContent>;
/** Toast state from useToastQueue */
state: ToastState<ToastContent>;
/** Index of the toast in the visible toasts array */
index?: number;
/** Callback when toast is closed */
onClose?: () => void;
/** Override status from content */
@@ -61,7 +64,7 @@ export interface ToastProps extends ToastOwnProps {}
*/
export type ToastRegionOwnProps = {
/** Toast queue instance */
queue: RAToastQueue<ToastContent>;
queue: ToastQueue<ToastContent>;
/** Custom class name */
className?: string;
};
+74 -2
View File
@@ -8123,10 +8123,12 @@ __metadata:
eslint-plugin-storybook: "npm:^10.3.0-alpha.1"
glob: "npm:^11.0.1"
globals: "npm:^15.11.0"
motion: "npm:^12.0.0"
react: "npm:^18.0.2"
react-aria-components: "npm:^1.14.0"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.30.2"
react-stately: "npm:^3.35.0"
storybook: "npm:^10.3.0-alpha.1"
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
@@ -25778,7 +25780,18 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4":
"axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4":
version: 1.13.4
resolution: "axios@npm:1.13.4"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10/54b7ef71c64837f9d52475832337f520cf6fa85c94612e03a3a2aad7082804a2544741267122696662147e90e6d2746601346984cf531ae715ecdb56d586a04c
languageName: node
linkType: hard
"axios@npm:^1.12.0":
version: 1.13.5
resolution: "axios@npm:1.13.5"
dependencies:
@@ -32468,6 +32481,28 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^12.31.0":
version: 12.31.0
resolution: "framer-motion@npm:12.31.0"
dependencies:
motion-dom: "npm:^12.30.1"
motion-utils: "npm:^12.29.2"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10/8cd76953b5e4e81e69b7bbec699cd5c913df87897148cd0f9fe85fa2f1c4e2768fbaeb6e40be9cf6d3f4b17a5a8024351d7e0ba93a9cdf9d8358c5031c5cdecd
languageName: node
linkType: hard
"framer-motion@npm:^6.5.1":
version: 6.5.1
resolution: "framer-motion@npm:6.5.1"
@@ -39881,6 +39916,43 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^12.30.1":
version: 12.30.1
resolution: "motion-dom@npm:12.30.1"
dependencies:
motion-utils: "npm:^12.29.2"
checksum: 10/22af7e074b388485b8f383bc9a8a3d03dec51f2e7112a3b50b9fde7076531ec0fccbeb61c669a439eb0a535992b121a48e612901b79f139538697755c291400e
languageName: node
linkType: hard
"motion-utils@npm:^12.29.2":
version: 12.29.2
resolution: "motion-utils@npm:12.29.2"
checksum: 10/ae5f9be58c07939af72334894ed1a18653d724946182a718dc3d11268ef26e63804c3f16dee5a6110596d4406b539c4513822b74f86adebef9488601c34b18b7
languageName: node
linkType: hard
"motion@npm:^12.0.0":
version: 12.31.0
resolution: "motion@npm:12.31.0"
dependencies:
framer-motion: "npm:^12.31.0"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10/abb2253b2e679f3d153e472f63e29fa0f3d9d4dd6bb2f2b0c9026897dd069a2371ddc2374bf70debdd480351ad5311e830ebf98e5ea7a44588e44099381e950b
languageName: node
linkType: hard
"mri@npm:1.1.4":
version: 1.1.4
resolution: "mri@npm:1.1.4"
@@ -44699,7 +44771,7 @@ __metadata:
languageName: node
linkType: hard
"react-stately@npm:^3.43.0":
"react-stately@npm:^3.35.0, react-stately@npm:^3.43.0":
version: 3.43.0
resolution: "react-stately@npm:3.43.0"
dependencies: