Improve style animation

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-03 17:28:51 +00:00
committed by Patrik Oldsberg
parent 6c18cb30b2
commit 7bef71f283
6 changed files with 129 additions and 37 deletions
@@ -25,7 +25,6 @@
transform: translateX(-50%);
z-index: 100050;
width: 300px;
pointer-events: none;
outline: none;
}
@@ -61,7 +60,8 @@
outline: none;
cursor: default;
user-select: none;
pointer-events: none;
/* All toasts need pointer-events to trigger hover expansion */
pointer-events: auto;
/* Stacking */
z-index: calc(1000 - var(--toast-index));
@@ -77,27 +77,16 @@
}
}
/* 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 {
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 {
/* Extend hover area above each toast to fill gaps when expanded */
/* This prevents the stack from collapsing when hovering between toasts */
.bui-Toast::before {
content: '';
position: absolute;
bottom: 100%;
left: 0;
right: 0;
height: var(--bui-space-2);
}
/* Only first toast (index 0) is interactive by default */
.bui-Toast[style*='--toast-index: 0'] {
/* Height matches the expanded gap (80px) to ensure continuous hover */
height: 12px;
pointer-events: auto;
}
@@ -27,20 +27,95 @@ const meta = preview.meta({
},
});
const randomToasts = [
// Title only - short
{ title: 'Saved', status: 'success' as const },
{ title: 'Error', status: 'danger' as const },
{ title: 'New notification', status: 'info' as const },
{ title: 'Warning', status: 'warning' as const },
// Title only - medium
{ title: 'Changes saved successfully', status: 'success' as const },
{ title: 'Connection restored', status: 'info' as const },
{ title: 'Action could not be completed', status: 'danger' as const },
// Title + short description
{
title: 'Files uploaded',
description: '3 files uploaded.',
status: 'success' as const,
},
{
title: 'Update available',
description: 'Version 2.0 is ready.',
status: 'info' as const,
},
{
title: 'Request failed',
description: 'Please try again.',
status: 'danger' as const,
},
{
title: 'Storage warning',
description: '90% used.',
status: 'warning' as const,
},
// Title + medium description
{
title: 'Deployment complete',
description:
'Your application has been deployed to production successfully.',
status: 'success' as const,
},
{
title: 'Session expiring',
description:
'Your session will expire in 5 minutes. Please save your work.',
status: 'warning' as const,
},
{
title: 'Permission denied',
description: 'You do not have access to perform this action.',
status: 'danger' as const,
},
// Title + long description
{
title: 'Sync completed',
description:
'All your files have been synchronized across devices. This includes 47 documents, 23 images, and 12 configuration files that were updated in the last hour.',
status: 'success' as const,
},
{
title: 'Rate limit exceeded',
description:
'You have exceeded the maximum number of API requests allowed. Please wait a few minutes before trying again or upgrade your plan for higher limits.',
status: 'warning' as const,
},
{
title: 'Critical error',
description:
'The server encountered an unexpected error while processing your request. Our team has been notified and is investigating the issue. Please try again later.',
status: 'danger' as const,
},
// Long title only
{
title: 'Your subscription has been renewed successfully for another year',
status: 'success' as const,
},
{
title: 'Multiple users are currently editing this document',
status: 'info' as const,
},
];
export const Default = meta.story({
render: () => (
<>
<ToastRegion queue={toastQueue} />
<Button
onPress={() =>
toastQueue.add(
{
title: 'Files uploaded',
description: '3 files uploaded successfully.',
},
{ timeout: 3000 },
)
}
onPress={() => {
const toast =
randomToasts[Math.floor(Math.random() * randomToasts.length)];
toastQueue.add(toast);
}}
>
Show Toast
</Button>
+21 -7
View File
@@ -75,6 +75,7 @@ export const Toast = forwardRef(
toast,
state,
index = 0,
isExpanded = false,
onClose,
status,
icon,
@@ -157,9 +158,17 @@ 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;
// Collapsed: each toast behind scales down 5% and peeks up 12px
const collapsedScale = Math.max(0, 1 - index * 0.05);
const collapsedY = -index * 12;
// Expanded: full scale, stacked vertically with gaps
// Each toast moves up by (toast height ~72px + gap 8px) * index
const expandedY = -index * 72;
// Use expanded or collapsed values based on hover state
const animateY = isExpanded ? expandedY : collapsedY;
const animateScale = isExpanded ? 1 : collapsedScale;
const stackZIndex = 1000 - index;
// Check if this toast is being manually closed
@@ -170,7 +179,12 @@ export const Toast = forwardRef(
// 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 };
: {
opacity: 0,
y: animateY + 50,
scale: animateScale,
zIndex: stackZIndex,
};
return (
<motion.div
@@ -186,8 +200,8 @@ export const Toast = forwardRef(
initial={{ opacity: 0, y: 100, scale: 1 }}
animate={{
opacity: 1,
y: stackY,
scale: stackScale,
y: animateY,
scale: animateScale,
zIndex: stackZIndex,
}}
exit={exitAnimation}
@@ -197,7 +211,7 @@ export const Toast = forwardRef(
manuallyClosingToasts.delete(toast.key);
}
}}
transition={{ type: 'tween', duration: 2, ease: 'easeOut' }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
{...dataAttributes}
data-status={finalStatus}
{...restProps}
@@ -69,10 +69,16 @@ export const ToastRegion = forwardRef(
// Get ARIA props for the toast region
const { regionProps } = useToastRegion({}, state, regionRef);
// Lock hover state after close to prevent stack collapse during DOM updates
// Track hover state for expanding/collapsing the stack
const [isHovered, setIsHovered] = useState(false);
// Lock expanded state after close to prevent stack collapse during exit animation
const [isHoverLocked, setIsHoverLocked] = useState(false);
const unlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Toasts are expanded when hovered, focused, or locked
const isExpanded = isHovered || isHoverLocked;
// Get inverted theme mode for toasts (light when app is dark, dark when app is light)
const invertedThemeMode = useInvertedThemeMode();
@@ -85,10 +91,10 @@ export const ToastRegion = forwardRef(
clearTimeout(unlockTimerRef.current);
}
// Unlock after a short delay to allow DOM to settle
// Unlock after a short delay to allow exit animation to complete
unlockTimerRef.current = setTimeout(() => {
setIsHoverLocked(false);
}, 300);
}, 500);
};
return (
@@ -98,6 +104,10 @@ export const ToastRegion = forwardRef(
className={className || classes.region}
data-theme-mode={invertedThemeMode}
data-hover-locked={isHoverLocked ? '' : undefined}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsHovered(true)}
onBlur={() => setIsHovered(false)}
{...dataAttributes}
{...restProps}
>
@@ -108,6 +118,7 @@ export const ToastRegion = forwardRef(
toast={toast}
state={state}
index={index}
isExpanded={isExpanded}
onClose={handleClose}
/>
))}
@@ -37,6 +37,7 @@ export const ToastDefinition = defineComponent<ToastOwnProps>()({
toast: {},
state: {},
index: {},
isExpanded: {},
onClose: {},
status: { dataAttribute: true },
icon: {},
@@ -44,6 +44,8 @@ export type ToastOwnProps = ContainerSurfaceProps & {
state: ToastState<ToastContent>;
/** Index of the toast in the visible toasts array */
index?: number;
/** Whether the toast stack is expanded (hovered/focused) */
isExpanded?: boolean;
/** Callback when toast is closed */
onClose?: () => void;
/** Override status from content */