Vue componentFree

Toast Stack

Vue 3 toast notification stack with push/dismiss API, success, error, info and warning variants, pause-on-hover timers and six positions. Tailwind CSS.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

A drop-in toast system: mount one ToastStack near the root of your app, grab it with a template ref and call push(), success(), error() and friends from anywhere you can reach it. Each call returns an id you can use to update or dismiss that toast later.

Toasts dismiss themselves after a few seconds, with a thin progress bar showing the time left. The countdown pauses while the pointer is over the stack, while keyboard focus is inside it and while the tab is hidden, so nobody misses a message they were reading. A loading variant stays up until you update() it to success or error, which covers the usual save-and-confirm flow.

The list is an aria-live region, and error toasts use role="alert" so they're announced straight away. Optional action buttons (like Undo) run a callback and close the toast. Change the corner with the position prop; on phones the stack spans the screen width.

  • Exposed API: push, update, dismiss, clear, success, error, info, warning
  • Auto-dismiss with pause on hover, focus and hidden tab
  • Loading toasts you can update in place
  • Action buttons such as Undo
  • Six positions and an aria-live region

Code

11 files in the repo
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'

export type ToastVariant = 'success' | 'error' | 'info' | 'warning' | 'loading'
export type ToastPosition = 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
export type DismissReason = 'timeout' | 'manual' | 'action' | 'overflow' | 'programmatic'

export interface ToastAction {
  label: string
  onClick: () => void
}

export interface ToastInput {
  /** Pass your own id to update or dismiss the toast later; otherwise one is generated. */
  id?: string
  title: string
  description?: string
  variant?: ToastVariant
  /** Milliseconds before auto-dismiss. 0 keeps it until dismissed. Loading toasts never auto-dismiss. */
  duration?: number
  action?: ToastAction
}

export interface Toast extends Required<Pick<ToastInput, 'id' | 'title' | 'variant' | 'duration'>> {
  description?: string
  action?: ToastAction
  createdAt: number
}

export interface ToastApi {
  push: (toast: ToastInput) => string
  update: (id: string, patch: Partial<Omit<ToastInput, 'id'>>) => void
  dismiss: (id: string) => void
  clear: () => void
  success: (title: string, description?: string) => string
  error: (title: string, description?: string) => string
  info: (title: string, description?: string) => string
  warning: (title: string, description?: string) => string
}

const props = withDefaults(defineProps<{
  position?: ToastPosition
  /** Default auto-dismiss time in milliseconds. */
  duration?: number
  /** Maximum toasts on screen; the oldest is removed when a new one would exceed it. */
  max?: number
  /** Pause every timer while the pointer is over the stack or focus is inside it. */
  pauseOnHover?: boolean
  /** Show a thin bar counting down the remaining time. */
  showProgress?: boolean
  /** Accessible name of the notifications region. */
  label?: string
}>(), {
  position: 'bottom-right',
  duration: 5000,
  max: 5,
  pauseOnHover: true,
  showProgress: true,
  label: 'Notifications',
})

const emit = defineEmits<{
  dismiss: [toast: Toast, reason: DismissReason]
}>()

const toasts = ref<Toast[]>([])
const hovered = ref(false)
const focusWithin = ref(false)
const pageHidden = ref(false)
const paused = computed(() => (props.pauseOnHover && (hovered.value || focusWithin.value)) || pageHidden.value)

/*
 * Each auto-dismissing toast owns a Web Animation on its progress bar. The animation is the timer:
 * pausing it pauses the countdown, and its `finish` event dismisses the toast. That keeps the bar and
 * the real remaining time perfectly in sync.
 */
const timers = new Map<string, Animation>()
let seq = 0

function effectiveDuration(t: Toast) {
  return t.variant === 'loading' ? 0 : t.duration
}

function startTimer(t: Toast, el: HTMLElement) {
  stopTimer(t.id)
  const ms = effectiveDuration(t)
  if (ms <= 0) return
  const animation = el.animate([{ transform: 'scaleX(1)' }, { transform: 'scaleX(0)' }], { duration: ms, easing: 'linear', fill: 'forwards' })
  animation.onfinish = () => remove(t.id, 'timeout')
  if (paused.value) animation.pause()
  timers.set(t.id, animation)
}

function stopTimer(id: string) {
  timers.get(id)?.cancel()
  timers.delete(id)
}

const bars = new Map<string, HTMLElement>()
// Function refs run again on every render; only (re)start the timer when the element is new.
// Cleanup happens in remove().
function registerBar(t: Toast, el: Element | null) {
  if (!(el instanceof HTMLElement) || bars.get(t.id) === el) return
  bars.set(t.id, el)
  startTimer(t, el)
}

