There are many different plugins for Vue or modules for Nuxt that you can simply install and use to display toasts in your application. However, sometimes you might want to create your own custom toast functionality. This could be because you want to have more control over the design and behavior of the toasts, or because you want to learn how to create your own custom functionality.
Introduction
The main point of this note is for me to document how I created my own re-usable toast component(s) in Nuxt, how to use them and how to customize them. This way, I can always refer back to this note when I need to implement toasts in a new project (when not going with a pre-made solution).
In summary, the toast system I went for consists of the following three parts:
The Composable
The first thing I did was to create a composable that would be responsible for managing the toasts. This composable would expose methods to add, or remove toasts, and would also expose the reactive list of currently active toasts.
import type { Toast, ToastOptions, ToastParams } from "~/types/toast";
const toasts = ref<Toast[]>([]);
export const useToast = () => {
/**
* Create a new toast
* @param toast Toast parameters, including type, title, and message
* @param options Toast lifetime, in milliseconds, or 0 for no lifetime, default 5_000
*/
const addToast = (toast: ToastParams, options: ToastOptions = {}) => {
const { lifetime = 5_000 } = options;
const id = Math.random().toString(36).substring(7) + Date.now();
toasts.value.push({ ...toast, id, lifetime: lifetime });
// If the toast has a lifetime, remove it after the lifetime
if (lifetime > 0) {
setTimeout(() => {
removeToast(id);
}, lifetime);
}
};
/**
* Create a new error toast (helper for normal toast, with some options overridden)
* @param message The error message
* @param overrideOptions Any options to override the default options
*/
const addErrorToast = (message: string, overrideOptions: ToastOptions = {}) => {
addToast(
{ type: "error", title: "Error", message },
{
...overrideOptions,
lifetime: 8_500,
}
);
};
// Remove a toast from the list of toasts
const removeToast = (id: string) => {
toasts.value = toasts.value.filter((toast) => toast.id !== id);
};
return {
toasts,
addToast,
addErrorToast,
removeToast,
};
};
I wanted to keep the types extracted in a central place, so I created a types/toast.ts
file that contains the types for the toast objects, as well as the parameters and options that can be passed to the addToast
method - this way, I can just import them to other files that need them.
Toast
is the type of the toast object, containing the type, id, title, message, and lifetime. Thetype
can be one of "success", "error", "info", or "warning", which are used to determine the appearance of the toast.ToastParams
is the type of the parameters that can be passed to theaddToast
method, containing thetype
,title
, and optionally themessage
. They are bundled up in an object to make theaddToast
method nicer to use.ToastOptions
is the type of the options that can be passed to theaddToast
method, currently only containing the lifetime, but could be expanded in the future.
export type Toast = {
type: "success" | "error" | "info" | "warning";
id: string;
title: string;
message?: string;
lifetime: number;
};
export type ToastParams = {
type: "success" | "error" | "info" | "warning";
title: string;
message?: string;
};
export type ToastOptions = {
lifetime?: number;
};
The Toast Layer
What I mean with "layer" is the HTML element that will contain the toasts, taking care of the positioning and animations of the conditionally rendered toasts within it. This layer will be a Vue component that will be placed at the root of the application, so that it can be accessed from anywhere.
1. ToastLayer - Script
In the script part of the ToastLayer component, we only need to get access to the toasts
list and the removeToast
method from the useToast
composable.
<script lang="ts" setup>
const { toasts, removeToast } = useToast();
</script>
2. ToastLayer - Template
In the template part, we will loop through the toasts
list and render a Toast
component for each toast. We will also bind the removeToast
method to the @remove-toast
event of the Toast
component, so that the toast can be removed when it is closed.
I also used the Teleport
component from Vue 3 to move the Toast
components to the body of the document, so that they are not affected by the CSS of any other potential parent elements, isolating them from the rest of the application. The ClientOnly
component is not really necessary, but as I never expect any server-side rendering to happen in this case, I added it to be safe. Finally, I used the TransitionGroup
component to animate the toasts when they are added or removed.
<template>
<ClientOnly>
<Teleport to="body">
<TransitionGroup name="toast" tag="div" class="toast-layer">
<Toast v-for="toast in toasts" :key="toast.id" :toast="toast" @remove-toast="removeToast" />
</TransitionGroup>
</Teleport>
</ClientOnly>
</template>
3. ToastLayer - Style
In the style part, I take care of how and where the toasts are displayed, as well as defining the animations that are used by the TransitionGroup
when the toasts are added or removed. In this instance, toasts will slide in from the right and slide out to the right with a fade effect. I positioned the toasts to the top right of the screen, and let them stack vertically via flexbox.
<style scoped>
.toast-enter-active, .toast-leave-active, .toast-move {
transition: opacity 0.75s;
transform: translateX(0);
transition: transform 0.75s ease-in-out, opacity 0.75s ease-in-out;
}
.toast-enter-active {
transition-duration: 0.25s;
transition-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.toast-enter-from, .toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-active {
position: absolute;
}
.toast-layer {
position: fixed;
top: 0;
right: 0;
z-index: 100000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1rem;
padding: 1rem;
width: 100%;
pointer-events: none;
}
</style>
With this, the ToastLayer
component is complete. It will now display the toasts that are added to the toasts
list, and remove them when they are closed - now there's only the Toast
component left to create.
The Toast Component
I kept the Toast
component simple, as it only needs to display the title and message of the toast, and provide a way to close it. The Toast
component will receive the toast
object as a prop, and emit a remove-toast
event when the close button is clicked.
1. Toast - Script
In the script part of the Toast
component, we define the props that the component will receive, and the method to emit the remove-toast
event when the close button is clicked.
<script lang="ts" setup>
import type { Toast } from "~/types/toast";
defineProps({
toast: {
type: Object as PropType<Toast>,
required: true,
},
});
const emits = defineEmits({
removeToast: (_id: string) => true,
});
</script>
2. Toast - Template
As for the template, I kept it simple - a rectangle with a title and a message, a close button to the right, and a "progress bar" that indicates the remaining lifetime, if necessary.
We've got a wrapper component element, that wraps around the actual toast and the lifetime progress bar, the toast element, which contains the title and message, and the close button, which emits the remove-toast
event when clicked.
The lifetime progress bar is a simple div that is styled to have a width of 100%, that continually shrinks to the left as the toast's lifetime decreases. The --toast-lifetime
CSS variable is used to set the lifetime of the toast, which is passed in milliseconds and converted to seconds.
<template>
<div class="toast-wrapper">
<div class="toast" :class="toast.type" :style="{ '--toast-lifetime': `${toast.lifetime / 1000}s` }">
<div class="toast-content">
<div class="toast-title">{{ toast.title }}</div>
<div class="toast-message" v-if="toast.message">{{ toast.message }}</div>
</div>
<UIIconButton class="toast-close" icon="mdi:close" size="24" color="#31724E" @click="emits('removeToast', toast.id)" />
</div>
<div class="toast-lifetime" :style="{ '--toast-lifetime': `${toast.lifetime / 1000}s` }">
<div class="toast-lifetime-inner"></div>
</div>
</div>
</template>
3. Toast - Style
The main styling for the toast's appearance happens in this file, so I'll split it up a little.
Toast type styling
I directly use the toast.type
class to set the border-top color of the toast, which is used to indicate the type of the toast. The colors are defined in the global SCSS file, and are used to give the toasts a consistent look across the application. Feel free to use any color you might like.
.toast.success {
border-top-color: var(--green);
}
.toast.error {
border-top-color: var(--red);
}
.toast.info {
border-top-color: var(--light-blue);
}
.toast.warning {
border-top-color: var(--yellow);
}
The Toast wrapper and main element
The toast-wrapper
class is used to style the outermost element of the toast, which contains the toast and the lifetime progress bar. It is styled to have a drop shadow, rounded corners, and to be hidden when the toast is not visible.
The toast
is the main element, containing the title, message, and close button. It is styled to have a white background, a border-top color that indicates the type of the toast, and a border radius that rounds the top corners.
.toast-wrapper {
width: 100%;
max-width: calc(min(100vw, 400px) - 2rem);
display: flex;
flex-direction: column;
filter: drop-shadow(0 0 25px rgba(0, 0, 0, 0.2));
border-radius: 0 0 0.5rem 0.5rem;
overflow: hidden;
}
.toast {
display: flex;
flex-direction: row;
gap: 1rem;
padding: 1rem;
border-radius: 0.5rem 0.5rem 0 0;
background: $white;
pointer-events: auto;
border-top: 4px solid var(--main-green);
width: 100%;
--toast-lifetime: 5s;
}