In this article, our React developers will demonstrate how to build React Notifications (toasts) from scratch, without using any third-party library (except React).
The notification component has the following requirements:
<Notification color="success" />
).success()
).The final source code can be found here and a demo can be viewed here.
I used create-react-app to generate the boilerplate for this project and CSS modules to style it.
You're free to use any other tools to generate the boilerplate and style the component.
Here's our directory structure, we'll go through every single file in it:
├── App.css├── App.js├── index.css├── index.js└── notify├── Notification| ├── Notification.module.css| ├── index.js| └── times.svg├── createContainer| ├── container.module.css| └── index.js└── index.js
// notify/Notification/index.jsimport React from "react";import PropTypes from "prop-types";import cn from "classnames";import { ReactComponent as Times } from "./times.svg";import styles from "./Notification.module.css";export default function Notification({ color = Color.info, children }) {return (<div className={cn([styles.notification, styles[color]])}>{children}<button className={styles.closeButton}><Times height={16} /></button></div>);}export const Color = {info: "info",success: "success",warning: "warning",error: "error",};Notification.propTypes = {notificationType: PropTypes.oneOf(Object.keys(Color)),children: PropTypes.element,};
The Notification
component so far has 2 props:
color
: a string value that determines the background color of the notification and can be either info, success, warning, or error.children
: any React elements we want to render inside the notification.And here are its styles:
/* notify/Notification/Notification.module.css */.notification {max-width: 430px;max-height: 200px;overflow: hidden;padding: 12px 48px 12px 12px;z-index: 99;font-weight: bold;position: relative;}.notification:not(:last-child) {margin-bottom: 8px;}.notification.info {background-color: #2196f3;}.notification.success {background-color: #4caf50;}.notification.warning {background-color: #ff9800;}.notification.error {background-color: #f44336;}.notification .closeButton {position: absolute;top: 12px;right: 12px;background: transparent;padding: 0;border: none;cursor: pointer;}.notification,.notification .closeButton {color: #fff;}
Our notifications should be rendered separately from the DOM structure of the application using them.
createContainer
is a helper function that creates a container element for the notifications (if it doesn't exist already) and append it directly to the document's body:
// notify/createContainer/index.jsimport styles from "./container.module.css";export default function createContainer() {const portalId = "notifyContainer";let element = document.getElementById(portalId);if (element) {return element;}element = document.createElement("div");element.setAttribute("id", portalId);element.className = styles.container;document.body.appendChild(element);return element;}
It has a fixed
position and is placed on the top right as per our requirements:
/* notify/createContainer/container.module.css */.container {position: fixed;top: 16px;right: 16px;}
We can then use ReactDOM.createPortal
to render the notification in the container we create:
// notify/Notification/index.jsconst container = createContainer();export default function Notification({ color = Color.info, children }) {return createPortal(<div className={cn([styles.notification, styles[color]])}>{children}<button className={styles.closeButton}><Times height={16} /></button></div>,container);}
Before writing a demo, let's expose Notification
and its Color
object in notify/index.js
so that they can be imported and used:
// notify/index.jsexport { default as Notification, Color } from "./Notification";
Now let's write a demo to showcase the different notifications:
// App.jsimport React from "react";import "./App.css";import { Notification, Color } from "./notify";function App() {const [notifications, setNotifications] = React.useState([]);const createNotification = (color) =>setNotifications([...notifications, { color, id: notifications.length }]);return (<div className="App"><h1>Notification Demo</h1><button onClick={() => createNotification(Color.info)}>Info</button><button onClick={() => createNotification(Color.success)}>Success</button><button onClick={() => createNotification(Color.warning)}>Warning</button><button onClick={() => createNotification(Color.error)}>Error</button>{notifications.map(({ id, color }) => (<Notification key={id} color={color}>This is a notification!</Notification>))}</div>);}export default App;
Our demo simply renders a list of notifications and has 4 different buttons to add colored notifications to our list.
Let's make it possible to close notifications by adding an onDelete
prop to Notification
and making the close button invoke that function on click:
// notify/Notification/index.jsexport default function Notification({color = Color.info,onDelete,children,}) {return createPortal(<div className={cn([styles.notification, styles[color]])}>{children}<button onClick={onDelete} className={styles.closeButton}><Times height={16} /></button></div>,container);}
Now, in App.js
, we pass an onDelete
prop function that deletes the corresponding notification from the list:
// App.jsfunction App() {const [notifications, setNotifications] = React.useState([]);const createNotification = (color) =>setNotifications([...notifications, { color, id: notifications.length }]);const deleteNotification = (id) =>setNotifications(notifications.filter((notification) => notification.id !== id));return (<div className="App"><h1>Notification Demo</h1><button onClick={() => createNotification(Color.info)}>Info</button><button onClick={() => createNotification(Color.success)}>Success</button><button onClick={() => createNotification(Color.warning)}>Warning</button><button onClick={() => createNotification(Color.error)}>Error</button>{notifications.map(({ id, color }) => (<Notificationkey={id}onDelete={() => deleteNotification(id)}color={color}>This is a notification!</Notification>))}</div>);}
Notifications are added and deleted too fast, which might confuse users. By adding "slide-in" and "slide-out" animations, we make notifications behave more naturally and improve the user experience.
To slide the notification in, we simply use the translateX
CSS transform and translate it from 100% to 0. Here's the corresponding animation created with keyframes:
/* notify/Notification/Notification.module.css */@keyframes slideIn {from {transform: translateX(100%);}to {transform: translateX(0%);}}.notification.slideIn {animation-name: slideIn;animation-duration: 0.3s;animation-timing-function: ease-in-out;}
"slide-out" is a bit more tricky. When hitting the close button, we need to have a "closing" phase before calling the onDelete
prop function. During the closing phase, we can slide the notification out using translateX(150%)
and add a transition to notification
to smoothen the "slide-out".
Here are the styles corresponding to the "slide-out" animation:
/* notify/Notification/Notification.module.css */.notification {...transition: transform 0.3s ease-out;}.notification.slideOut {transform: translateX(150%);flex: 0;}
To achieve the closing phase in Notification
, we can use a boolean state variable isClosing
(set to false
by default) . When we hit the close button, we set isClosing
to true
, wait for a the transition duration (300ms
here), and then call the onDelete
function.
We only use the slideIn
animation styles when we're not in the closing phase (i.e. isClosing=false
) and slideOut
animation styles when we're in the closing phase (i.e. isCloseing=true
).
// notify/Notification/index.jslet timeToDelete = 300;export default function Notification({color = Color.info,onDelete,children,}) {const [isClosing, setIsClosing] = React.useState(false);React.useEffect(() => {if (isClosing) {const timeoutId = setTimeout(onDelete, timeToDelete);return () => {clearTimeout(timeoutId);};}}, [isClosing, onDelete]);return createPortal(<divclassName={cn([styles.notification,styles[color],{ [styles.slideIn]: !isClosing },{ [styles.slideOut]: isClosing },])}>{children}<button onClick={() => setIsClosing(true)} className={styles.closeButton}><Times height={16} /></button></div>,container);}
When a notification is deleted, the ones below it shift suddenly to the top to fill up its position.
To make this shift more natural, let's add a container around the notification that shrinks smoothly during the closing phase:
// notify/Notification/index.jslet timeToDelete = 300;export default function Notification({color = Color.info,onDelete,children,}) {const [isClosing, setIsClosing] = React.useState(false);React.useEffect(() => {if (isClosing) {const timeoutId = setTimeout(onDelete, timeToDelete);return () => {clearTimeout(timeoutId);};}}, [isClosing, onDelete]);return createPortal(<div className={cn([styles.container, { [styles.shrink]: isClosing }])}><divclassName={cn([styles.notification,styles[color],{ [styles.slideIn]: !isClosing },{ [styles.slideOut]: isClosing },])}>{children}<buttononClick={() => setIsClosing(true)}className={styles.closeButton}><Times height={16} /></button></div></div>,container)
The container has a max-height
of 200px
by default and shrinks to 0
during the closing phase. We should also move the margin
definition to the container:
/* notify/Notification/Notification.module.css */.container {overflow: hidden;max-height: 200px;transition: max-height 0.3s ease-out;}.container:not(:last-child) {margin-bottom: 8px;}.container.shrink {max-height: 0;}
Let's add an autoClose
boolean prop to the Notification component and use useEffect
to close the notification after 10 seconds if the prop is set to true.
// notify/Notification/index.jsexport default function Notification({color = Color.info,autoClose = false,onDelete,children,}) {const [isClosing, setIsClosing] = React.useState(false);React.useEffect(() => {if (autoClose) {const timeoutId = setTimeout(() => setIsClosing(true), timeToClose);return () => {clearTimeout(timeoutId);};}}, [autoClose]);
Now let's modify our demo to pass autoClose=true
to the notifications:
// App.jsfunction App() {const [notifications, setNotifications] = React.useState([]);const createNotification = (color) =>setNotifications([...notifications, { color, id: notifications.length }]);const deleteNotification = (id) =>setNotifications(notifications.filter((notification) => notification.id !== id));return (<div className="App"><h1>Notification Demo</h1><button onClick={() => createNotification(Color.info)}>Info</button><button onClick={() => createNotification(Color.success)}>Success</button><button onClick={() => createNotification(Color.warning)}>Warning</button><button onClick={() => createNotification(Color.error)}>Error</button>{notifications.map(({ id, color }) => (<Notificationkey={id}onDelete={() => deleteNotification(id)}color={color}autoClose={true}>This is a notification!</Notification>))}</div>);}
Now notifications close automatically after 10 seconds of their creation:
We want to be able to create notifications imperatively, by calling functions such as success()
or error()
.
The trick is to create a component similar to our App
one that is rendered by default and provides us a function to create notifications.
Let's create NotificationsManager
to serve that purpose:
// notify/NotificationsManagerimport React from "react";import PropTypes from "prop-types";import Notification from "./Notification";export default function NotificationsManager({ setNotify }) {let [notifications, setNotifications] = React.useState([]);let createNotification = ({ color, autoClose, children }) => {setNotifications((prevNotifications) => {return [...prevNotifications,{children,color,autoClose,id: prevNotifications.length,},];});};React.useEffect(() => {setNotify(({ color, autoClose, children }) =>createNotification({ color, autoClose, children }));}, [setNotify]);let deleteNotification = (id) => {const filteredNotifications = notifications.filter((_, index) => id !== index,[]);setNotifications(filteredNotifications);};return notifications.map(({ id, ...props }, index) => (<Notificationkey={id}onDelete={() => deleteNotification(index)}{...props}/>));}NotificationsManager.propTypes = {setNotify: PropTypes.func.isRequired,};
NotificationsManager receives one prop setNotify
, which is used to give access to the createNotification
function to create notifications imperatively.
Now let's render NotificationsManager
in the same container as Notfication and create our notification creation functions. We access createNotification
function through the setNotify
prop and use it to create our notification creation functions:
// notify/index.jsimport React from "react";import ReactDOM from "react-dom";import NotificationsManager from "./NotificationsManager";import Notification, { Color } from "./Notification";import createContainer from "./createContainer";const containerElement = createContainer();let notify;ReactDOM.render(<NotificationsManagersetNotify={(notifyFn) => {notify = notifyFn;}}/>,containerElement);export { Notification, Color };export function info(children, autoClose) {return notify({color: Color.info,children,autoClose,});}export function success(children, autoClose) {return notify({color: Color.success,children,autoClose,});}export function warning(children, autoClose) {return notify({color: Color.warning,children,autoClose,});}export function error(children, autoClose) {return notify({color: Color.error,children,autoClose,});}
Now let's test these functions out in App.js
. Let's also make 2 changes to improve our demo:
// App.jsimport React from "react";import Highlight from "react-highlight";import "./App.css";import "./highlight-js-night-owl.css";import { Notification, Color, info, success, warning, error } from "./notify";const message = "This is a notification!";function DeclarativeDemo() {const [notifications, setNotifications] = React.useState([]);const createNotification = (color) =>setNotifications([...notifications, { color, id: notifications.length }]);const deleteNotification = (id) =>setNotifications(notifications.filter((notification) => notification.id !== id));return (<><Highlight>{`const [notifications, setNotifications] = React.useState([]);const createNotification = (color) =>setNotifications([...notifications, { color, id: notifications.length }]);const deleteNotification = (id) =>setNotifications(notifications.filter((notification) => notification.id !== id));return (<><button onClick={() => createNotification(Color.info)}>Info</button><button onClick={() => createNotification(Color.success)}>Success</button><button onClick={() => createNotification(Color.warning)}>Warning</button><button onClick={() => createNotification(Color.error)}>Error</button>{notifications.map(({ id, color }) => (<Notificationkey={id}onDelete={() => deleteNotification(id)}color={color}autoClose={true}>{message}</Notification>))}</>);`}</Highlight><button onClick={() => createNotification(Color.info)}>Info</button><button onClick={() => createNotification(Color.success)}>Success</button><button onClick={() => createNotification(Color.warning)}>Warning</button><button onClick={() => createNotification(Color.error)}>Error</button>{notifications.map(({ id, color }) => (<Notificationkey={id}onDelete={() => deleteNotification(id)}color={color}autoClose={true}>{message}</Notification>))}</>);}function ImperativeDemo() {return (<><Highlight>{`<><button onClick={() => info(message, true)}>Info</button><button onClick={() => success(message, true)}>Success</button><button onClick={() => warning(message, true)}>Warning</button><button onClick={() => error(message, true)}>Error</button></>`}</Highlight><button onClick={() => info(message, true)}>Info</button><button onClick={() => success(message, true)}>Success</button><button onClick={() => warning(message, true)}>Warning</button><button onClick={() => error(message, true)}>Error</button></>);}function App() {const [demo, setDemo] = React.useState("declarative");return (<div className="App"><select onChange={(e) => setDemo(e.target.value)}><option value="declarative">Declarative demo</option><option value="imperative">Imperative demo</option></select>{demo === "declarative" ? <DeclarativeDemo /> : <ImperativeDemo />}</div>);}export default App;
Tinloof is a web design and development agency home to experienced React developers who specialise in creating powerful websites, apps and headless commerce solutions. We offer a wide range of web and development services and digital brand design services. So, whether you’re starting from scratch or looking for optimisations, we can offer the support you need. Get in touch with us today to discuss your project goals with a member of our team.