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.