Toast

In the below code example you can see how you can create and show a Toast notification and how you can use this in your Nuxt 3 app.

Screenshots

Toast

Implementation

plugin
// folder: /plugins/toast.ts
import type { Toast } from '~/types/site'

export default defineNuxtPlugin(() => {
  const toastMessages = useState<Array<Toast>>('toasts', () => [])

  const add = (input: Toast) => {
    if (!input || typeof input !== 'object') {
      throw new Error(`Toast data is invalid: ${input}`)
    }

    if (toastMessages.value.find((toast: Toast) => toast.id === input.id)) {
      throw new Error(`Toast with id: ${input.id} already exists`)
    }

    toastMessages.value = [...toastMessages.value, input]
  }

  const remove = (id: number) => {
    if (!id || typeof id !== 'number') {
      throw new Error(`Toast id is invalid: ${id}`)
    }

    toastMessages.value = toastMessages.value.filter((toast) => toast.id !== id)
  }

  return {
    provide: {
      toastMessages,
      toast: {
        add,
        remove
      }
    }
  }
})
component

//Code in this gist:
/*
-- components
---- toast
------- index.vue
------- item.vue
*/

// folder: /components/toast/index.vue
<template>
  <client-only>
    <div class="toaster">
      <div class="container">
        <transition-group name="toast-slide-in">
          <toast-item
            v-for="toast of $toastMessages"
            :key="toast.id"
            :toast-data="toast"
          />
        </transition-group>
      </div>
    </div>
  </client-only>
</template>

<script lang="ts" setup>
const { $toastMessages } = useNuxtApp()
</script>

<style lang="scss" scoped>
.toaster {
  position: fixed;
  inset: 0;
  padding-top: 1rem;
  pointer-events: none;
  @include z-index('toast');
  @include standardTransition(transform, 240ms, ease-in-out);

  &:not(.toaster__menu-visible) {
    transform: translateY(17.6rem);
  }

  @include breakpoint('md') {
    padding: 1rem;

    &:not(.toaster__menu-visible) {
      transform: translateY(14rem);
    }
  }

  .container {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
  }
}
.toast-slide-in-move,
.toast-slide-in-enter-active,
.toast-slide-in-leave-active {
  transition: transform 0.3s ease-out;
}

.toast-slide-in-enter-from,
.toast-slide-in-leave-to {
  transform: translateX(100%);
}
.toast-slide-in-leave-active {
  position: absolute;
}
</style>


// folder: /components/toast/item.vue
<template>
  <div
    :id="options.id.toString()"
    class="toast"
    @mouseover="paused = true"
    @mouseleave="paused = false"
  >
    <div class="toast__wrapper">
      <h6 v-if="options.title" class="toast__title">{{ options.title }}</h6>
      <div
        ref="description"
        class="toast__text"
        :class="{ 'toast__text--bold': !options.title }"
        v-html="options.text"
      ></div>
    </div>

    <button
      class="toast__close"
      :aria-label="$messages('aria.close')"
      @click="removeToast"
    >
      <svgo-close class="icon__close" />
      <svg v-if="options.autoClose" class="icon__circle">
        <circle r="15" cx="15" cy="15" :style="animateClosing"></circle>
      </svg>
    </button>
  </div>
</template>

<script lang="ts" setup>
import type { Toast } from '~/types/site'

const props = defineProps({
  toastData: {
    type: Object as PropType<Toast>,
    required: true
  }
})

const { $toast } = useNuxtApp()
const description = ref<HTMLElement | null>(null)

const autoCloseTimer = ref(60)
const dashArrayOffsetTimer = ref(0)
const dashArrayOffset = ref(0)
const paused = ref(false)

// set autoClose default: true
const options = {
  autoClose: true,
  ...props.toastData
} as Toast

const animateClosing = computed(
  () => `stroke-dashoffset: ${dashArrayOffset.value}px;`
)

onMounted(() => {
  if (options.autoClose) {
    initAutoClose()
  }

  if (!description.value) return

  const links = description.value.querySelectorAll('a')
  if (!links) return

  for (const link of links) {
    // TODO discuss
    // eslint-disable-next-line @typescript-eslint/no-misused-promises
    link.addEventListener('click', navigateToLink)
  }
})

const navigateToLink = async (event: Event) => {
  event.preventDefault()

  const el = event.target as HTMLElement

  const goto = el.getAttribute('href')
  if (!goto) return

  removeToast()
  await navigateTo(goto)
}

const initAutoClose = () => {
  const countdown = setInterval(() => {
    if (!paused.value) {
      autoCloseTimer.value = autoCloseTimer.value - 1
      dashArrayOffsetTimer.value = dashArrayOffsetTimer.value + 1

      dashArrayOffset.value = Math.ceil(
        (92 / 60) * dashArrayOffsetTimer.value + 21
      )

      if (autoCloseTimer.value <= 0) {
        clearInterval(countdown)
        removeToast()
      }
    }
  }, 100)
}

const removeToast = () => {
  $toast.remove(options.id)
}
</script>

<style lang="scss" scoped>
.toast {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  width: 100%;
  padding: 1.3rem 2rem 2rem;
  border-radius: 0.3rem;
  background: var(--white-color);
  box-shadow: 0px 0.3rem 1rem 0 rgba(30, 30, 30, 0.5);
  pointer-events: all;
  margin-bottom: 1.5rem;

  @include breakpoint('sd') {
    width: 34rem;
  }

  &__wrapper {
    flex: 0 0 100%;
    max-width: calc(100% - 3.8rem);
    justify-content: flex-start;
    align-items: center;
    gap: 0.8rem;
  }

  &__title {
    flex: 0 0 100%;
    max-width: calc(100% - 3rem);
    font-size: var(--heading-s-font-size);
    font-family: var(--body-font-bold);
  }

  &__text {
    padding-top: 0.5rem;
    line-height: 1.2;
    font-size: var(--body-font-size);
    line-height: var(--body-line-height);

    &--bold {
      font-family: var(--body-font-bold);
    }
  }

  &__close {
    flex: 0 0 3rem;
    height: 3rem;
    background: none;
    border-radius: 0;
    box-shadow: none;
    color: var(--black-color);
    border: none;
    padding: 0;
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;

    .icon {
      &__close {
        width: 1.2rem;
        height: 1.2rem;
        transform: translateY(0.05rem);
        @include standardTransition(transform);
      }

      &__circle {
        position: absolute;
        top: 0;
        left: 0;
        width: 3rem;
        height: 3rem;
        overflow: visible;
        transform: rotateY(-180deg) rotateZ(-90deg);

        circle {
          stroke-dasharray: 11.3rem;
          stroke-dashoffset: 0;
          stroke-linecap: round;
          stroke-width: 0.2rem;
          stroke: var(--secondary-color);
          fill: none;
          @include standardTransition(stroke-dashoffset, 60ms);
        }
      }
    }

    &:hover {
      &:after {
        transform: translateY(0.05rem) scale(1.05);
      }
    }
  }
}
</style>
usage
// folder: /layouts/index.vue
  <template>
    <header />
    <div class="main">
      <h1> Toast Example</h1>
    </div>
    <footer />

 <!-- Add the component -->
    <toast />
  </template>

  <script lang="ts" setup>
  onMounted(() => {
    if (route.query.logout) {
      // Call the function
      $toast.add({
        id: 1,
        text: $messages('authentication.logout.message')
      })
    }
  })
  </scrip>