function setPaused(value: boolean) {
  timers.forEach((a) => (value ? a.pause() : a.play()))
}

function remove(id: string, reason: DismissReason) {
  const index = toasts.value.findIndex((t) => t.id === id)
  if (index === -1) return
  const [toast] = toasts.value.splice(index, 1)
  stopTimer(id)
  bars.delete(id)
  if (toast) emit('dismiss', toast, reason)
}

function push(input: ToastInput): string {
  const id = input.id ?? `toast-${Date.now().toString(36)}-${++seq}`
  if (toasts.value.some((t) => t.id === id)) {
    update(id, input)
    return id
  }
  toasts.value.push({
    id,
    title: input.title,
    description: input.description,
    variant: input.variant ?? 'info',
    duration: input.duration ?? props.duration,
    action: input.action,
    createdAt: Date.now(),
  })
  while (toasts.value.length > props.max) {
    const oldest = toasts.value[0]
    if (!oldest) break
    remove(oldest.id, 'overflow')
  }
  return id
}

function update(id: string, patch: Partial<Omit<ToastInput, 'id'>>) {
  const toast = toasts.value.find((t) => t.id === id)
  if (!toast) return
  const restart = patch.variant !== undefined || patch.duration !== undefined
  Object.assign(toast, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)))
  const bar = bars.get(id)
  if (restart && bar) startTimer(toast, bar)
}

const api: ToastApi = {
  push,
  update,
  dismiss: (id) => remove(id, 'programmatic'),
  clear: () => [...toasts.value].forEach((t) => remove(t.id, 'programmatic')),
  success: (title, description) => push({ title, description, variant: 'success' }),
  error: (title, description) => push({ title, description, variant: 'error' }),
  info: (title, description) => push({ title, description, variant: 'info' }),
  warning: (title, description) => push({ title, description, variant: 'warning' }),
}

defineExpose(api)

function runAction(t: Toast) {
  t.action?.onClick()
  remove(t.id, 'action')
}

function onVisibility() {
  pageHidden.value = document.visibilityState === 'hidden'
}

function onFocusOut(event: FocusEvent) {
  const next = event.relatedTarget as Node | null
  const root = event.currentTarget as HTMLElement
  if (!next || !root.contains(next)) focusWithin.value = false
}

watch(paused, setPaused)

onMounted(() => document.addEventListener('visibilitychange', onVisibility))
onBeforeUnmount(() => {
  document.removeEventListener('visibilitychange', onVisibility)
  timers.forEach((a) => a.cancel())
  timers.clear()
})

const isTop = computed(() => props.position.startsWith('top'))

const positionClass = computed(() => ({
  'top-left': 'top-0 left-0 sm:items-start',
  'top-center': 'top-0 left-1/2 -translate-x-1/2 sm:items-center',
  'top-right': 'top-0 right-0 sm:items-end',
  'bottom-left': 'bottom-0 left-0 sm:items-start',
  'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2 sm:items-center',
  'bottom-right': 'bottom-0 right-0 sm:items-end',
}[props.position]))

const variantStyle: Record<ToastVariant, { icon: string; iconClass: string; bar: string }> = {
  success: {
    icon: 'M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.86-9.86a.75.75 0 0 0-1.22-.88l-3.24 4.5-1.62-1.62a.75.75 0 1 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.09l3.75-5.22Z',
    iconClass: 'text-emerald-500 dark:text-emerald-400',
    bar: 'bg-emerald-500',
  },
  error: {
    icon: 'M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z',
    iconClass: 'text-red-500 dark:text-red-400',
    bar: 'bg-red-500',
  },
  info: {
    icon: 'M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.25v3a.75.75 0 0 0 .75.75h1a.75.75 0 0 0 0-1.5h-.25V9.75A.75.75 0 0 0 10 9H9Z',
    iconClass: 'text-sky-500 dark:text-sky-400',
    bar: 'bg-sky-500',
  },
  warning: {
    icon: 'M8.48 2.95c.67-1.16 2.37-1.16 3.04 0l6.25 10.83c.67 1.17-.17 2.62-1.52 2.62H3.75c-1.35 0-2.19-1.45-1.52-2.62L8.48 2.95ZM10 6.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 6.5Zm0 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
    iconClass: 'text-amber-500 dark:text-amber-400',
    bar: 'bg-amber-500',
  },
  loading: { icon: '', iconClass: 'text-gray-400', bar: 'bg-gray-400' },
}
</script>

