How I built animated Toast component

Kamil Pyszkowski's pictureKamil Pyszkowski
8 mins

Toasts are a common UI pattern used to provide feedback to users in a non-intrusive way. They can be used to display success messages, errors, generally speaking - notifications. Conceptually it's quite simple - a message appears on the screen for a short period of time and then disappears. However, implementing a toast component with animations can be tricky, especially when it comes to managing timing, order, priority and accessibility. It was a great venture to build a toast component that is not only functional but also visually appealing and accessible. With use of modern tools it's really simple and effective.

Overview

The core of the component is Base UI primitive Toast component. It provides a solid foundation including accessibility features, orchestration and consistent imperative API which is ideal for toasts. It this particular case it means you call the function to trigger the toast which is uncommon in React where JSX is declarative by design.

You can think of the Toast component as a queue of messages that are displayed on top of the screen. Technically it uses Portal. State management is embedded in the primitive.

The Toast component I built consist of three main parts:

  • ToastProvider - a context provider to wrap the application
  • useToast - a hook to access the toast manager
  • toastManager - a global toast manager that can be accessed outside of React components

For available props and options reach out the Base UI documentation. Provider props allow you to set global timeout or limit the number of toasts visible at a time.

Animations are powered by Motion. But you can use plain CSS transitions if you wish. Toast primitive exposes useful data attributes to be used as selectors. Styles are written with Tailwind but you can use literally anything.

Timeout (ms)
3,000

Implementation

Let's get into this. First I will walk you through the structure. There are two main components - ToastProvider and ToastList.

Structure

The ToastProvider consists of Toast.Provider, Toast.Portal and ToastList. I don't have much to say here and it's beautyful. It clearly presents the benefits of primitive components. These 20 lines of code do all the heavy lifting.

1import { Toast } from '@base-ui-components/react/toast'
2
3export const toastManager = Toast.createToastManager()
4
5type ToastProviderProps = Toast.Provider.Props
6
7export function ToastProvider(props: ToastProviderProps) {
8 const { children, ...restProps } = props
9
10 return (
11 <Toast.Provider
12 toastManager={toastManager}
13 {...restProps}
14 >
15 {children}
16
17 <Toast.Portal>
18 <ToastList />
19 </Toast.Portal>
20 </Toast.Provider>
21 )
22}

The ToastList renders a Toast.Viewport and each toast by iterating over array of toasts. The general reason it got split like this was to access the context in the ToastList. The array is not available one level up because it's not yet in the scope of Toast.Provider.

1import { Toast } from '@base-ui-components/react/toast'
2import { tv } from 'tailwind-variants'
3import {
4 AnimatePresence,
5 motion,
6 type MotionProps,
7 type Transition,
8 type Variants,
9} from 'motion/react'
10
11const getStyles = tv({
12 slots: {
13 viewport: 'fixed right-5 bottom-5 w-full max-w-xs',
14 toastContainer: [
15 'absolute bottom-0 flex w-full flex-col rounded-xl border border-neutral-700 bg-neutral-800 p-4 shadow-lg',
16 // To create hoverable area between toasts preventing glitches when moving cursor between them
17 'before:absolute before:-bottom-3 before:left-0 before:h-3 before:w-full first:before:hidden',
18 ],
19 label: 'text-sm text-neutral-50',
20 description:
21 'overflow-hidden mask-r-from-90% text-sm text-ellipsis whitespace-nowrap text-neutral-400',
22 },
23})
24
25export function ToastList() {
26 const { toasts } = Toast.useToastManager()
27
28 const styles = getStyles()
29
30 return (
31 <Toast.Viewport className={styles.viewport()}>
32 {toasts.map((toast) => (
33 <Toast.Root
34 className={styles.toastContainer()}
35 swipeDirection={[]} // Disabled swipe gestures
36 key={toast.id}
37 toast={toast}
38 >
39 <Toast.Title className={styles.label()} />
40 <Toast.Description className={styles.description()} />
41 </Toast.Root>
42 ))}
43 </Toast.Viewport>
44 )
45}

Animations

That's pretty much all for the structure. It works and looks decent. The only missing part are animations and interactivity. The best part is the effect of stacked toasts which expands as you hover or focus on them. To do that let's enhance the ToastList with some animations.

First you need to understand the render prop provided by the Toast.Root primitive. It allows you to render the component as a complete custom component. It will allow you use motion component and implement animations. The render prop allows both ReactNode and a function which returns ReactNode. Since it's a function, you can access the state object which contains useful information about the toast such as expanded and limited. The only missing data bit is the index of the toast in the list but it can be easiliy accessed while mapping over the toasts array.

