Article | Back to articles
UX Files - Coding the UI of notification toasts
23/12/2020 by Benoit Rajalu
This post is part of UX Files, a series of articles where I investigate interface patterns through three lenses: UX, UI and development. Let's start with a look at the Toast.
The state logic we built in part one handles most of the rules we set for ourselves, but there are many things left to do with our views.
We still need to address:
- Displaying variants,
- Displaying actions or links,
- Displaying a reverse loader for self-dismissing Toasts, one that pauses when the Toast is hovered or when some of its interactive elements are focused,
- Remove a Toast from the stack either when that timer is done or when a close button is clicked,
- Display the number of Toasts not yet displayed in the Stack when there's more than one in the queue.
The view also has a responsibility all interfaces on the web share. It must provide adequate accessibility features.
There's a lot left to do, so let's start!
The Stack
We'll start with the heart of this, the one component we actually need to call in our apps to display Toasts: the Stack.
Its responsibilities are plentiful. It must read the state from the store we've created earlier. It must display Toasts but also the counter for the ones yet to come. It also has to carry some of the accessibility features discussed above. Finally, if we are going to animate all of this, it will have to handle how things appear and disappear.
Quite a heavy workload.
Let's start simple and define our types.
tsx
import * as Toast from "../../domain/API";
type themeProps = {
defaultColor: string;
positiveColor: string;
negativeColor: string;
font: string;
textColor: string;
counterColor: string;
};
export type Positions =
| "top left"
| "top right"
| "top center"
| "bottom left"
| "bottom right"
| "bottom center";
type StackTypes = {
stack: Toast.Stack;
onCloseToast: (toast: Toast.Toast) => void;
position: Positions;
offset?: number;
theme?: Partial<themeProps>;
};
Using our API, we can define our props neatly. We'll need a stack, fed by our store, but we also need to know where to position our component. The offset and theme props are optional and only relevant to styles, though I believe making the offset (the distance between the screen borders and the Toasts) customizable through a prop is an important addition to an agnostic solution.
You may wonder why the closing of the Toasts callback is fed to the Stack and not the Toast. Doing this means we will only have one "point of entry" where our components need to be connected to our store. Calling our future <Stack />
component with the proper props will be the only necessary action to display Toasts in our app. If you need to change the API or the Store, you'll only need to change that one prop for Stack
.
Speaking of the devil, here's how it looks:
tsx
export default function Stack({
stack,
onCloseToast,
position,
offset = 32,
theme
}: StackPropsTypes) {
const stackLength = stack.length;
return (
<div
role="log"
aria-live="polite"
aria-label="Notification stack"
className={styles.Stack}
ref={stackRef}
style={{
maxWidth: `${400 + offset * 2}px`,
...positionOutput[position]
}}
>
{stackLength > 1 && (
<StackCounter
count={stackLength}
position={setCounterPosition(offset, position)}
/>
)}
{pipe(
stack,
// fp-ts helps us check our Array has entries
NonEmptyArrayFP.fromArray,
OptionFP.fold(
// If it has no entry, no Toasts!
() => null,
(nonEmptyStack) => (
<div
initial={animationsStyles.initial}
animate={animationsStyles.animate}
exit={animationsStyles.exit}
key="stack"
style={{
padding: `${offset}px`
}}
>
{pipe(
nonEmptyStack,
NonEmptyArrayFP.head,
(toast) => (
<ToastComponent
toast={toast}
key={toast.id}
onCloseToast={() => onCloseToast(toast)}
animations={animationsStyles}
/>
))}
</div>
)
)
)}
</div>
);
}
We have not yet looked at its children components so don't worry about them yet. What matters here is how we handle our Stack component. First, accessibility. We use the role="log"
attribute, helped by aria-live="polite"
. This will help screen readers announce our Toasts when they appear in the stack. It's a mandatory feature for many people who can't see what's happening on the screen.
Next we see the little bits of logic put in place to secure our ruleset. <StackCounter />
will only appear if there's more than one Toast in the stack. <ToastComponent />
will only display the first (and according to our API, the most important) Toast in the stack.
We also see the parts I left out. The theme
prop is not exploited in this example as it is a bit irrelevant to the article, it's purely used to set some CSS variables. Dive in the Codesandbox if you want to know more. I also left out the animations. To handle them, I'm going to lean on framer-motion. Its rich toolset has everything we need to follow our guidelines.
tsx
export default function Stack({
...
}: StackPropsTypes) {
const animationsStyles = positionAnimation[position];
const stackLength = stack.length;
return (
<div
...
>
{/* Enables fading in and out of the stack for the counter */}
<AnimatePresence>
{stackLength > 1 && (
<StackCounter
...
position={setCounterPosition(offset, position)}
/>
)}
</AnimatePresence>
{/* This enables us to hide the Stack with an animation */}
{/* Without it the last Toast would disappear in a flash */}
<AnimatePresence>
{pipe(
stack,
NonEmptyArrayFP.fromArray,
OptionFP.fold(
() => null,
(nonEmptyStack) => (
/* This animates the Stack as a whole */
/* It matters mostly for the exit */
<motion.div
initial={animationsStyles.initial}
animate={animationsStyles.animate}
exit={animationsStyles.exit}
key="stack"
style={{
padding: `${offset}px`
}}
>
{/* Inside the stack, we set a new AnimatePresence */}
{/* It will handle the coming and going for each Toast */}
<AnimatePresence exitBeforeEnter>
{pipe(
nonEmptyStack,
NonEmptyArrayFP.head,
(toast) => (
<ToastComponent
toast={toast}
key={toast.id}
onCloseToast={() => onCloseToast(toast)}
animations={animationsStyles}
/>
)
)}
</AnimatePresence>
</motion.div>
)
)
)}
</AnimatePresence>
</div>
);
}
Our animations are relative to the position of the Stack in the window. We need to parse the position and output the right set of animation rules, that's where positionAnimation
, like positionOuput
and setCounterPosition
shine. Each of these functions take the consumer's choice for "position" and apply the right decision to our view.
All of them are detailed in this helper file.
Using our API to materialize business logic, enforcing accessibility and crafting animations tailored to the consumer's choice: our Stack is ready to go! Well... It will be once we're done building <ToastComponent />
and <StackCounter />
.
The Toasts
You've had a peek at <ToastComponent />
, but that's just a decoy!
Remember that fancy fold
fonction we built in our API? That's where it shines:
tsx
type ToastTypes = {
toast: ToastAPI.Toast;
onCloseToast: () => void;
animations: toastAnimationRule;
};
export default function Toast({ toast, onCloseToast, animations }: ToastTypes) {
return (
<>
{pipe(
toast,
ToastAPI.fold({
onDefault: (defaultToast) => (
<ToastDefault
toast={defaultToast}
onCloseToast={onCloseToast}
animations={animations}
/>
),
onNegative: (negativeToast) => (
<ToastNegative
toast={negativeToast}
onCloseToast={onCloseToast}
animations={animations}
/>
),
onPositive: (positiveYoast) => (
<ToastPositive
toast={positiveYoast}
onCloseToast={onCloseToast}
animations={animations}
/>
)
})
)}
</>
);
}
The only thing <ToastComponent />
does is take a toast
out of our Stack and "triage" it to the proper view. Remember: each of these have different styles (only background colors in our case but better safe than sorry) and the negative one doesn't even need to display a loader since it can never be auto-dismissing.
There's an obvious downside to this technique as it does lead to a small amount of code duplication. After all, each of our variants are based on the same general shape. This repetition however affords us complete trust in each instance as they are all bound to refined types. It's a tradeoff that is easily offset using shared styles and composition for the components themselves. Here's how <ToastDefault />
looks:
tsx
import styles from "./Toast.module.scss";
import {
toastAnimationRule,
bottomMovement
} from "../../PositionAnimationHelpers";
type ToastTypes = {
toast: ToastAPI.ToastDefault;
onCloseToast: () => void;
animations?: toastAnimationRule;
};
export default function ToastDefault({
toast,
onCloseToast,
animations = bottomMovement
}: ToastTypes) {
const { countDown, setPauseTimeout } = useAutoDismiss({
delay: toast.dismissDelay || 0,
callback: onCloseToast
});
return (
<motion.div
className={styles.toast}
onMouseEnter={() => setPauseTimeout(true)}
onMouseLeave={() => setPauseTimeout(false)}
initial={animations.initial}
animate={animations.animate}
exit={animations.exit}
key={toast.id}
role="alert"
aria-label="new notification"
>
<div className={styles.reflowContainer}>
<div className={styles.content}>
<p>{toast.copy}</p>
</div>
{toast.action && (
<button
type="button"
className={styles.actionButton}
onClick={toast.action.callback}
onFocus={() => setPauseTimeout(true)}
onBlur={() => setPauseTimeout(false)}
>
{toast.action.copy}
</button>
)}
{toast.link && (
<a
className={styles.link}
href={toast.link.href}
onFocus={() => setPauseTimeout(true)}
onBlur={() => setPauseTimeout(false)}
>
{toast.link.copy}
</a>
)}
</div>
{toast.dismissDelay && (
<div className={styles.timer}>
<Timer delay={countDown} length={toast.dismissDelay} />
</div>
)}
<button
onClick={onCloseToast}
type="button"
className={styles.closeButton}
onFocus={() => setPauseTimeout(true)}
onBlur={() => setPauseTimeout(false)}
title="Close this notification"
>
<CloseButton />
</button>
</motion.div>
);
}
It seems a lot but it's actually fairly straightforward.
First, its types ensure we're going to host a <ToastComponent />
. According to our ruleset, it means anything goes when it comes to contents. That's why we see the various checks for toast.dismissDelay
, toast.action
etc... That is work we don't need to do in our <ToastNegative />
variant when it comes to the delay and our typing would not even allow it.
If the API changes someday for any type of Toast, having split our components in individual variants will help us cater to each specific new rule. Say the "positive" variant can no longer accept a dismissDelay
? We would update the type in our API, the compiler would warn us we've broken <ToastPositive />
and we'd only need to fix it and nothing else.
Next is the style. As we've seen, our three Toasts share more than a bit when it comes to layout and blocks. So all three variants share a parent stylesheet (using CSS modules here but any other solution would achieve the same effect).
If you look at our ToastNegative or ToastPositive components, you'll see that all they do to change their looks is apply one more CSS class and the deed is done.
Accessibility in layout
One last thing about the Toast layout. Part of accessibility is ensuring that our interface is still comfortably readable under a large zoom. In our case, it means we must ensure the Toast's content will "reflow" correctly (meaning lines will break and the component won't disappear out of the window). But it's not enough! My design wasn't great for this: forcing the buttons, link, timer and close button on the right meant that the only part able to reflow properly was the copy on the left.
On a very small screen (or under a large zoom) our interactive elements on the right wouldn't leave much place for the text. A simple media-query helped resolve this.
It's build from a mixin first:
scss
@mixin MQMaxWidth($size) {
@media screen and (max-width: #{$size}px), screen and (max-width: #{$size/16}rem) {
@content;
}
}
With this, we can write a media-query that works both in pixels and rem. Why would we want that?
Pixels are related to the available viewport real-estate. If you resize your browser window, you'll reduce the real estate. That's what a width in pixels represents.
Rem are something else:
Equal to the computed value of font-size on the root element. When specified on the font-size property of the root element, the rem units refer to the property's initial value.
https://www.w3.org/TR/css-values-3/#rem
When using the zoom utilities of our browsers, that root value is updated. In most browsers, the base value of 1rem is 16px. You may change that value in your stylesheets but media-queries only rely on the browser-set default. Our mixin therefore enables us to have the same effects on our styles regardless of pixels or rem. Here we use it to change the flex-model for part of the Toast only when needed:
scss
@include MQMaxWidth(400) {
.reflowContainer {
flex-flow: column;
justify-content: flex-start;
align-items: flex-start;
> *:not(:last-child) {
margin-bottom: 0.8rem;
}
}
}
Auto-dismiss
You may have noticed the React hook in our code.
tsx
const { countDown, setPauseTimeout } = useAutoDismiss({
delay: toast.dismissDelay || 0,
callback: onCloseToast
});
Its role is to centralize everything that's necessary for toasts to fade automatically without breaking our rules. Remember: we can't dismiss a Toast programmatically if it's currently being hovered or if its interactive children are focused.
Plus there's something only our view can know: is the Toast currently on a visible page? How silly would it be to dismiss a Toast that was only displayed in a hidden tab.
Enters useAutoDismiss
.
tsx
import { useState, useEffect, useRef } from "react";
type HookTypes = {
delay: number;
callback: () => void;
};
export default function useAutoDismiss({ delay, callback }: HookTypes) {
const [countDown, setCountdown] = useState<number>(delay);
const [pauseTimeout, setPauseTimeout] = useState<boolean>(false);
let targetTime = useRef<number>(delay || 0);
useEffect(() => {
const timer = setInterval(() => {
if (document.hidden || pauseTimeout) {
return;
}
targetTime.current = targetTime.current - 100;
setCountdown(countDown - 100);
if (targetTime.current <= 0) {
clearInterval(timer);
if (delay) {
callback();
}
}
}, 100);
return () => clearInterval(timer);
}, [delay, callback, targetTime, countDown, pauseTimeout]);
return {
countDown,
setPauseTimeout
};
}
It takes our delay
and close
callbacks as entries. It's all it needs to work its magic. The rest is handled by setInterval
. Why not setTimeout
?
An interval gives us the chance to interrupt it. With this tiny condition:
tsx
if (document.hidden || pauseTimeout) {
return;
}
We can check whether the page where the Toast lives is displayed. If not, we stop the countdown. The same condition also checks for pauseTimeout
, a value that is set in our view when the mouse is hovering the Toast or when its interactive elements are focused. We simply need the hook to export the "setter" for this flag, alongside the remaining countdown.
Calling the Stack
Once we're done coding our contents, we're ready to call the Stack and connect it to our store. That's actually the easiest part.
tsx
import * as Toast from "./domain/Toast/toastAPI";
import { applyStackModifier, useToastState } from "./domain/Toast/store";
export default function App() {
// Getting our state
const stackState = useToastState();
// This is what you'd use to send a Toast into the Stack
function handleAddToStack(toast: Toast.Toast) {
applyStackModifier(Toast.updateStack(toast))();
}
// Creating our closing callback
function closeToast(toast: Toast.Toast) {
applyStackModifier(Toast.removeFromStack(toast))();
}
return (
<div className="App">
...
<button onClick={() => handleAddToStack(
Toast.makeDefault({
copy: 'Thanks for reading',
id: 'test-1',
priority: 'MEDIUM',
createdAt: new Date(),
})
}>
Add to Stack
</button>
// Feeding the stack its required props
<Stack
stack={stackState}
onCloseToast={closeToast}
position="bottom left"
/>
</div>
);
}
With just that, we can add and remove Toast from the Stack without having to implement any individual Toast component anywhere. The store waits for our orders and the Stack reflects it, according to the rules we set.
Conclusion
That was a lot, so thanks for reading! I did leave some parts out, so if you feel like taking a deeper dive, you can get the whole code in this Codesandbox. But the best way to conclude an article about implementing a UI pattern is to let the reader try it for themselves.
So have at it!
Huge thanks to Guillaume for his peer-reviews and guidance.