DEV Community

Zafar Alam
Zafar Alam

Posted on

App wide Toast Notifications in Vue 3

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 and success 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

GitHub logo 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  };

}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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.

Our first Notification

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

GitHub logo 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 (3)

Collapse
 
llai profile image
lutzklai

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!

Collapse
 
zafaralam profile image
Zafar Alam

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 :)

Collapse
 
joeri profile image
JoeriTheGreat

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