Simple notifications with React
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
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"
>
✕
</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
✕ ✕. 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
@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.
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
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
const Footer = () => {
return (
<>
<footer>
I made this website
</footer>
</>
);
};
export default Footer;
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
import { createContext } from "react";
const NotificationContext = createContext({
notifications: [],
addNotification: () => {},
});
export default NotificationContext;
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
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;
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
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
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.