Building a notification system
Why I built a notification system from scratch instead of reaching for a library — and what writing it myself taught me.
I recently had the opportunity to build a new notification system for our platform and I wanted to write up some of the decision-making involved during the process.
What is a notification component?
A notification component usually appears subtly on a page to alert the user to some information; it then typically self-closes after a short time interval.
The appearance of a notification could be a direct consequence of an action the user has performed (in which case the notification acts as a feedback mechanism), or it could be unprompted, like letting the user know that a background update is ready to install. Notifications alert the user of something but they are much less obtrusive to the user experience than a modal.
Side note: I came across a
toastcomponent while reading up on notification components. An interpretation I found for differentiating between the two is that the former just displays a message and does not allow any interaction, whereas the latter supports it e.g. clicking on it to execute some action like redirecting or “opening” something. We wanted that, so “notification” was the correct terminology for us.
What we had, and what we needed
We already had a notification component. It worked, but it predated our current design system and resided in our legacy codebase, so we couldn't reach for it for newer features.
Rather than just port it over with a style alignment, I wrote down the features that I would have included if I built it from scratch:
- Positioning — configurable to any of the four corners of the viewport (in practice only top-right was ever needed)
- Self-closing — dismisses itself after a set duration (four seconds by default), and pauses that timer while the cursor is over it, so a notification cannot vanish while you are reading it
- Stacking — several notifications arriving at once should queue rather than overwrite each other
- Accessible — announced properly to screen readers, not just visually present
- A utility hook — so that using it in a component is a one-liner
Five states fell out of the design system: success, error, a plain informational state, a loading state for asynchronous actions, and a clickable state that runs an action when selected. I mocked all of them up, and accounted for edge cases: what a really long heading does, what a message body does when it runs on too long.
Those five collapsed to three when I built it: success, error and info. Loading and clickable states
were additive to these three states since they are the same three intents with a spinner swapped in
for the icon, or with an action attached. So the component takes an intent plus two optional
modifiers rather than five variants.
Design at work is normally owned by our product designer but this side-project was an exception: the visual design and the implementation were both mine. You can see the design I ended up with below; I've rebuilt them in HTML and CSS instead of just screenshotting:
Heading
Heading
Heading
Heading
Heading
Go to campaignBuild from scratch, or utilise a library?
With the requirements written down, I then pondered how to go about with implementation and the question of building from scratch, or utilising an existing library cropped up.
The case for a library. react-toastify was the
most popular option and covered the use cases well. Queueing, stacking, positioning and transitions
all come for free, and it is not especially heavy — around 16kB minified. Against it: customising
the styling would have meant overriding its CSS, which pulls away from the TailwindCSS approach used
everywhere else in the codebase.
The case for building it. We lean on Ariakit for accessible primitives, but it had no notification component, so there was nothing to compose this out of. It was on the maintainer's radar though, and he had published a sketch of the API he had in mind — so I argued that if we shaped ours along the same lines, swapping over later would be a small job rather than a rewrite. Beyond that: no extra dependency, full control over the styling with the same tokens and utility classes as everything else, and a lot more to learn from writing the queueing, the stacking and the timer behaviour than from configuring them.
The thing that actually decided it
Both options were defensible, and what tipped it was not really technical: this was a development project. It was suggested as something to work on in the gaps between sprints, for the learning, and there was no delivery date attached to it.
That mattered more than anything else on the list. react-toastify is the right answer when you
need notifications shipped this week. It is the wrong answer when the point of the exercise is to
understand how notifications work.
Under delivery pressure I would have installed the library.
Thinking about the DX
Designing the aesthetics of any UI component is always important, but what was interesting here was thinking about how to build a notification system that would have a good API and feel “good” to use for developers.
A common use case for a notification is to act as a feedback mechanism. For example, we may want to show a success notification when a user adds something to their basket on an e-commerce site. Or, if we go the other way, we might want to show an error if something went wrong when trying to add the item.
With the above in mind, we'll need a way to create notifications on the fly — developers should be able to create a notification in their specific scenario and expect it to surface to the user.
After a few iterations, I ended up with this:
const notification = notificationManager.create('<NOTIFICATION_TYPE>', {
heading: 'Hello world',
message: 'Your first notification',
});
I felt that the interface of the .create method was pretty neat. The first parameter takes in the
type of notification you want to create, and the second parameter is the configuration object e.g.
heading, message, timeout, action, etc.
👆🏼 Note that we're creating an instance of a notification and assigning it to a
const
Updating a notification in place
The next scenario I wanted to cover was transitioning to a different type of notification. Examples I saw online simply pushed a new (and separate) notification to communicate the change. To illustrate this point, imagine a user has just fired off an asynchronous action; an initial notification would then be shown; once the action completes, a new notification would be “stacked” on top of the initial one.
There's nothing wrong with this approach, but what I wanted instead was for the original notification to update with the new state.
To be clear, stacking is still supported and still the right behaviour for genuinely separate notifications — two unrelated things happening at once should be two cards. Updating is for the narrower case where one notification's state changes and the earlier version of it is no longer worth reading.
Having an update method on the notification instance seemed logical! So I ended up with something like the below:
notification.update({
type: '<SOME_OTHER_TYPE>',
heading: 'Notification updated',
message: 'Action was a success!',
});
This is the reason create returns an instance rather than just firing and forgetting. Putting the
whole flow together:
const notificationManager = useNotificationManager();
const someClickHandler = async () => {
const notification = notificationManager.create('info', {
heading: 'Saving',
message: `We're saving your work — hold tight...`,
});
// Fire off some async action
await save();
// After async action resolves, update notification
notification.update({
type: 'success',
heading: 'Saved! ✅',
message: 'We successfully saved your work',
});
};
In its simplest form, exposing these two methods was enough for a small notification system to
function. There is also a close method on the manager, but that exists to handle the self-closing
behaviour from outside the notification's own context — developers using the system never need to
reach for it.
Making it accessible
The same amount of care in getting it visually right should be taken to getting it right for screen reader users.
A notification appears without the user asking for it, it disappears on a timer, and it might be one of several. So there's a couple of scenarios to keep in mind here.
Each notification is marked up as a live region:
role="region" aria-live="polite" aria-labelledby="..." aria-describedby="..."
aria-live="polite" means the announcement waits for a natural pause rather than interrupting
whatever is currently being read — which is right for a notification, since almost nothing here is
urgent enough to talk over someone. aria-labelledby and aria-describedby point at the heading
and message so the announcement carries the actual content rather than just signalling that
something appeared.
The container holding them is labelled too, and it is aria-hidden when there are no active
notifications. That last bit is easy to miss: without it, you leave an empty region permanently
exposed for a screen reader to find and announce nothing.
Closing statement
I'm really happy with how the component turned out.
It had to work for two audiences at once — the person receiving the notification, and the developer reaching for it — and I think we landed somewhere that is genuinely easy to pick up and use.
Writing the queueing, the timers, the update-in-place behaviour and the screen reader handling myself taught me far more than wiring up a library would have, and that was the point of doing it that way.
Update — 2026
Coming back to this a couple of years on, the component is still the one we use. It now sits in around 40 call sites across six apps in the platform, including features I have worked on since, which feels like a real success to me.
The Ariakit component still hasn't materialised either, so the smooth migration I argued for never got tested — we would still be waiting if we had held out for it.
Read more writing →