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.
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
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.
<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>
| Prop | Type | Default | Description |
|---|---|---|---|
| position | 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' | 'bottom-right' | Screen corner or edge the stack is anchored to. |
| duration | number | 5000 | Default auto-dismiss time in milliseconds. A toast can override it; 0 keeps it until dismissed. |
| max | number | 5 | Maximum toasts on screen. The oldest is removed when a new one would exceed it. |
| pauseOnHover | boolean | true | Pause all timers while the pointer is over the stack or focus is inside it. |
| showProgress | boolean | true | Show a thin bar counting down each toast's remaining time. |
| label | string | 'Notifications' | Accessible name of the notifications region. |
| Event | Payload | Description |
|---|---|---|
| dismiss | (toast: Toast, reason: 'timeout' | 'manual' | 'action' | 'overflow' | 'programmatic') | Fired when a toast is removed, with the reason. |
Component · Hero sections
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.
Component · Feature sections
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.
Component · Layout
Component · Data display
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.