DEV Community

Cover image for How to create a notification/toast system in React Typescript with Redux Toolkit, Tailwind and Framer Motion
Christoffer Berg
Christoffer Berg

Posted on

How to create a notification/toast system in React Typescript with Redux Toolkit, Tailwind and Framer Motion

In this short article we'll be building a Notification/Toast component.

The goal of this article is simply to provide inspiration on how to build a component like this. Nothing in this article is highly opinionated so please use another state manager, another file structure, another style system if you wish.

This type of component has been described with many different names, and the different names might all signal various things to different people, but what this article will be addressing is a basic component that simply informs the user of something as a response to any given action e.g. updating profile information etc.

You can find the finished demo and code beneath.

Demo: Here
Github repository: Here

We will be building 4 variants of the Notification component – Success, Warning, Error and Info.

The article will be a quick run through of code and so it is required to have basic knowledge on a modern React based development setup and the tools used, as I won't describe the different parts in depth.

Tools used:

Next.js
Redux Toolkit
Framer Motion
Tailwind
Radix UI
Radix colors
react-use
clsx
lodash
ms

npx create-next-app@latest --typescript name-of-project
Enter fullscreen mode Exit fullscreen mode

Basic setup and Redux Toolkit

After bootstrapping a Next.js project with typescript, we'll start by setting up Redux, and for this we'll be using the official, opinionated, batteries-included toolset for efficient Redux development: Redux Toolkit.

From here on, create a src folder and inside src create an app folder, a features folder and then a redux folder. Also move the default Next.js pages folder inside src.

This will be our basic project structure.
It does not matter how you structure the project, or how you prefer to name folders – this is just a general baseline that I like to use.

Each feature will be put into the features folder, and have its own components, hooks and Redux state slice. We will consider Notifications to be an app "feature".

Folder structure

Inside the redux folder we'll create 3 files: hooks.ts, rootReducer.ts and store.ts.

These 3 files will contain our basic Redux setup.

The store.ts file will contain the basic setup of our global Redux store. It will contain our different reducers, and export different type helpers, that'll be used throughout the project.

// src/redux/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'
import { rootReducer } from '@redux/rootReducer'

export const store = configureStore({
  reducer: rootReducer,
})

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>
Enter fullscreen mode Exit fullscreen mode

Notice the @redux/rootreducer import. tsconfig paths has been used for this. Please see the tsconfig.json and tsconfig.paths.json file in the repo.

Now inside rootReducer.ts we'll setup our Redux root reducer, that will contain all of the different reducers one might create throughout a project.

// src/redux/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit'

import notificationsReducer from '@features/notifications/notification.slice'

export const rootReducer = combineReducers({
  notifications: notificationsReducer,
})
Enter fullscreen mode Exit fullscreen mode

The rootReducer is importing a notificationsReducer that hasn't been created yet. We will create this soon.

Lastly inside hooks.ts we'll export general Redux hooks to use throughout the project.

// src/redux/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from '@redux/store'

export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
Enter fullscreen mode Exit fullscreen mode

The hooks are basically just adding type safety to regular Redux hooks.

All of this basic setup can be found in the Redux Toolkit documentation.

Creating our Notifications Redux slice

Under features we'll create our notifications feature and inside that feature a notifications.slice.ts file, that will contain all the Redux logic for our toasts/notifications.

We'll start by defining how our Notifications state should look like, and the state slice itself.

// src/features/notifications/notifications.slice.ts
type NotificationsState = {
  notifications: Notification[]
}

const initialState: NotificationsState = {
  notifications: [],
}

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {},
})
Enter fullscreen mode Exit fullscreen mode

The Notification type that we use in the State slice will be defined in the Notification component itself later on. It looks like this:

// src/features/notifications/NotificationItem.tsx
export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

Enter fullscreen mode Exit fullscreen mode

We'll then add our different reducers to handle adding/dismissing a notification.

