Motivation
I'm working on an app that had a requirement to show toast notifications for the various actions that were performed by the user or alerting on errors while performing background tasks. The app frontend is built using Vue 3 and typescript.
So I started searching for a Vue 3 plugin to cut down on building my own but after spending a couple of hours and looking at the various options currently available I decided to build my own version.
A number of coffees and quite a few hair pulling hours later I finally came up with a solution. It took a couple more hours to build and test my theory and here I am to share what I did.
What was required from the notifications?
- Notifications can be created from any component, route.
- Should be able to create
info
,warning
,error
andsuccess
notifications. - Notifications should be auto dismissible by default (should have a progress bar for remaining time).
- Can have persistent notifications
- Can dismiss notification by clicking them
Just want the code
You can access the Demo for the sample app.
You can access the full code of example project at
zafaralam / vue-3-toast
An example of how to implement toast notifications in your Vue3 apps
Creating a Vue 3 project
We are going to start with creating a blank project using Vite for Vue 3 project. You can read more about getting started with Vite at the docs.
We are going to use typescript for this example project.
I've listed the commands below for creating the project and adding the required dependencies
yarn create vite vue-3-toast --template vue-ts
# once the above command completes you can move into
# the new directory created and run the following
# commands to add sass and vue-router
yarn add sass vue-router@4 remixicon
# you can test your project is created successfully
# by running
yarn dev
Your project structure should like below at this stage
├───node_modules
├───public
│ └───favicon.ico
├───src
│ ├───App.vue
│ ├───main.ts
│ ├───env.d.ts
│ ├───assets
│ │ └──logo.png
│ └───components
│ └──HelloWorld.vue
├───.gitignore
├───index.html
├───package.json
├───README.md
├───tsconfig.json
├───vite.config.js
└───yarn.lock
We will now add a couple of routes in our application.
Let create a Home.vue and Contact.vue files under the src folder of your project. We will update these files later.
Create a router.ts file under src folder of your project and add the following code.
router.ts
import { createRouter, createWebHistory } from "vue-router";
import Home from "./Home.vue";
import Contact from "./Contact.vue";
const history = createWebHistory();
const routes = [
{
path: "/",
name: "home",
component: Home,
},
{
path: "/contact",
name: "contact",
component: Contact,
},
];
const router = createRouter({ history, routes });
export default router;
Update your main.ts file with the following code
main.ts
import { createApp } from "vue";
import "remixicon/fonts/remixicon.css";
import App from "./App.vue";
import router from "./router"
createApp(App).use(router).mount("#app");
We have added the router to our app and also included remixicon icon library for some font-icons (You can use other icons as you see fit).
Let update our App.vue file to have a router view and links to our Home, Contact pages.
Note: I'm using the setup script tag sugar for my .vue files but you don't have too.
App.vue
<script setup lang="ts"></script>
<template>
<div class="main">
<nav>
<router-link to="/">Home</router-link>
<router-link to="/contact">Contact</router-link>
</nav>
<router-view></router-view>
</div>
</template>
<style lang="scss">
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
height: 100vh;
width: 100vw;
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
&.hide-overflow {
overflow: hidden;
}
}
.main {
display: flex;
flex-direction: column;
gap: 2rem;
nav {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
height: 4rem;
a {
padding: 0.5rem;
&:hover {
background: whitesmoke;
}
}
}
}
</style>
Creating a our composition function
We are going to create a composition function for managing our notifications. We don't need vuex for this as it would be a bit of overkill (You can if you like).
We can start by creating a notifications.ts file under src folder of our project.
We will add a Notification interface, CreateNotification type and defaultNotificationOptions varaiable in the file.
export interface Notification {
id: string;
type: string;
title: string;
message: string;
autoClose: boolean;
duration: number;
}
The above interface will be used to create a reactive reference for our notifications.
// ...
export type CreateNotification = {
(options: {
type?: string;
title?: string;
message?: string;
autoClose?: boolean;
duration?: number;
}): void;
};
The above type will be used by other parts of the app to create new notifications.
// ...
const defaultNotificationOptions = {
type: "info",
title: "Info Notification",
message:
"Ooops! A message was not provided.",
autoClose: true,
duration: 5,
};
The defaultNotificationOptions variable provides sensible defaults for our notifications. You can change the values as you like.
We will also be adding a utility function to generate unique id's for our notifications. Add it to the notifications.ts file.
function createUUID(): string {
let dt = new Date().getTime();
var uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
var r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16);
return (c == "x" ? r : (r & 0x3) | 0x8).toString(16);
}
);
return uuid;
}
Now, we can create our composition function.
Note: Add the ref import from Vue at the top of the notifications.ts file. import { ref } from "vue";
Our useNotifications composition function will be quite simple and will provide ability to add, remove notifications and also a list of current notifications.
export default function useNotifications() {
// Reactive array of notifications.
const notifications = ref<Notification[]>([]);
// function to create notification
const createNotification: CreateNotification = (options) => {
const _options = Object.assign({ ...defaultNotificationOptions }, options);
notifications.value.push(
...[
{
id: createUUID(),
..._options,
},
]
);
};
// function to remove notification
const removeNotifications = (id: string) => {
const index = notifications.value.findIndex((item) => item.id === id);
if (index !== -1) notifications.value.splice(index, 1);
};
// The two functions below are more for body
// overflow when creating notifications that slide
// in from outside the viewport. We will not be
// using them for now but good to have.
const stopBodyOverflow = () => {
document && document.body.classList.add(...["hide-overflow"]);
};
const allowBodyOverflow = () => {
document && document.body.classList.remove(...["hide-overflow"]);
};
// You need this to ensure we can use the
// composition function.
return {
notifications,
createNotification,
removeNotifications,
stopBodyOverflow,
allowBodyOverflow,
};
}
Creating a Toast Notification Component
We have done most of the hard work and now we will be creating a notification component to display our notification.
We start by creating ToastNotification.vue file under src/components folder of our project.
ToastNotification.vue
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
// Props for our component,
// these are the same as Notitfication interface.
const props = defineProps({
id: { type: String, required: true },
type: {
type: String,
default: "info",
required: false,
},
title: { type: String, default: null, required: false },
message: {
type: String,
default: "Ooops! A message was not provided.",
required: false,
},
autoClose: { type: Boolean, default: true, required: false },
duration: { type: Number, default: 5, required: false },
});
// Defining emits
// for closing a notification
const emit = defineEmits<{
(e: "close"): void;
}>();
// some reactive values to manage the notification
const timer = ref(-1);
const startedAt = ref<number>(0);
const delay = ref<number>(0);
// setting up the automatic
// dismissing of notificaton
// after the specified duration
onMounted(() => {
if (props.autoClose) {
startedAt.value = Date.now();
delay.value = props.duration * 1000;
timer.value = setTimeout(close, delay.value);
}
});
// a computed property to set
// the icon for the notification
const toastIcon = computed(() => {
switch (props.type) {
case "error":
return "ri-emotion-unhappy-line";
case "warning":
return "ri-error-warning-line";
case "success":
return "ri-emotion-happy-line";
default:
return "ri-information-line";
}
});
// a computed property to set
// the icon and progres bar color
// for the notification
const toastColor = computed(() => {
switch (props.type) {
case "error":
return "#ff355b";
case "warning":
return "#e8b910";
case "success":
return "#00cc69";
default:
return "#0067ff";
}
});
// a computed property to set
// the title of the notification
const toastTitle = computed(() => {
return props.title && props.title !== null ? props.title : "Notification";
});
// a method to close the
// notification and emit the action
const close = () => {
emit("close");
};
</script>
<template>
<div
class="toast-notification"
:style="`--toast-duration: ${duration}s; --toast-color: ${toastColor}`"
@click.prevent="close"
:ref="id"
>
<div @click="close" class="close-btn" title="Close">
<i class="ri-icon ri-lg ri-close-fill"></i>
</div>
<div class="body">
<i :class="`ri-icon ri-2x ${toastIcon}`"></i>
<div class="vl"></div>
<div class="content">
<div class="content__title">{{ toastTitle }}</div>
<p class="content__message">{{ message }}</p>
</div>
</div>
<div v-if="autoClose" class="progress"></div>
</div>
</template>
<style lang="scss" scoped>
.toast-notification {
--toast-color: #0067ff;
cursor: pointer;
max-width: 450px;
position: relative;
background: white;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.08),
0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
min-height: 4rem;
padding-inline: 1.5rem;
padding-block: 1.2rem;
transition: all 0.3s ease-in-out;
.close-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: flex;
place-items: center;
justify-content: center;
height: 32px;
width: 32px;
transition: var(--all-transition);
cursor: pointer;
&:hover {
box-shadow: 0px 0px 10px rgb(228, 228, 228);
border-radius: 50%;
}
}
.body {
display: flex;
gap: 1.4rem;
place-items: center;
i {
color: var(--toast-color);
}
.vl {
background: #e4e4e4;
width: 0.12rem;
height: 3rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.1rem;
&__title {
font-weight: 600;
}
}
}
.progress {
position: absolute;
bottom: 0px;
left: 0;
height: 0.4rem;
width: 100%;
background: var(--toast-color);
animation: progress var(--toast-duration) ease-in-out forwards;
}
@keyframes progress {
to {
width: 0;
}
}
@keyframes toast-fade-in {
to {
opacity: 1;
}
}
@keyframes toast-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
}
</style>
I've added comments in the code for what each item in the component is used for. This is a pretty regular component if you are familiar with Vue.
Rendering notifications in the App
We can now add rendering of notifications to the app and also ability throughout the app to add notifications.
First we are going to import the useNotifications composition function and initialize it.
We have also imported the provide
helper from vue
package to provide the ability to create notifications anywhere in the app. This is what makes our notifications central within the app. You can read more about Provide/Inject on the Vue documentation site.
// inside <script setup lang="ts">
import { provide } from "vue";
import useNotifications from "./notifications";
const {
notifications,
createNotification,
removeNotifications,
stopBodyOverflow,
allowBodyOverflow,
} = useNotifications();
provide("create-notification", createNotification);
We can now update the template section of the App.vue file to render the notifications. Update the App.vue file code with the below code.
App.vue
<script setup lang="ts">
import { provide } from "vue";
import useNotifications from "./notifications";
import ToastNotification from "./components/ToastNotification.vue";
const {
notifications,
createNotification,
removeNotifications,
stopBodyOverflow,
allowBodyOverflow,
} = useNotifications();
provide("create-notification", createNotification);
</script>
<template>
<div class="main">
<nav>
<router-link to="/">Home</router-link>
<router-link to="/contact">Contact</router-link>
</nav>
<div class="btn-group">
<button
@click.prevent="
() => {
createNotification({
message: 'This is a notification from the App.vue Component',
});
}
"
>
Notification From App Component
</button>
</div>
<div class="router-view">
<router-view></router-view>
</div>
<transition-group
name="toast-notification"
tag="div"
class="toast-notifications"
@before-enter="stopBodyOverflow"
@after-enter="allowBodyOverflow"
@before-leave="stopBodyOverflow"
@after-leave="allowBodyOverflow"
>
<toast-notification
v-for="(item, idx) in notifications"
:key="item.id"
:id="item.id"
:type="item.type"
:title="item.title"
:message="item.message"
:auto-close="item.autoClose"
:duration="item.duration"
@close="
() => {
removeNotifications(item.id);
}
"
></toast-notification>
</transition-group>
</div>
</template>
<style lang="scss">
* {
padding: 0;
margin: 0;
box-sizing: border-box;
transition: all 0.3s ease-in-out;
}
body {
height: 100vh;
width: 100vw;
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
&.hide-overflow {
overflow: hidden;
}
}
button {
text-transform: uppercase;
padding-inline: 0.6rem;
padding-block: 1rem;
font-weight: 600;
cursor: pointer;
border: 1px solid gainsboro;
&:hover,
&:focus {
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.08),
0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
}
}
.btn-group {
display: flex;
gap: 1rem;
}
.page-content {
margin-block: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.main {
display: flex;
flex-direction: column;
gap: 2rem;
padding-inline: 2rem;
nav {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
height: 4rem;
a {
padding: 0.5rem;
&:hover {
background: whitesmoke;
}
}
}
.router-view {
border-block-start: 2px solid whitesmoke;
}
.toast-notifications {
z-index: 100;
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: column-reverse;
gap: 0.8rem;
}
.toast-notification-enter-active {
animation: toast-fade-in 0.5s ease-in-out;
}
.toast-notification-leave-active {
animation: toast-fade-in 0.5s ease-in-out reverse;
}
@keyframes toast-fade-in {
from {
opacity: 0;
transform: scale(0.4);
}
to {
opacity: 1;
transform: scale(1);
}
}
}
</style>
Wow! that was a bit of work and you should be happy to have made it this far. Let's take a moment to enjoy what we have built so far.
Run the app using yarn dev
from your terminal and open the app in a browser window.
You should see a button to generate notifications on the page. Have a crack, you earned it.
Updating Home and Contact Route pages
Update the code in the Home.vue and Contact.vue files as per below and you will be ready to generate notifications from both routes. We are using the Inject
helper from the vue package to create our notifications.
Home.vue
<script setup lang="ts">
import { inject } from "vue";
import { CreateNotification } from "./notifications";
const createNotification = <CreateNotification>inject("create-notification");
</script>
<template>
<div class="page-content">
<h2>Home Page</h2>
<div class="btn-group">
<button
@click.prevent="createNotification({ message: 'Info Home Page' })"
>
Info
</button>
<button
@click.prevent="
createNotification({
type: 'error',
message: 'Error Notification from Home Page',
duration: 10,
})
"
>
Error
</button>
<button
@click.prevent="
createNotification({
type: 'warning',
message: 'Warning Notification from Home Page',
})
"
>
Warning
</button>
<button
@click.prevent="
createNotification({
type: 'success',
message: 'Success Notification from Home Page',
})
"
>
Success
</button>
<button
@click.prevent="
createNotification({
message: 'Persistant Notification Home Page',
autoClose: false,
})
"
>
Persistant Info
</button>
</div>
</div>
</template>
<style lang="scss" scoped></style>
Contact.vue
<script setup lang="ts">
import { inject } from "vue";
import { CreateNotification } from "./notifications";
const createNotification = <CreateNotification>inject("create-notification");
</script>
<template>
<div class="page-content">
<h2>Contact Page</h2>
<div class="btn-group">
<button
@click.prevent="createNotification({ message: 'Info Contact Page' })"
>
Info
</button>
<button
@click.prevent="
createNotification({
type: 'error',
message: 'Error Notification from Contact Page',
duration: 10,
})
"
>
Error
</button>
<button
@click.prevent="
createNotification({
type: 'warning',
message: 'Warning Notification from Contact Page',
})
"
>
Warning
</button>
<button
@click.prevent="
createNotification({
type: 'success',
message: 'Success Notification from Contact Page',
})
"
>
Success
</button>
<button
@click.prevent="
createNotification({
message: 'Persistant Notification Contact Page',
autoClose: false,
})
"
>
Persistant Info
</button>
</div>
</div>
</template>
<style lang="scss" scoped></style>
That's all folks!!!
We were able to accomplish the requirements we mentioned at the start.
If you can think of any improvements or fixes to the example project I would love to know. Leave a comment or create an issue in the linked Github repo.
Send me a message on Twitter
You can access the full code of example project at
zafaralam / vue-3-toast
An example of how to implement toast notifications in your Vue3 apps
Hope you enjoyed this post and found the information useful.
Thanks for reading and happy coding!!!
Top comments (5)
Great article! I am considering converting one of my vue2 option api applications to vue3 composition api. This definitely helps me wrap my head around app-wide notifications.
I'm curious how you handle notifications within apis. For example if I had composable similar to vue's example (vuejs.org/guide/reusability/compos...) and wanted to show a success or error message after an api call finishes. Is it as simple as nesting the composable and calling
createNotification
within the api composable? If so and I write a test for my api composable, do I end up just mocking the notification composable?Thank you for the article!
Grey to hear that the article helped you. Your logic sounds good for mocking the notification composable.
Would be keen to see your implementation.
Good luck and enjoy the Vue :)
I tried to make it into a plugin so i can just call the createNotification but failed.
Would be nice if you created something so you don't need to use the inject every time
Hey! I know it's been a while but if you want don't want to use the inject all the time then you can create a compostable that does it for you.
Yes we did just that.
But like a said it's such a nice and simple way it would be great if this would be a vue3 plugin.