Improve hover animation on toast

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-27 17:53:53 +00:00
committed by Patrik Oldsberg
parent 69a323b9c4
commit 53def9cff7
7 changed files with 223 additions and 148 deletions
-1
View File
@@ -51,7 +51,6 @@
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"motion": "^12.29.2",
"react-aria-components": "^1.14.0"
},
"devDependencies": {
@@ -78,21 +78,53 @@
/* Stacking from bottom */
.bui-Toast {
--swipe-x: 0;
bottom: 0;
left: 0;
width: 100%;
transform-origin: bottom center;
transform: translateY(calc(var(--toast-index) * var(--toast-peek) * -1))
scale(var(--toast-scale));
opacity: calc(1 - (var(--toast-index) * 0.15));
will-change: transform;
}
/* Disable transitions during swipe for immediate feedback */
.bui-Toast[data-swiping] {
transition: none !important;
opacity: calc((1 - (var(--toast-index) * 0.15)) * (1 - (var(--swipe-x) / 400)));
}
/* Only first toast is interactive */
.bui-Toast[style*='--toast-index: 0'] {
/* Expanded state on hover - show all toasts stacked with gap */
.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);
opacity: 1;
pointer-events: auto;
}
/* Add padding above each toast when expanded to fill the gap */
.bui-ToastRegion:hover .bui-Toast::before,
.bui-ToastRegion:focus-within .bui-Toast::before,
.bui-ToastRegion[data-hover-locked] .bui-Toast::before {
content: '';
position: absolute;
bottom: 100%;
left: 0;
right: 0;
height: var(--bui-space-2);
}
.bui-Toast:not([style*='--toast-index: 0']) {
/* Only first toast (index 0) is interactive by default */
.bui-Toast {
pointer-events: none;
}
.bui-Toast[style*='--toast-index: 0'] {
pointer-events: auto;
}
/* Status variants */
.bui-Toast[data-status='info'] {
@@ -193,10 +225,21 @@
}
}
/* Starting state - toast entering */
.bui-Toast[data-starting-style] {
transform: translateY(calc(100% + 2rem)) scale(1) !important;
opacity: 0 !important;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.bui-Toast {
transition: none;
}
html.bui-toast-add::view-transition-new(*),
html.bui-toast-remove::view-transition-old(*) {
animation: none;
}
}
}
+135 -38
View File
@@ -14,10 +14,21 @@
* limitations under the License.
*/
import { forwardRef, Ref, isValidElement, ReactElement, useRef } from 'react';
import { motion } from 'motion/react';
import { useToast } from '@react-aria/toast';
import { useButton } from 'react-aria';
import {
forwardRef,
Ref,
isValidElement,
ReactElement,
useContext,
useState,
useEffect,
useRef,
} from 'react';
import {
UNSTABLE_Toast as RAToast,
UNSTABLE_ToastStateContext,
Button as RAButton,
} from 'react-aria-components';
import {
RiInformationLine,
RiCheckLine,
@@ -28,8 +39,6 @@ import {
import type { ToastProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ToastDefinition } from './definition';
import type { ToastState } from 'react-stately';
import type { ToastContent } from './types';
/**
* A Toast displays a brief, temporary notification of actions, errors, or other events in an application.
@@ -44,15 +53,15 @@ import type { ToastContent } from './types';
* @example
* Basic usage with queue:
* ```tsx
* import { queue } from '@backstage/ui';
* import { toastQueue } from '@backstage/ui';
*
* queue.add({ title: 'File saved successfully', status: 'success' });
* toastQueue.add({ title: 'File saved successfully', status: 'success' });
* ```
*
* @example
* With description and auto-dismiss:
* ```tsx
* queue.add(
* toastQueue.add(
* {
* title: 'Update available',
* description: 'A new version is ready to install.',
@@ -65,17 +74,80 @@ import type { ToastContent } from './types';
* @public
*/
export const Toast = forwardRef(
(props: ToastProps, _forwardedRef: Ref<HTMLDivElement>) => {
const { ownProps, dataAttributes } = useDefinition(ToastDefinition, props);
const { classes, toast, state, index = 0, status, icon } = ownProps;
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ToastDefinition,
props,
);
const { classes, toast, onSwipeEnd, status, icon } = ownProps;
const ref = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Get state from context
const state = useContext(UNSTABLE_ToastStateContext);
const { toastProps, titleProps, descriptionProps, closeButtonProps } =
useToast({ toast }, state, ref);
// Calculate index from state
const visibleToasts = state?.visibleToasts || [];
const arrayIndex = visibleToasts.findIndex(t => t.key === toast.key);
const index = arrayIndex >= 0 ? arrayIndex : 0;
const { buttonProps } = useButton(closeButtonProps, closeButtonRef);
// Track starting state for enter animation
const [isStarting, setIsStarting] = useState(true);
// Track swipe position
const [swipeX, setSwipeX] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const startXRef = useRef(0);
const toastRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Remove starting state after brief delay to trigger animation
const timer = setTimeout(() => setIsStarting(false), 50);
return () => clearTimeout(timer);
}, []);
const handlePointerDown = (e: React.PointerEvent) => {
// Check if region is hovered (expanded state)
const region = toastRef.current?.closest(
'[role="region"]',
) as HTMLElement;
setIsExpanded(region?.matches(':hover') || false);
setIsSwiping(true);
startXRef.current = e.clientX;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isSwiping) return;
const deltaX = e.clientX - startXRef.current;
// Only allow swipe right
if (deltaX > 0) {
setSwipeX(deltaX);
}
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!isSwiping) return;
setIsSwiping(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
// Notify parent that swipe ended to lock hover
onSwipeEnd?.();
// If swiped more than 150px, close the toast
if (swipeX > 150) {
// Animate off screen before closing
setSwipeX(400);
setTimeout(() => {
state?.close(toast.key);
}, 200);
} else {
// Spring back
setSwipeX(0);
}
};
// Get content from toast
const content = toast.content;
@@ -116,46 +188,71 @@ export const Toast = forwardRef(
const statusIcon = getStatusIcon();
return (
<motion.div
ref={ref}
<RAToast
toast={toast}
ref={node => {
if (toastRef) {
(
toastRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}
}}
className={classes.root}
style={
{
'--toast-index': index,
'--swipe-x': swipeX,
transform:
swipeX > 0
? isExpanded
? `translateX(${swipeX}px) translateY(calc((var(--toast-index) * -100%) - (var(--toast-index) * var(--bui-space-2)))) scale(1)`
: `translateX(${swipeX}px) translateY(calc(var(--toast-index) * var(--toast-peek) * -1)) scale(var(--toast-scale))`
: undefined,
} as React.CSSProperties
}
{...toastProps}
data-swiping={isSwiping ? '' : undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={() => {
setIsSwiping(false);
setSwipeX(0);
}}
{...dataAttributes}
data-status={finalStatus}
initial={{ y: '150%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '150%', opacity: 0 }}
transition={{
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
}}
data-starting-style={isStarting ? '' : undefined}
{...restProps}
>
<div className={classes.content}>
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
<div>
<div {...titleProps} className={classes.title}>
{content.title}
</div>
<div className={classes.title}>{content.title}</div>
{content.description && (
<div {...descriptionProps} className={classes.description}>
{content.description}
</div>
<div className={classes.description}>{content.description}</div>
)}
</div>
</div>
<button
{...buttonProps}
ref={closeButtonRef}
<RAButton
slot="close"
className={classes.closeButton}
onPress={() => {
// Lock hover first to prevent collapse
onSwipeEnd?.();
// Small delay before actually closing to let lock take effect
setTimeout(() => {
state?.close(toast.key);
}, 0);
}}
>
<RiCloseLine aria-hidden="true" />
</button>
</motion.div>
</RAButton>
</RAToast>
);
},
);
@@ -14,11 +14,9 @@
* limitations under the License.
*/
import { forwardRef, Ref, useEffect, useState, useRef } from 'react';
import { useToastRegion } from '@react-aria/toast';
import { AnimatePresence } from 'motion/react';
import type { QueuedToast, ToastState } from 'react-stately';
import type { ToastRegionProps, ToastContent } from './types';
import { forwardRef, Ref, useState, useRef } 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';
@@ -53,57 +51,58 @@ import { Toast } from './Toast';
* @public
*/
export const ToastRegion = forwardRef(
(props: ToastRegionProps, _ref: Ref<HTMLDivElement>) => {
(props: ToastRegionProps, ref: Ref<HTMLDivElement>) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
ToastRegionDefinition,
props,
);
const { classes, queue, className } = ownProps;
const [, forceUpdate] = useState({});
const ref = useRef<HTMLDivElement>(null);
// Lock hover state after swipe/close to prevent collapse
const [isHoverLocked, setIsHoverLocked] = useState(false);
const unlockTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Subscribe to queue changes to trigger re-renders
const unsubscribe = queue.subscribe(() => forceUpdate({}));
return unsubscribe;
}, [queue]);
const state: ToastState<ToastContent> = {
visibleToasts: queue.visibleToasts,
add: (content: ToastContent, options?: any) =>
queue.add(content, options),
close: (key: string) => queue.close(key),
pauseAll: () => queue.pauseAll(),
resumeAll: () => queue.resumeAll(),
const lockHover = () => {
setIsHoverLocked(true);
// Clear any pending unlock
if (unlockTimerRef.current) {
clearTimeout(unlockTimerRef.current);
}
};
const { regionProps } = useToastRegion({}, state, ref);
const unlockHover = (e: React.MouseEvent) => {
// Check if mouse is actually leaving the region bounds
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const isOutside =
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom;
if (!isOutside) {
// Mouse is still inside, don't unlock yet
return;
}
// Delay unlock to prevent collapse during DOM updates
unlockTimerRef.current = setTimeout(() => {
setIsHoverLocked(false);
}, 100);
};
return (
<div
{...regionProps}
<RAToastRegion
ref={ref}
queue={queue}
className={className || classes.region}
aria-label="Notifications"
data-hover-locked={isHoverLocked ? '' : undefined}
{...dataAttributes}
{...restProps}
onMouseLeave={unlockHover}
>
<ol style={{ margin: 0, padding: 0, listStyleType: 'none' }}>
<AnimatePresence mode="popLayout">
{queue.visibleToasts.map(
(toast: QueuedToast<ToastContent>, arrayIndex: number) => {
// Reverse index so newest toast (at end of array) gets index 0
const index = queue.visibleToasts.length - 1 - arrayIndex;
return (
<li key={toast.key} style={{ display: 'contents' }}>
<Toast toast={toast} index={index} state={state} />
</li>
);
},
)}
</AnimatePresence>
</ol>
</div>
{({ toast }) => <Toast toast={toast} onSwipeEnd={lockHover} />}
</RAToastRegion>
);
},
);
@@ -35,8 +35,7 @@ export const ToastDefinition = defineComponent<ToastOwnProps>()({
surface: 'container',
propDefs: {
toast: {},
state: {},
index: {},
onSwipeEnd: {},
status: { dataAttribute: true },
icon: {},
},
+2 -4
View File
@@ -41,10 +41,8 @@ export interface ToastContent {
export type ToastOwnProps = {
/** Toast object from the queue */
toast: QueuedToast<ToastContent>;
/** Toast state for hooks */
state: ToastState<ToastContent>;
/** Index of the toast in the stack (0 = frontmost) */
index: number;
/** Callback when swipe ends */
onSwipeEnd?: () => void;
/** Override status from content */
status?: Responsive<'info' | 'success' | 'warning' | 'danger'>;
/** Override icon from content */
-60
View File
@@ -8123,7 +8123,6 @@ __metadata:
eslint-plugin-storybook: "npm:^10.3.0-alpha.1"
glob: "npm:^11.0.1"
globals: "npm:^15.11.0"
motion: "npm:^12.29.2"
react: "npm:^18.0.2"
react-aria-components: "npm:^1.14.0"
react-dom: "npm:^18.0.2"
@@ -32469,28 +32468,6 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^12.29.2":
version: 12.29.2
resolution: "framer-motion@npm:12.29.2"
dependencies:
motion-dom: "npm:^12.29.2"
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/13ea43efa814e4df1f3a0bf7ee6c1736f3f01b96cb677e560b62d58db8768f00a53c17156bb5bf6bf06b786370468495dbb6b0a83e3b5b5289e5235046ccb03b
languageName: node
linkType: hard
"framer-motion@npm:^6.5.1":
version: 6.5.1
resolution: "framer-motion@npm:6.5.1"
@@ -39904,43 +39881,6 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^12.29.2":
version: 12.29.2
resolution: "motion-dom@npm:12.29.2"
dependencies:
motion-utils: "npm:^12.29.2"
checksum: 10/d1eeb6840363cc2b4d1e1c2d9a91becc117bb64747482b1a99f7f2d4da709f049c9544c3a566c641cada368037019c0fafd3e7cf56f0477e88d65e9886bcdc40
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.29.2":
version: 12.29.2
resolution: "motion@npm:12.29.2"
dependencies:
framer-motion: "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/0480a984192dd0f884709a007c1d28f31ff5fcdecb6674517edbe11ac0e318efc845373f35800e0606899613e6617ec87b1c09d5ecf3f5a5aeb3eb25fef22c7d
languageName: node
linkType: hard
"mri@npm:1.1.4":
version: 1.1.4
resolution: "mri@npm:1.1.4"