// src/features/notifications/notifications.slice.ts
const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {
    /**
     * Add a notification to the list
     *
     * @param state - Our current Redux state
     * @param payload - A notification item without an id, as we'll generate this.
     */
    addNotification: (
      state,
      { payload }: PayloadAction<Omit<Notification, 'id'>>
    ) => {
      const notification: Notification = {
        id: nanoid(),
        ...payload,
      }

      state.notifications.push(notification)
    },
    /**
     * Remove a notification from the list
     *
     * @param state - Our current Redux state
     * @param payload - The id of the Notification to dismiss
     */
    dismissNotification: (
      state,
      { payload }: PayloadAction<Notification['id']>
    ) => {
      const index = state.notifications.findIndex(
        (notification) => notification.id === payload
      )

      if (index !== -1) {
        state.notifications.splice(index, 1)
      }
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

We our reducer logic in place, we'll finish off the notifications state slice by creating and exporting a selector function to select the notifications state, and a hook to easily use it in our React components.

We'll also export the reducer itself and the accompanying Redux actions.

The full file looks like this:

// src/features/notifications/notifications.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { nanoid } from 'nanoid'

import type { Notification } from '@features/notifications/components/NotificationItem'
import type { RootState } from '@redux/store'
import { useAppSelector } from '@redux/hooks'

type NotificationsState = {
  notifications: Notification[]
}

const initialState: NotificationsState = {
  notifications: [],
}

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {
    /**
     * Add a notification to the list
     *
     * @param state - Our current Redux state
     * @param payload - A notification item without an id, as we'll generate this.
     */
    addNotification: (
      state,
      { payload }: PayloadAction<Omit<Notification, 'id'>>
    ) => {
      const notification: Notification = {
        id: nanoid(),
        ...payload,
      }

      state.notifications.push(notification)
    },
    /**
     * Remove a notification from the list
     *
     * @param state - Our current Redux state
     * @param payload - The id of the Notification to dismiss
     */
    dismissNotification: (
      state,
      { payload }: PayloadAction<Notification['id']>
    ) => {
      const index = state.notifications.findIndex(
        (notification) => notification.id === payload
      )

      if (index !== -1) {
        state.notifications.splice(index, 1)
      }
    },
  },
})

const { reducer, actions } = notificationsSlice

// Actions
export const { addNotification, dismissNotification } = actions

// Selectors
const selectNotifications = (state: RootState) =>
  state.notifications.notifications

// Hooks
export const useNotifications = () => useAppSelector(selectNotifications)

export default reducer

Enter fullscreen mode Exit fullscreen mode

Create Notifications components

Under src/features/notifications create a components folder. This is where we'll be putting all components related to our Notifications feature.

We will be creating 3 components.

Notifications.tsx, NotificationList.tsx and lastly NotificationItem.tsx.

Notification components

Our Parent Notifications.tsx component will subscribe to our Notifications state slice, output the NotificationList component and map over the notifications list that lives inside our Redux slice to render multiple NotificationItems as children inside the NotificationList.

Notifications parent component

// src/features/ntoifications/components/Notifications.tsx
import { useNotifications } from '@features/notifications/notification.slice'

import { NotificationItem } from '@features/notifications/components/NotificationItem'
import { NotificationList } from '@features/notifications/components/NotificationList'

export const Notifications = () => {
  const notifications = useNotifications()

  return (
    <NotificationList>
      {notifications.map((notification) => (
        <NotificationItem key={notification.id} notification={notification} />
      ))}
    </NotificationList>
  )
}

Enter fullscreen mode Exit fullscreen mode

Notifications list component

Our NotificationList.tsx component is a component that will hold all of our NotificationItems. It will utilise the React Portal concept to render the HTML in a different part of the DOM. I use the Portal component from Radix UI.

The portal appends to document.body by default but can be customized to use a different container.

Out NotificationList will also wrap our single Notification items with Framer Motion animation components, that will allow us to animate position changes etc with ease.

import * as Portal from '@radix-ui/react-portal'
import type { ReactNode } from 'react'
import { AnimatePresence, AnimateSharedLayout } from 'framer-motion'

type Props = {
  children: ReactNode
}

export const NotificationList = ({ children }: Props) => {
  return (
    <Portal.Root>
      <AnimateSharedLayout>
        <ul
          aria-live="assertive"
          className="flex fixed z-50 flex-col gap-4 m-4 lg:m-8 pointer-events-none"
        >
          <AnimatePresence initial={false}>{children}</AnimatePresence>
        </ul>
      </AnimateSharedLayout>
    </Portal.Root>
  )
}

Enter fullscreen mode Exit fullscreen mode

Notification item component

The Notification item itself will be a component that renders the notification text, has an icon and a style based on its type, and also provides a way to close the notification and an optional callback to run when closing the notification.

You could also implement the possibility for a custom action inside the notification etc, but I am keeping it simple for this demo.

Notification item types

// src/features/notifications/components/NotificationItem.tsx
export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

type Props = {
  notification: Notification
}
Enter fullscreen mode Exit fullscreen mode

Notification item motion direction and position

This is only necessary to easily switch between different rendering positions for demo purposes. In a real world app, you would most likely choose a single position to render all notifications in.

// src/features/notifications/components/NotificationItem.tsx
/**
 * To handle different positions of the notification, we need to change the
 * animation direction based on whether it is rendered in the top/bottom or left/right.
 *
 * @param position - The position of the Notification
 * @param fromEdge - The length of the position from the edge in pixels
 */