1import { Toast } from '@base-ui-components/react/toast'
2import {
3 AnimatePresence,
4 motion,
5 type MotionProps,
6 type Transition,
7 type Variants,
8} from 'motion/react'
9
10type ToastState = Toast.Root.State & {
11 index: number
12}
13
14const toastVariants: Variants = {
15 idle: (state: ToastState) => ({
16 y: state.expanded
17 ? `calc((-100% - 0.75rem) * ${state.index})`
18 : `${-16 * state.index}%`,
19 scale: state.expanded ? 1 : 1 - state.index * 0.1,
20 opacity: state.limited ? 0 : 1,
21 }),
22 slide: (state: ToastState) => ({
23 y: `calc((100% + 0.75rem) * ${state.index + 1})`,
24 opacity: 0,
25 }),
26}
27
28const toastTransition: Transition = {
29 type: 'spring',
30 damping: 24,
31 stiffness: 280,
32}
33
34function ToastList() {
35 const { toasts } = Toast.useToastManager()
36
37 const styles = getStyles()
38
39 return (
40 <Toast.Viewport className={styles.viewport()}>
41 <AnimatePresence mode="popLayout">
42 {toasts.map((toast, index) => (
43 <Toast.Root
44 className={styles.toastContainer()}
45 swipeDirection={[]} // Disabled swipe gestures
46 key={toast.id}
47 toast={toast}
48 render={(props, state) => (
49 <motion.div
50 custom={{ ...state, index }}
51 animate="idle"
52 initial="slide"
53 exit="slide"
54 variants={toastVariants}
55 transition={toastTransition}
56 {...(props as MotionProps)}
57 style={{
58 zIndex: 1000 - index,
59 }}
60 />
61 )}
62 >
63 <Toast.Title className={styles.label()} />
64 <Toast.Description className={styles.description()} />
65 </Toast.Root>
66 ))}
67 </AnimatePresence>
68 </Toast.Viewport>
69 )
70}

The animation itself is beginner friendly. Basically it's just a presence animation with conditional styles based on the toast state in idle variant. Since the toast slides up on mount and slides down on exit - it uses shared slide variant. The mode prop on AnimatePresence makes the mounting animations properly orchestrated without waiting for each toast to slide up or down.

The props spread over the motion.div is important as it contains internals of the Toast.Root component crucial for interactivity and accessibility. The only "internal" safe to overwrite is the style prop. It contains CSS variables that can be used for animations but since I used Motion, I don't need them. The zIndex is the only set to ensure that the toasts are stacked correctly.

How it works

That's it! The component is now fully functional with animations. Now let me explain how it works in practice. The only thing you need to do is to wrap your application with the ToastProvider and use the useToast hook to trigger toasts.

The example below shows how to use the useToast hook to trigger a toast when an HTTP Server-sent Event is received. The useToast hook provides an add function that can be used to trigger a toast with a message and options.

1import { useToast } from '~/components/toast'
2import { useTRPC } from '~/lib/trpc/client'
3import { useQueryClient } from '@tanstack/react-query'
4import { useSubscription } from '@trpc/tanstack-react-query'
5
6function Notifications() {
7 const trpc = useTRPC()
8 const queryClient = useQueryClient()
9
10 const toasts = useToast()
11
12 useSubscription(
13 trpc.notifications.onNotification.subscriptionOptions(undefined, {
14 onData: (notification) => {
15 toasts.add({
16 title: notification.data.label,
17 description: notification.data.description,
18 data: {
19 userId: notification.data.userId,
20 createdAt: notification.data.createdAt,
21 },
22 })
23
24 queryClient.setQueryData(
25 trpc.notifications.getAll.queryKey(),
26 (cachedData) => {
27 if (!cachedData) return [notification.data]
28 return [notification.data, ...cachedData]
29 },
30 )
31 },
32 }),
33 )
34
35 return ()
36}

As you can see, custom data can be passed to the toast. It can be used to render additional information in the toast or to handle actions within the toast. The fact API is imperative allows you to trigger toasts as a consequence of business logic, no side effects, no implicit state management such as isNotificationToastVisible monster. All clean. Briliant! 💎

Closing words

I didn't cover all the features Toast primitive provides, such swipe gestures or promise API. Feel free to explore the documentation for more details. There are dozens of possibilities and that's what I like the most about the concept of primitive components 🔥.

One last thing. You need to know that you don't have to build this component yourself. There is a Sonner library that provides almost identical component with similar API. The only drawbacks (at least to me) are that it's much more opinionated and much less customizable. Literally in minutes you can have exact the same result with much more flexibility. But you still have a choice 😉.

I hope you found this article helpful and that it added value to your learning journey. I'd love to hear your thoughts, feedback, or questions — feel free to reach out via email at . If you enjoyed this writing, take a moment to explore other articles. Don't forget to check back soon for fresh insights and updates!

See you around!
— Kamil

Fri, Jun 5, 2026, 1:55:51 AM
Genuinely crafted in Poland © 2026