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 applicationuseToast- a hook to access the toast managertoastManager- 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.
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'23export const toastManager = Toast.createToastManager()45type ToastProviderProps = Toast.Provider.Props67export function ToastProvider(props: ToastProviderProps) {8 const { children, ...restProps } = props910 return (11 <Toast.Provider12 toastManager={toastManager}13 {...restProps}14 >15 {children}1617 <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'1011const 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 them17 '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})2425export function ToastList() {26 const { toasts } = Toast.useToastManager()2728 const styles = getStyles()2930 return (31 <Toast.Viewport className={styles.viewport()}>32 {toasts.map((toast) => (33 <Toast.Root34 className={styles.toastContainer()}35 swipeDirection={[]} // Disabled swipe gestures36 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'910type ToastState = Toast.Root.State & {11 index: number12}1314const toastVariants: Variants = {15 idle: (state: ToastState) => ({16 y: state.expanded17 ? `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}2728const toastTransition: Transition = {29 type: 'spring',30 damping: 24,31 stiffness: 280,32}3334function ToastList() {35 const { toasts } = Toast.useToastManager()3637 const styles = getStyles()3839 return (40 <Toast.Viewport className={styles.viewport()}>41 <AnimatePresence mode="popLayout">42 {toasts.map((toast, index) => (43 <Toast.Root44 className={styles.toastContainer()}45 swipeDirection={[]} // Disabled swipe gestures46 key={toast.id}47 toast={toast}48 render={(props, state) => (49 <motion.div50 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'56function Notifications() {7 const trpc = useTRPC()8 const queryClient = useQueryClient()910 const toasts = useToast()1112 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 })2324 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 )3435 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 😉.