const getMotionDirectionAndPosition = (
  position: NotificationPositions,
  fromEdge = 24
) => {
  const directionPositions: NotificationPositions[] = ['top', 'bottom']
  const factorPositions: NotificationPositions[] = ['top-right', 'bottom-right']

  const direction = directionPositions.includes(position) ? 'y' : 'x'
  let factor = factorPositions.includes(position) ? 1 : -1

  if (position === 'bottom') factor = 1

  return {
    [direction]: factor * fromEdge,
  }
}

Enter fullscreen mode Exit fullscreen mode

Notification item motion variants (Framer motion)

This is the Framer Motion variants that will control how the notification item is animated on and off screen.

// src/features/notifications/components/NotificationItem.tsx
const motionVariants: Variants = {
  initial: (position: NotificationPositions) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position),
    }
  },
  animate: {
    opacity: 1,
    y: 0,
    x: 0,
    scale: 1,
    transition: {
      duration: 0.4,
      ease: [0.4, 0, 0.2, 1],
    },
  },
  exit: (position) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position, 30),
      transition: {
        duration: 0.2,
        ease: [0.4, 0, 1, 1],
      },
    }
  },
}

Enter fullscreen mode Exit fullscreen mode

Notification item component implementation

And lastly the notification item implementation itself.

export const NotificationItem = ({
  notification: { id, autoHideDuration, message, onClose, type = 'info' },
}: Props) => {
  const dispatch = useAppDispatch()
  const duration = useNotificationDuration() // Demo purposes
  const isPresent = useIsPresent()
  const position = useNotificationPosition() // Demo purposes
  const prefersReducedMotion = usePrefersReducedMotion()

  // Handle dismiss of a single notification
  const handleDismiss = () => {
    if (isPresent) {
      dispatch(dismissNotification(id))
    }
  }

  // Call the dismiss function after a certain timeout
  const [, cancel, reset] = useTimeoutFn(
    handleDismiss,
    autoHideDuration ?? duration
  )

  // Reset or cancel dismiss timeout based on mouse interactions
  const onMouseEnter = () => cancel()
  const onMouseLeave = () => reset()

  // Call `onDismissComplete` when notification unmounts if present
  useUpdateEffect(() => {
    if (!isPresent) {
      onClose?.()
    }
  }, [isPresent])

  return (
    <motion.li
      className={clsx(
        'flex w-max items-center shadow px-4 py-3 rounded border transition-colors duration-100 min-w-[260px] text-sm pointer-events-auto',
        notificationStyleVariants[type]
      )}
      initial="initial"
      animate="animate"
      exit="exit"
      layout="position"
      custom={position}
      variants={!prefersReducedMotion ? motionVariants : {}}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="flex gap-2 items-center">
        {notificationIcons[type]}
        <span className="max-w-sm font-medium">{message}</span>
      </div>

      <div className="pl-4 ml-auto">
        <button
          onClick={handleDismiss}
          className={clsx(
            'p-1 rounded transition-colors duration-100',
            closeButtonStyleVariants[type]
          )}
        >
          <Cross2Icon />
        </button>
      </div>
    </motion.li>
  )
}

Enter fullscreen mode Exit fullscreen mode

Different parts of the component is styled by grabbing tailwind classes from an object based on type.

Notification item component full file

import clsx from 'clsx'
import { ReactNode } from 'react'
import { motion, useIsPresent, type Variants } from 'framer-motion'
import { useTimeoutFn, useUpdateEffect } from 'react-use'

import {
  CheckCircledIcon,
  Cross2Icon,
  ExclamationTriangleIcon,
  InfoCircledIcon,
} from '@radix-ui/react-icons'

import {
  dismissNotification,
  NotificationPositions,
  useNotificationDuration,
  useNotificationPosition,
} from '@features/notifications/notification.slice'
import { useAppDispatch } from '@redux/hooks'
import { usePrefersReducedMotion } from '@app/core/hooks/usePrefersReducedMotion'

export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

type Props = {
  notification: Notification
}

/**
 * To handle different positions of the notification, we need to change the
 * animation direction based on whether it is rendered in the top/bottom or left/right.
 *
 * @param position - The position of the Notification
 * @param fromEdge - The length of the position from the edge in pixels
 */
const getMotionDirectionAndPosition = (
  position: NotificationPositions,
  fromEdge = 24
) => {
  const directionPositions: NotificationPositions[] = ['top', 'bottom']
  const factorPositions: NotificationPositions[] = ['top-right', 'bottom-right']

  const direction = directionPositions.includes(position) ? 'y' : 'x'
  let factor = factorPositions.includes(position) ? 1 : -1

  if (position === 'bottom') factor = 1

  return {
    [direction]: factor * fromEdge,
  }
}

