App wide Toast Notifications in Vue 3
Tags: vue, vue3, typescript, vite
Published: 2021-09-30 06:12:43

## 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](https://jolly-ritchie-3aac9d.netlify.app/) for the sample app.

You can access the full code of example project at {% github zafaralam/vue-3-toast no-readme %}

## 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](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)** 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

```shell
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**
```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**
```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**
```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.

```ts
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.

```ts
// ...

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.

```ts
// ...

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.

```ts
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.

```ts
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**
```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](https://v3.vuejs.org/guide/component-provide-inject.html) on the Vue documentation site.

```ts
// 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**
```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.

![Our first Notification](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8mzw5qq9ktscf8550qw9.gif)

## 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**
```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**
```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](https://twitter.com/zafalam)

You can access the full code of example project at {% github zafaralam/vue-3-toast no-readme %}

Hope you enjoyed this post and found the information useful.

Thanks for reading and happy coding!!!