Simple notifications with React

Update : For those looking for a quick and complete toast notification solution they don't need to build and maintain, I've recently had a lot of success using Emil Kowalski's Sonner package.

Recently I was working on a create-react-app project where I needed to notify the user of certain state changes - such as an email being deleted - in an unobtrusive way, as well as give them a chance to undo or cancel certain actions. After exploring some notification/alert libraries I couldn't find a solution that met all of my requirements, so I decided to make my own.

What we will build

  • A small, customisable and accessible notification component that is dismissed after 3 seconds
  • A smooth animation to show and hide the notification that can be ignored depending on the user's system preferences
  • A way to create and render a new notification with a simple function call
  • Have notifications persist across pages using the overall application state
  • A button that allows the user to dismiss the notification early
  • Optionally, notifications know how to perform certain actions upon dismissal
  • Notifications could also contain a second button to cancel out the original action

The component

src/components/Notification.js
const Notification = ({ text, status, onFinish, actionText, action }) => {
  return (
    <div
      className={`notification notification-${status}`}
      role="status"
      aria-live="polite"
      onAnimationEnd={onFinish}
    >
      <span>{text}</span>

      <div className="notification-buttons">
        {actionText !== null && (
          <button
            type="button"
            className="notification-button notification-action"
            onClick={action}
          >
            {actionText}
          </button>
        )}

        <button
          type="button"
          className="notification-button"
          onClick={onFinish}
          aria-label="Close notification"
          title="Close"
        >
          &#x2715;
        </button>
      </div>
    </div>
  );
};

export default Notification;

We pass in text, status, onFinish, actionText and action properties. text will be what we display inside the notification. status will allow for custom styling if you wish to implement different styles for error, success etc. onFinish is a callback function that will trigger when the notification has been removed from the screen. actionText is the content of an optional button such as "cancel". action is a callback function that will be triggered if the button containing our actionText is clicked. We will create a helper function shortly to help set sensible defaults for these values.

The component has a role of status and an aria-live attribute of polite. This tells any screen reader to announce this content when it is rendered as a priority, but not as a matter of urgency. More information can be found at Inclusive Components - Notifications.

Our close button text is the unicode symbol &#x2715; ✕. You can replace this with the word "Close", an SVG, or an icon component of your choice, but we should avoid using the letter X because screen readers may read it out as "X" rather than "Close".

We also give our close button an aria-label of "Close notification" to help provide more context to screen readers and a title of "Close" for most browsers to render a tooltip when hovering over the button. The most accessible thing to do here is just to take up a little bit extra space and use the word "Close", but from my own testing at the time of writing, screen readers ignored the unicode character and prioritised the aria-label instead. You can find more information in Sara Soueidan's article Accessible Icon Buttons

The last thing to note is that our main component has an onAnimationEnd property that calls our onFinish function. onAnimationEnd is an event that is fired when a CSS animation has completed. For us, this is when the notification has moved off-screen, signalling it is "finished". This is the next thing we need to implement.

Styling and animation

src/styles/notifications.css
@keyframes notification {
  0% {
    bottom: -100px;
  }
  10% {
    bottom: 20px;
  }
  90% {
    bottom: 20px;
  }
  100% {
    bottom: -100px;
  }
}

.notification {
  box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
  border-radius: 8px;
  position: fixed;
  z-index: 1;
  left: 20px;
  right: 20px;
  bottom: -120px;
  max-width: 300px;
  padding: 10px 20px;
  color: #e5e5e5;
  border-bottom: 2px solid #1b2431;
  display: flex;
  align-items: center;
  justify-content: space-between;
  animation: notification 3s cubic-bezier(0.5, 0, 0.5, 1) 1;
  background-color: #1f2937;
}

@media (prefers-reduced-motion: reduce) {
  .notification {
    bottom: 20px;
    animation: none;
  }
}

.notification-success {
  background-color: #10b981;
  border-color: #0ea674;
}

.notification-buttons {
  display: flex;
  align-items: center;
  gap: 10px;
}

.notification-button {
  border: none;
  background-color: transparent;
  padding: 10px;
  font-size: 1rem;
  color: currentColor;
}

.notification-button:hover {
  background-color: rgba(0, 0, 0, 0.3);
}

.notification-action {
  color: #be911c;
}

@media (max-width: 500px) {
  .notification {
    max-width: none;
  }
}

This is where you can be creative with themeing and styles.