const motionVariants: Variants = {
  initial: (position: NotificationPositions) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position),
    }
  },
  animate: {
    opacity: 1,
    y: 0,
    x: 0,
    scale: 1,
    transition: {
      duration: 0.4,
      ease: [0.4, 0, 0.2, 1],
    },
  },
  exit: (position) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position, 30),
      transition: {
        duration: 0.2,
        ease: [0.4, 0, 1, 1],
      },
    }
  },
}

const notificationStyleVariants: Record<
  NonNullable<Notification['type']>,
  string
> = {
  success: 'bg-green-3 border-green-6',
  error: 'bg-red-3 border-red-6',
  info: 'bg-purple-3 border-purple-6',
  warning: 'bg-yellow-3 border-yellow-6',
}

const notificationIcons: Record<
  NonNullable<Notification['type']>,
  ReactNode
> = {
  success: <CheckCircledIcon />,
  error: <ExclamationTriangleIcon />,
  info: <InfoCircledIcon />,
  warning: <ExclamationTriangleIcon />,
}

const closeButtonStyleVariants: Record<
  NonNullable<Notification['type']>,
  string
> = {
  success: 'hover:bg-green-5 active:bg-green-6',
  error: 'hover:bg-red-5 active:bg-red-6',
  info: 'hover:bg-purple-5 active:bg-purple-6',
  warning: 'hover:bg-yellow-5 active:bg-yellow-6',
}

export const NotificationItem = ({
  notification: { id, autoHideDuration, message, onClose, type = 'info' },
}: Props) => {
  const dispatch = useAppDispatch()
  const duration = useNotificationDuration()
  const isPresent = useIsPresent()
  const position = useNotificationPosition()
  const prefersReducedMotion = usePrefersReducedMotion()

  // Handle dismiss of a single notification
  const handleDismiss = () => {
    if (isPresent) {
      dispatch(dismissNotification(id))
    }
  }

  // Call the dismiss function after a certain timeout
  const [, cancel, reset] = useTimeoutFn(
    handleDismiss,
    autoHideDuration ?? duration
  )

  // Reset or cancel dismiss timeout based on mouse interactions
  const onMouseEnter = () => cancel()
  const onMouseLeave = () => reset()

  // Call `onDismissComplete` when notification unmounts if present
  useUpdateEffect(() => {
    if (!isPresent) {
      onClose?.()
    }
  }, [isPresent])

  return (
    <motion.li
      className={clsx(
        'flex w-max items-center shadow px-4 py-3 rounded border transition-colors duration-100 min-w-[260px] text-sm pointer-events-auto',
        notificationStyleVariants[type]
      )}
      initial="initial"
      animate="animate"
      exit="exit"
      layout="position"
      custom={position}
      variants={!prefersReducedMotion ? motionVariants : {}}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="flex gap-2 items-center">
        {notificationIcons[type]}
        <span className="max-w-sm font-medium">{message}</span>
      </div>

      <div className="pl-4 ml-auto">
        <button
          onClick={handleDismiss}
          className={clsx(
            'p-1 rounded transition-colors duration-100',
            closeButtonStyleVariants[type]
          )}
        >
          <Cross2Icon />
        </button>
      </div>
    </motion.li>
  )
}

Enter fullscreen mode Exit fullscreen mode

Lastly output the Notifications component at a root level e.g. under Next.js _app.tsx wrapper

import '@styles/globals.css'
import type { AppProps } from 'next/app'
import { Provider } from 'react-redux'

import { Notifications } from '@features/notifications/components/Notifications'
import { store } from '@redux/store'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <Provider store={store}>
        <Component {...pageProps} />

        <Notifications />
      </Provider>
    </>
  )
}

export default MyApp

Enter fullscreen mode Exit fullscreen mode

It is now possible to dispatch the Redux action we created in the state slice; addNotification from any component in the app, and render a notification. 👍

// Any component

import { addNotification } from '@features/notifications/notification.slice'
import { useAppDispatch } from '@redux/hooks'

export const Component = () => {
  const dispatch = useAppDispatch()

  return (
    <button
      onClick={() =>
        dispatch(
          addNotification({
            message: 'Hello world!',
            type: 'info',
            onClose: () => console.log('I was closed'),
            autoHideDuration: 6000,
          })
        )
      }
    >
      Render notification
    </button>
  )
}

Enter fullscreen mode Exit fullscreen mode

Demo: Here
Github repository: Here

Notes

The notification animation has been inspired by/copied from https://chakra-ui.com/docs/feedback/toast

Top comments (0)