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
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".
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>
>
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,
})
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
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: {},
})
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
}
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)
}
},
},
})
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
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
.
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>
)
}
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>
)
}
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
}
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,
}
}
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],
},
}
},
}
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>
)
}
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>
)
}
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
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>
)
}
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)