The key point here is the animation which holds the notification off the bottom of the screen for the first 10% of its animation cycle, then moves it onto the screen with a 20px gap for 80% of it's animation before moving it back off-screen again for the final 10%. The animation runs for 3 seconds and is set to only run 1 time. It uses a cubic bezier for timing which can be replaced with any kind of easing you prefer. The animation reaching 100% after 3 seconds is what will trigger the onAnimationEnd event in our component.

For users who have configured their operating system to reduce animations we cancel this animation property all together using a prefers-reduced-motion media query. This is why the notification also has a default bottom value of 20px, so that it renders on the screen without the animation. These users will have to manually dismiss the notifications. You can read more about prefers-reduced-motion in the article prefers-reduced-motion: Sometimes less movement is more.

Some styling has been applied using flexbox to align the notification content with any buttons that may appear in it and the buttons have had a lot of their default properties such as border, font size and colour overridden to match the theme. We also have a notification-success class that changes the background and border of the notification if we pass status="success" in our markup. You can add as many statuses as you like.

A comparison of the standard notification and the success notification styles

For positioning and sizing, we use a fixed position to ensure the notification will always be placed relative to the window itself and a z-index of 1 to render it above any content it might clash with. left and right values are specified as 20px to make the notification the same width as the window with some margin between. A max-width then re-sizes the notification to 300px to make it less intrusive to the rest of the page. A media query removes this max width on small screens to allow the notification to take up however much space it needs.

Managing notifications

src/hooks/useNotification.js
import { useState } from "react";

const useNotifications = () => {
  const [notifications, setNotifications] = useState([]);

  const newNotification = (notification) => {
    return {
      text: notification.text,
      status: notification.status ?? "info",
      onFinish: notification.hasOwnProperty("onFinish")
        ? () => {
            notification.onFinish();
            removeNotification(notifications.indexOf(notification));
          }
        : () => {
            removeNotification(notifications.indexOf(notification));
          },
      actionText: notification.actionText ?? null,
      action: notification.hasOwnProperty("action")
        ? () => {
            notification.action();
            removeNotification(notifications.indexOf(notification));
          }
        : () => {
          removeNotification(notifications.indexOf(notification));
        },
    };
  };

  const addNotification = (notification) => {
    setNotifications((notifications) => [
      ...notifications,
      newNotification(notification),
    ]);
  };

  const removeNotification = (i) => {
    const array = notifications.splice(i, 1);
    setNotifications(array);
  };

  return {
    notifications,
    addNotification,
  };
};

export default useNotifications;

We will manage our notifications using a custom hook. We will store a notifications array in state with useState and provide a setNotifications method to allow the state to be updated. notifications will be exported to allow other files to be aware of them.

Our main two methods are addNotification and removeNotification. addNotification will be called from outside of this file so we return it as part of the exported object. The method takes a notification object and transforms it into the expected shape (the shape defined in our component) using the newNotification method. newNotification sets the notification text to whatever we provide, so we must always be sure to provide this, and sets defaults for every other property so that we can omit them when calling addNotification if we don't need them. Finally, it spreads the new notification into the existing ones as a new array to avoid mutating the original and properly update the state.

For the onFinish and action properties we always want to have some method present, and we know that we will always have some form of expected behaviour; like removing the notification from the array on finish. For this reason we check to see if we have provided our own onFinish or action properties in the object we pass in, and if we have we execute those methods before performing the default action such as removing the notification. We also remove the notification on action which will force it to be removed without giving it a chance to "finish", which is what makes cancellable actions like "cancel" possible.

removeNotification creates a new array by splicing the notification out of the original array and setting our state to that. Again, we want to avoid mutating the original array. There are other ways to do this such as using array.filter or array.slice but for those to work properly we would have to either assume every notification will have a unique text property or provide some kind of unique identifier to each notification upon creation. Because we don't need to call this method from outside of this file we don't need to return and export it.

Page setup

src/components/Footer.js
const Footer = () => {
  return (
    <>
      <footer>
        I made this website
      </footer>
    </>
  );
};

export default Footer;
src/pages/Home.js
import Footer from "./../components/Footer";
                
const Home = () => {
  return (
    <>
      <h1>Home page</h1>

      <Footer />
    </>
  );
};

export default Home;

The first thing we will need is a page to render our notifications in, we can come back and do the actual rendering code later.

Our notification rendering code will go inside the footer component which we will include in all pages.

Accessing notifications across the application

src/context/NotificationContext.js
import { createContext } from "react";

const NotificationContext = createContext({
  notifications: [],
  addNotification: () => {},
});