<template>
  <section
    :aria-label="label"
    class="pointer-events-none fixed z-[100] flex w-full flex-col p-4 sm:w-auto sm:p-6"
    :class="positionClass"
  >
    <TransitionGroup
      tag="ol"
      aria-live="polite"
      aria-relevant="additions text"
      class="flex w-full flex-col gap-3 sm:w-96"
      :class="isTop ? 'flex-col-reverse' : 'flex-col'"
      enter-active-class="transition duration-300 ease-out"
      :enter-from-class="isTop ? 'opacity-0 -translate-y-3 scale-95' : 'opacity-0 translate-y-3 scale-95'"
      leave-active-class="transition duration-200 ease-in"
      leave-to-class="opacity-0 scale-95"
      move-class="transition duration-300 ease-out"
      @mouseenter="hovered = true"
      @mouseleave="hovered = false"
      @focusin="focusWithin = true"
      @focusout="onFocusOut"
    >
      <li
        v-for="t in toasts"
        :key="t.id"
        :role="t.variant === 'error' ? 'alert' : undefined"
        class="pointer-events-auto relative flex w-full gap-3 overflow-hidden rounded-xl bg-white p-4 pr-10 shadow-lg ring-1 ring-gray-900/10 dark:bg-gray-900 dark:shadow-black/40 dark:ring-white/10"
      >
        <svg
          v-if="t.variant === 'loading'"
          viewBox="0 0 20 20"
          fill="none"
          aria-hidden="true"
          class="mt-0.5 size-5 shrink-0 animate-spin text-gray-400"
        >
          <circle cx="10" cy="10" r="7.25" stroke="currentColor" stroke-opacity="0.25" stroke-width="2" />
          <path d="M17.25 10A7.25 7.25 0 0 0 10 2.75" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        </svg>
        <svg v-else viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="mt-0.5 size-5 shrink-0" :class="variantStyle[t.variant].iconClass">
          <path fill-rule="evenodd" clip-rule="evenodd" :d="variantStyle[t.variant].icon" />
        </svg>

        <div class="min-w-0 flex-1">
          <p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t.title }}</p>
          <p v-if="t.description" class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ t.description }}</p>
          <button
            v-if="t.action"
            type="button"
            class="mt-2.5 rounded-md text-sm font-semibold text-emerald-700 hover:text-emerald-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
            @click="runAction(t)"
          >
            {{ t.action.label }}
          </button>
        </div>

        <button
          type="button"
          class="absolute top-3 right-3 rounded-md p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-emerald-600 dark:hover:bg-white/10 dark:hover:text-gray-200"
          :aria-label="`Dismiss: ${t.title}`"
          @click="remove(t.id, 'manual')"
        >
          <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
            <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
          </svg>
        </button>

        <div
          :ref="(el) => registerBar(t, el as Element | null)"
          aria-hidden="true"
          class="absolute inset-x-0 bottom-0 h-0.5 origin-left"
          :class="[variantStyle[t.variant].bar, showProgress && effectiveDuration(t) > 0 ? 'opacity-60' : 'opacity-0']"
        />
      </li>
    </TransitionGroup>
  </section>
</template>

Props

PropTypeDefaultDescription
position'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right''bottom-right'Screen corner or edge the stack is anchored to.
durationnumber5000Default auto-dismiss time in milliseconds. A toast can override it; 0 keeps it until dismissed.
maxnumber5Maximum toasts on screen. The oldest is removed when a new one would exceed it.
pauseOnHoverbooleantruePause all timers while the pointer is over the stack or focus is inside it.
showProgressbooleantrueShow a thin bar counting down each toast's remaining time.
labelstring'Notifications'Accessible name of the notifications region.

Events

EventPayloadDescription
dismiss(toast: Toast, reason: 'timeout' | 'manual' | 'action' | 'overflow' | 'programmatic')Fired when a toast is removed, with the reason.
All toasts & alerts →
Preview of Hero SplitFree

Component · Hero sections

Hero Split

Split Vue 3 hero section with copy and two CTAs on the left and a Tailwind-drawn app window mockup on the right. Dark mode, typed props, no images.

by Vueplay1 installs
Preview of Bento GridFree

Component · Feature sections

Bento Grid

Bento-style Vue 3 feature grid with wide and tall tiles and small CSS-drawn visuals like maps, timelines and code. Tailwind CSS, dark mode, typed tiles.

by Vueplay
Preview of BlockFree

Component · Layout

Block

Preview of BoardFree

Component · Data display

Board

A flexible board component built for smooth panning, zooming with a grid layout. It supports both mouse and touch input, making it a great fit for whiteboards, diagram tools, mind maps, and workflow editors. Designed for performance and responsiveness, it delivers a fluid experience on desktop and mobile with intuitive gestures and real-time interaction on a scalable canvas.

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings