Toast

Exploration of a notification component.

We recently released a new version of our toast component at Compound. The toast you are seeing here is a bit different, it uses framer motion's drag gesture for the swipe to dismiss functionality, the rest is just CSS transitions.

In this post I'll explain how I built it and talk about some challenges along the way.

CSS transitions

We use a CSS variable called var(--y) which is the translateY() value of each toast. We change its value through data attributes like data-hovering. CSS animations are used for the swipe out and exit animations.

data-front={isFront}
data-state={isOpen ? 'open' : 'closed'}
data-hovering={isHovering}
data-hidden={index > 2}

Stacked view

We lift each toast by 15px times its index, this means that the second toast gets lifted by 15px, third by 30px and so on. Scale of each toast also gets changed by -0.05 times the index. This is how the CSS looks:

transform: var(--y) scale(calc(-1 * var(--index) * 0.05 + 1));

Not every toast has the same height which means that if we had 3 toasts with different heights some would stick out more than others. To fix this we set the height of all toasts to the height of the first one when in the stacked state (not hovering). Try the incorrect behavior below.

Here's the correct behavior:

Hover animation

To calculate the right position of toasts while hovering we add the height of each toast to the heights array on mount. We also attach id of the toast to the height object as we will need to know which height to remove later.

useEffect(() => {
const node = toastRef.current;
if (node) {
setHeights((heights) => [{ height: node.clientHeight, toastId }, ...heights]);
}
}, [setHeights, toast]);

We can now calculate the height of all the toasts before the current one.

const toastsHeightBefore = heights.reduce((prev, curr, reducerIndex) => {
if (reducerIndex >= index) {
return prev;
}
return prev + curr.height;
}, 0);

We also need to add the gap between the toasts, we just multiply the gap constant (which is 20px in this case) by the index of the current toast. We then use this offset variable as our translateY() value to move the toast on hover.

const offset = index * GAP + toastsHeightBefore;

Removing toasts

When a toast is going to be removed we set the isOpen state to false and remove the height of the toast from the heights array. When the isOpen state changes to false we trigger a css animation.

[data-state='closed'] {
animation: fadeOut 200ms ease-out;
}

We also listen for the animationend event, when an animation ends we remove the toast from the toasts array. This allows us to have an exit transition before we remove the toast from the DOM.

useEffect(() => {
const node = toastRef.current;
const handleAnimationEnd = () => removeToast(toast);
if (node) {
node.addEventListener('animationend', handleAnimationEnd);
}
return () => node?.removeEventListener('animationend', handleAnimationEnd);
}, [removeToast, toast]);

Swiping

At Compound, we use Radix's swipe gesture that comes with their toast component to enable swiping. The one here is just pure exploration from my side. I decided to try framer motion's drag gesture, it works well, but it doesn't fit perfectly with the rest of the implementation .

CSS transitions are used for all the other animations here, framer motion applies inline styling when animating so it overrides other styles that are responsible for toast's y position for example. We need to preserve the y position by using the whileDrag prop.

whileDrag={{ y: `calc(-1 * ${offset}px)` }}

The current implementation just removes the toast when the onDragEnd callback fires. We could improve it by only removing the toast when you've dragged for more than 20px and not allowing to swipe to the left at all. It's imperfect, there's definitely room for improvemets.

Summary

Building this component was fun, really enjoyed using just CSS to achieve it. You can expect more of these "experiments" in the future.