Custom Toasts in Nuxt

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:

  1. The composable that manages the toasts
  2. The toast layer component
  3. The toast component

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.

useToasts.ts
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. The type 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 the addToast method, containing the type, title, and optionally the message. They are bundled up in an object to make the addToast method nicer to use.
  • ToastOptions is the type of the options that can be passed to the addToast method, currently only containing the lifetime, but could be expanded in the future.
types/toast.ts
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.

components/Toast/ToastLayer.vue
<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.

components/Toast/ToastLayer.vue
<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.

components/Toast/ToastLayer.vue
<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.

components/Toast/Toast.vue
<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.

components/Toast/Toast.vue
<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.

components/Toast/Toast.vue
.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.

components/Toast/Toast.vue
.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;
}