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