export default NotificationContext;
src/App.js
import { BrowserRouter, Route, Routes } from "react-router-dom";
import NotificationContext from "./context/NotificationContext";
import useNotifications from "./hooks/useNotifications";
import Home from "./pages/Home";
import "./styles/notifications.css";

const App = () => {
  const { notifications, addNotification } = useNotifications();

  return (
    <NotificationContext.Provider
      value={{
        notifications,
        addNotification,
      }}
    >
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Home />} />
        </Routes>
      </BrowserRouter>
    </NotificationContext.Provider>
  );
};

export default App;

To allow all pages to make use of the notification managing logic we will create a context provider and wrap it around the rest of our app. We can use our useNotification hook to quickly set up the context provider. This example uses the react-router-dom package to let us add multiple pages to our application. For now we only have one page, which is our Home page that will be rendered via the default / route.

Rendering notifications

src/components/Footer.js
import React, { useContext } from "react";
import NotificationContext from "../context/NotificationContext";
import Notification from "./Notification";

const Footer = () => {
  const { notifications } = useContext(NotificationContext);

  return (
    <>
      <footer>
        I made this website
      </footer>

      {notifications.map((notification, i) => {
        return (
          <Notification
            key={i}
            text={notification.text}
            status={notification.status}
            onFinish={notification.onFinish}
            actionText={notification.actionText}
            action={notification.action}
          />
        );
      })}
    </>
  );
};

export default Footer;
src/pages/Home.js
import React, { useContext } from "react";
import Footer from "./../components/Footer";
import NotificationContext from "./../context/NotificationContext";
                
const Home = () => {
  const { addNotification } = useContext(NotificationContext);

  return (
    <>
      <h1>Home page</h1>

      <button
        type="button"
        onClick={() => {
          addNotification({
            text: "Hello!",
          });
        }}
      >
        Click me
      </button>

      <Footer />
    </>
  );
};

export default Home;

We're finally ready to start adding notifications. The footer component accesses the notifications array tied to our context and iterates through it, rendering a Notification component for each one. We know all of the objects in this array will have the properties we need thanks to our newNotification method so we can pass them into the component directly. We also parse the index of that notification in the array and provide it as a key property so that react can establish a relationship between that element and the DOM.

We've also added a button to our home page that when clicked will create out first notification for us. Now we can try it out!

It's worth noting that clicking the button multiple times in a short period of time will render multiple notifications on top of each other. With some tinkering we could allow each notification to position itself relative to any others on the page, but we should be cautious not to flood a users attention with too many "important" messages at once, particularly user's who use screen reading technologies. For most applications, one notification at a time is more than enough.

Using the onFinish and action properties

onFinish

src/pages/Home.js
import React, { useContext, useState } from "react";
import Footer from "./../components/Footer";
import NotificationContext from "./../context/NotificationContext";

const Home = () => {
  const { addNotification } = useContext(NotificationContext);

  const [status, setStatus] = useState("OK");

  return (
    <>
      <h1>Home page</h1>

      <div>Status: {status}</div>

      <button
        type="button"
        onClick={() => {
          addNotification({
            text: "Breaking website...",
            onFinish: () => {
              setStatus("Broken!");
            },
          });
        }}
      >
        Click me
      </button>

      <Footer />
    </>
  );
};

export default Home;

We can use the onFinish property to make something happen when a notification has animated off the screen or is closed using the close button. In this example we use a status property set in state and change it's value on finish to break our application.

action

src/pages/Home.js
import React, { useContext, useState } from "react";
import Footer from "./../components/Footer";
import NotificationContext from "./../context/NotificationContext";

const Home = () => {
  const { addNotification } = useContext(NotificationContext);

  const [status, setStatus] = useState("OK");

  return (
    <>
      <h1>Home page</h1>

      <div>Status: {status}</div>

      <button
        type="button"
        onClick={() => {
          addNotification({
            text: "Breaking website...",
            onFinish: () => {
              setStatus("Broken!");
            },
            actionText: "Cancel",
            action: () => {
              setStatus("Saved!");
            },
          });
        }}
      >
        Click me
      </button>

      <Footer />
    </>
  );
};

export default Home;

We can combine our onFinish property with action to specify that we don't want to call our finishing function if the action is pressed instead. In this example we save our application from being broken.

If you are just trying to cancel the finishing function you can omit the action property completely and just use actionText to indicate that the button needs to show. The rest will be automatically handled by our newNotification method. action is for when you want to supply some additional functionality on top of cancelling the finishing function.