From 53def9cff772e7dd6a63c9ba1d5baa29091e80bf Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 27 Jan 2026 17:53:53 +0000 Subject: [PATCH] Improve hover animation on toast Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 - .../ui/src/components/Toast/Toast.module.css | 49 ++++- packages/ui/src/components/Toast/Toast.tsx | 173 ++++++++++++++---- .../ui/src/components/Toast/ToastRegion.tsx | 79 ++++---- .../ui/src/components/Toast/definition.ts | 3 +- packages/ui/src/components/Toast/types.ts | 6 +- yarn.lock | 60 ------ 7 files changed, 223 insertions(+), 148 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 5749d55c22..a253de0532 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": { diff --git a/packages/ui/src/components/Toast/Toast.module.css b/packages/ui/src/components/Toast/Toast.module.css index efdcf1bea1..1709bd0241 100644 --- a/packages/ui/src/components/Toast/Toast.module.css +++ b/packages/ui/src/components/Toast/Toast.module.css @@ -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; + } } } diff --git a/packages/ui/src/components/Toast/Toast.tsx b/packages/ui/src/components/Toast/Toast.tsx index c78f970901..70b133c4e0 100644 --- a/packages/ui/src/components/Toast/Toast.tsx +++ b/packages/ui/src/components/Toast/Toast.tsx @@ -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) => { - const { ownProps, dataAttributes } = useDefinition(ToastDefinition, props); - const { classes, toast, state, index = 0, status, icon } = ownProps; + (props: ToastProps, ref: Ref) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + ToastDefinition, + props, + ); + const { classes, toast, onSwipeEnd, status, icon } = ownProps; - const ref = useRef(null); - const closeButtonRef = useRef(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(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 ( - { + if (toastRef) { + ( + toastRef as React.MutableRefObject + ).current = node; + } + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + (ref as React.MutableRefObject).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} >
{statusIcon &&
{statusIcon}
}
-
- {content.title} -
+
{content.title}
{content.description && ( -
- {content.description} -
+
{content.description}
)}
- -
+ + ); }, ); diff --git a/packages/ui/src/components/Toast/ToastRegion.tsx b/packages/ui/src/components/Toast/ToastRegion.tsx index 0e2e32322f..6e4745dd6e 100644 --- a/packages/ui/src/components/Toast/ToastRegion.tsx +++ b/packages/ui/src/components/Toast/ToastRegion.tsx @@ -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) => { + (props: ToastRegionProps, ref: Ref) => { const { ownProps, restProps, dataAttributes } = useDefinition( ToastRegionDefinition, props, ); const { classes, queue, className } = ownProps; - const [, forceUpdate] = useState({}); - const ref = useRef(null); + // Lock hover state after swipe/close to prevent collapse + const [isHoverLocked, setIsHoverLocked] = useState(false); + const unlockTimerRef = useRef(null); - useEffect(() => { - // Subscribe to queue changes to trigger re-renders - const unsubscribe = queue.subscribe(() => forceUpdate({})); - return unsubscribe; - }, [queue]); - - const state: ToastState = { - 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 ( -
-
    - - {queue.visibleToasts.map( - (toast: QueuedToast, arrayIndex: number) => { - // Reverse index so newest toast (at end of array) gets index 0 - const index = queue.visibleToasts.length - 1 - arrayIndex; - return ( -
  1. - -
  2. - ); - }, - )} -
    -
-
+ {({ toast }) => } + ); }, ); diff --git a/packages/ui/src/components/Toast/definition.ts b/packages/ui/src/components/Toast/definition.ts index a149cc1521..7d9f651157 100644 --- a/packages/ui/src/components/Toast/definition.ts +++ b/packages/ui/src/components/Toast/definition.ts @@ -35,8 +35,7 @@ export const ToastDefinition = defineComponent()({ surface: 'container', propDefs: { toast: {}, - state: {}, - index: {}, + onSwipeEnd: {}, status: { dataAttribute: true }, icon: {}, }, diff --git a/packages/ui/src/components/Toast/types.ts b/packages/ui/src/components/Toast/types.ts index dc0d9b667f..d4947a7df7 100644 --- a/packages/ui/src/components/Toast/types.ts +++ b/packages/ui/src/components/Toast/types.ts @@ -41,10 +41,8 @@ export interface ToastContent { export type ToastOwnProps = { /** Toast object from the queue */ toast: QueuedToast; - /** Toast state for hooks */ - state: ToastState; - /** 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 */ diff --git a/yarn.lock b/yarn.lock index bab3d96844..1ce7bc74dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"