vue-toast-kit — система уведомлений для Vue 3 без компромиссов

Toast-уведомления нужны в каждом Vue-приложении. Два варианта: взять vue-toastification (последний релиз три года назад, API устарел) или vue-sonner (красиво, но минималистично — Promise API есть, а дальше сам). Третий вариант — написать свой.
vue-toast-kit — пакет с полным набором фич для Vue 3 и Nuxt 3. Promise API с автопереключением типа, приоритетная очередь с вытеснением, undo с прогресс-таймером, группировка тостов, headless режим, design system из 30+ CSS custom properties, stack mode как в Sonner, event emitter для аналитики, rate limiting, localStorage persist. 9.2 KB gzip, одна peer-зависимость — Vue 3.

Установка
bash
npm install vue-toast-kit
ts
// main.ts
import { createApp } from 'vue'
import { VueToastPlugin } from 'vue-toast-kit'
import 'vue-toast-kit/style'
import App from './App.vue'
const app = createApp(App)
app.use(VueToastPlugin, { position: 'bottom-right', theme: 'system' })
app.mount('#app')
vue
<!-- App.vue — один контейнер на всё приложение -->
<template>
<RouterView />
<ToastContainer />
</template>
<ToastContainer> регистрируется глобально плагином — импортировать не нужно.
Базовый API
useToast() работает внутри компонентов. Именованный синглтон toast — вне компонентов: в Pinia-сторах, axios-интерцепторах, роутере.
ts
import { useToast } from 'vue-toast-kit'
const toast = useToast()
toast.success('Файл загружен')
toast.error('Соединение прервано')
toast.warning('Осталось 5 МБ из 10 МБ')
toast.info('Обновление доступно')
toast.loading('Подключение к серверу…') // sticky, без автозакрытия
ts
// Вне компонента — в Pinia store, axios interceptor и т.д.
import { toast } from 'vue-toast-kit'
axios.interceptors.response.use(null, (err) => {
toast.error(`Ошибка: ${err.message}`)
return Promise.reject(err)
})
Каждый вызов возвращает id. Через него можно обновить или закрыть тост позже.
ts
const id = toast.loading('Обработка…')
// ...
toast.update(id, { type: 'success', message: 'Готово!' })
// Или только текст, без изменения опций:
toast.updateMessage(id, 'Почти готово…')
// Закрыть программно:
toast.dismiss(id)
Promise API
Самое частое применение loading-тоста — ждать промис. toast.promise делает это за вас: показывает loading, переключает в success или error по результату, возвращает исходный промис нетронутым.
ts
const user = await toast.promise(
fetchUser(id),
{
loading: 'Загружаем профиль…',
success: (u) => `Добро пожаловать, ${u.name}!`,
error: (e) => `Не удалось загрузить: ${(e as Error).message}`,
},
)
Функции success и error получают данные и ошибку — можно строить динамические сообщения. Reject пробрасывается дальше, try/catch в вашем коде работает как обычно.
В Pinia store:
ts
// stores/files.ts
import { toast } from 'vue-toast-kit'
export const useFileStore = defineStore('files', {
actions: {
async upload(file: File) {
return toast.promise(
uploadAPI(file),
{
loading: `Загружаем ${file.name}…`,
success: (res) => `${res.name} загружен (${res.size} KB)`,
error: (e) => `Ошибка: ${(e as Error).message}`,
},
)
},
},
})
Undo с прогресс-таймером

Пользователь удалил запись — нужно дать несколько секунд на отмену. toast.undo рисует прогресс-бар внутри тоста. Клик по кнопке — вызывается onUndo и тост закрывается. Таймер вышел — тост закрывается молча, onAutoClose для финальной операции.
ts
function deleteFile(id: string) {
markForDeletion(id)
toast.undo(`Файл "${fileName}" удалён`, {
undo: {
label: 'Восстановить',
duration: 6000,
onUndo: () => {
restoreFile(id)
toast.success('Файл восстановлен')
},
},
onAutoClose: () => permanentlyDelete(id),
})
}
onUndo может быть async — ошибки внутри не сломают тост:
ts
toast.undo('Письмо заархивировано', {
undo: {
onUndo: async () => {
await api.restore(emailId)
toast.success('Письмо возвращено во входящие')
},
},
})
Приоритетная очередь
Четыре уровня: critical, high, normal, low. Когда слоты заполнены и приходит тост с высоким приоритетом — он вытесняет тост с низким. Вытесненный переходит в pending-очередь и всплывает когда место освободится.
ts
// Слоты заполнены нормальными тостами
toast.error('Критическая ошибка сервера', { priority: 'critical' })
// Вытолкнет самый низкоприоритетный тост в pending, встанет сам
Pending-очередь отсортирована по приоритету. Освобождается слот — первым встаёт самый важный.
ts
// Для некритичных уведомлений
toast.info('Новое сообщение от Алексея', { priority: 'low' })
toast.success('Автосохранение')
// Эти не будут вытеснять другие, просто встанут в очередь
maxVisible задаётся на <ToastContainer>:
vue
<ToastContainer :max-visible="3" position="bottom-right" />
Группировка

Несколько тостов с одним groupKey складываются в стопку с каунтером +N. Клик по каунтеру — разворачивает группу, показывает все тосты. Закрытие лидера — следующий тост автоматически становится лидером.
ts
// Три вызова — один видимый тост "+2"
toast.info('Новое сообщение от Алисы', { groupKey: 'messages' })
toast.info('Новое сообщение от Бориса', { groupKey: 'messages' })
toast.info('Новое сообщение от Карины', { groupKey: 'messages' })
Удобно для нотификаций о системных событиях, которые могут массово прийти за короткое время.
Stack mode (Sonner-style)

stackMode складывает тосты в визуальный стек — видна только верхняя карточка, за ней угадываются до двух следующих, слегка уменьшенных и сдвинутых. Наведение мыши — стек разворачивается в обычный список.
vue
<ToastContainer :stack-mode="true" position="bottom-right" />
Полезно когда нотификации появляются часто и не должны занимать много места на экране.
Design System
30+ CSS custom properties с тремя готовыми темами.
vue
<!-- Тема через prop -->
<ToastContainer theme="dark" />
<ToastContainer theme="system" /> <!-- следует prefers-color-scheme -->
Токены можно переопределить глобально в :root или передать объект прямо в prop:
vue
<ToastContainer
:theme="{
colorBg: '#1a1a2e',
colorText: '#e2e8f0',
colorSuccess: '#00ff88',
colorError: '#ff4d6d',
borderRadius: '16px',
shadow: '0 8px 32px rgba(0,0,0,0.5)',
maxWidth: '360px',
}"
/>
css
/* Или глобально в CSS */
:root {
--vtk-border-radius: 6px;
--vtk-font-family: 'Inter', sans-serif;
--vtk-max-width: 360px;
}
Все токены типизированы через ToastDesignTokens — автодополнение в IDE работает.
Headless mode
useToastState() возвращает сырые реактивные данные очереди. <ToastContainer> не нужен — рисуете UI полностью сами.
vue
<script setup lang="ts">
import { useToast, useToastState } from 'vue-toast-kit'
const toast = useToast()
const { active, count } = useToastState()
</script>
<template>
<div class="my-notifications">
<div
v-for="t in active"
:key="t.id"
:class="`notification notification--${t.options.type}`"
@mouseenter="t.pause()"
@mouseleave="t.resume()"
>
<span>{{ t.message }}</span>
<button @click="t.dismiss()">✕</button>
<div class="progress" :style="{ width: `${t.remaining.value * 100}%` }" />
</div>
</div>
</template>
Каждый ToastItem реактивен: remaining.value (0–1 для прогресс-бара), isPaused.value, groupCount.value. Методы: pause(), resume(), dismiss(), update(opts).
Multi-instance и изолированные контексты
createToastContext() создаёт независимую очередь. Передаётся в useToast(ctx) и <ToastContainer :context="ctx" />. Полная изоляция — разные очереди, разные настройки.
vue
<script setup lang="ts">
import { createToastContext, useToast } from 'vue-toast-kit'
// Очередь для критических алертов — отдельно от обычных тостов
const alertCtx = createToastContext({ maxVisible: 3 })
const alertToast = useToast(alertCtx)
function showCritical(msg: string) {
alertToast.error(msg, { priority: 'critical', duration: 0, closable: true })
}
</script>
<template>
<!-- Глобальная очередь -->
<ToastContainer position="bottom-right" />
<!-- Критические алерты — другой угол, другой z-index -->
<ToastContainer
:context="alertCtx"
position="top-center"
:z-index="10000"
/>
</template>
Event emitter
События очереди — для интеграции с аналитикой, Sentry, логгером. Все подписчики возвращают функцию отписки.
ts
import { getOrCreateGlobalContext } from 'vue-toast-kit'
const queue = getOrCreateGlobalContext().queue
queue.onAdd((item) => {
analytics.track('toast_shown', {
type: item.options.type,
message: typeof item.message === 'string' ? item.message : '[component]',
})
})
const off = queue.onDismiss((id) => {
analytics.track('toast_dismissed', { id })
})
// Отписаться при уничтожении компонента:
onUnmounted(off)
Rate limiting и localStorage persist
Защита от спама тостами — например при быстром клике или массовом приходе ошибок с сервера:
ts
import { createToastContext } from 'vue-toast-kit'
// Не более 3 тостов в секунду, лишние молча отбрасываются
const ctx = createToastContext({ rateLimit: 3, rateLimitWindowMs: 1000 })
Или через плагин глобально:
ts
app.use(VueToastPlugin, { rateLimit: 5 })
Тосты с persist: true переживают перезагрузку страницы — восстанавливаются из localStorage:
ts
app.use(VueToastPlugin, { persistStorage: true })
// Этот тост вернётся после перезагрузки
toast.warning('Обслуживание сервера в 03:00', { persist: true, duration: 0 })
Nuxt 3
ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['vue-toast-kit/nuxt'],
vueToastKit: {
position: 'top-right',
theme: 'system',
maxVisible: 5,
},
})
Модуль автоматически добавляет плагин на клиент, настраивает авто-импорты useToast, useToastState, createToastContext, синглтона toast и компонента <ToastContainer>. CSS подключается автоматически — никакого ручного импорта.
SSR-safe: тосты, отправленные до монтирования <ToastContainer>, буферируются и сбрасываются после монтирования.
Сценарии в реальных проектах
CRM: операции с записями
В CRM удаляют записи, переносят задачи, архивируют сделки. Пользователь должен видеть результат и иметь возможность отменить. Критические ошибки должны пробиваться сквозь очередь тостов.
ts
// composables/useDealActions.ts
import { toast } from 'vue-toast-kit'
export function useDealActions() {
async function archiveDeal(deal: Deal) {
// Помечаем как удалённую локально — UI реагирует мгновенно
store.markArchived(deal.id)
toast.undo(`Сделка "${deal.title}" архивирована`, {
undo: {
label: 'Отменить',
duration: 7000,
onUndo: async () => {
store.unmarkArchived(deal.id)
await api.deals.restore(deal.id)
toast.success('Сделка восстановлена')
},
},
onAutoClose: async () => {
// Таймер вышел — окончательно архивируем
await api.deals.archive(deal.id)
},
})
}
async function moveDeal(deal: Deal, stage: Stage) {
await toast.promise(
api.deals.move(deal.id, stage.id),
{
loading: `Перемещаем в "${stage.name}"…`,
success: `Сделка перемещена в "${stage.name}"`,
error: (e) => `Ошибка: ${(e as Error).message}`,
},
)
}
async function massDelete(ids: string[]) {
const result = await toast.promise(
api.deals.massDelete(ids),
{
loading: `Удаляем ${ids.length} записей…`,
success: (res) => `Удалено ${res.deleted} записей`,
error: 'Не удалось выполнить массовое удаление',
},
)
if (result.failed > 0) {
// Критическая ошибка пробивается сквозь очередь
toast.warning(`${result.failed} записей не удалось удалить`, {
priority: 'high',
duration: 8000,
})
}
}
return { archiveDeal, moveDeal, massDelete }
}
Undo-логика атомарна: либо пользователь успевает отменить — тогда onUndo, либо нет — тогда onAutoClose. Оба колбека не могут сработать одновременно.
Мониторинг: приоритетная очередь с группировкой
Дашборд мониторинга получает события с серверов в реальном времени. Критические инциденты должны выбиваться на первый план, массовые однотипные события — группироваться, чтобы не спамить экран.
ts
// composables/useMonitoring.ts
import { toast, createToastContext } from 'vue-toast-kit'
// Отдельный контекст для мониторинга — не смешивается с UI-нотификациями
const monitorCtx = createToastContext({
maxVisible: 5,
rateLimit: 10,
rateLimitWindowMs: 2000,
})
const alertToast = useToast(monitorCtx)
// Аналитика всех алертов
monitorCtx.queue.onAdd((item) => {
logger.info('alert_shown', {
type: item.options.type,
priority: item.options.priority,
message: item.message,
})
})
function handleServerEvent(event: ServerEvent) {
if (event.severity === 'critical') {
// Критическое — пробивается сквозь очередь, не закрывается само
alertToast.error(event.message, {
priority: 'critical',
duration: 0,
closable: true,
groupKey: `critical-${event.service}`,
})
return
}
if (event.severity === 'warning') {
// Предупреждения группируются по сервису
alertToast.warning(event.message, {
priority: 'high',
duration: 10_000,
groupKey: `warn-${event.service}`,
})
return
}
// Информационные — низкий приоритет, группируются вместе
alertToast.info(event.message, {
priority: 'low',
duration: 5000,
groupKey: 'info-events',
})
}
vue
<!-- В App.vue — два отдельных контейнера -->
<template>
<RouterView />
<!-- Обычные UI-нотификации -->
<ToastContainer position="bottom-right" />
<!-- Алерты мониторинга — отдельный угол, отдельная очередь -->
<ToastContainer
:context="monitorCtx"
position="top-right"
:max-visible="5"
:z-index="10000"
:stack-mode="true"
/>
</template>
Критические инциденты вытесняют информационные тосты из видимой зоны за счёт приоритетов. Десятки однотипных предупреждений складываются в один тост с +N. Stack mode сжимает несколько алертов в стопку — не перекрывают интерфейс.
Загрузчик файлов: параллельные промисы
Пользователь загружает несколько файлов одновременно. Для каждого — отдельный тост с прогрессом. Успех и ошибки обрабатываются независимо.
ts
// composables/useUploader.ts
import { toast } from 'vue-toast-kit'
export function useUploader() {
async function uploadFile(file: File): Promise<UploadResult> {
const id = toast.loading(`Загружаем ${file.name}…`)
try {
// Стримим прогресс через updateMessage
const result = await uploadWithProgress(file, (percent) => {
toast.updateMessage(id, `${file.name} — ${percent}%`)
})
toast.update(id, {
type: 'success',
message: `${file.name} загружен (${formatBytes(result.size)})`,
duration: 4000,
closable: true,
})
return result
} catch (err) {
toast.update(id, {
type: 'error',
message: `${file.name}: ${(err as Error).message}`,
duration: 8000,
closable: true,
action: {
label: 'Повторить',
onClick: () => uploadFile(file),
},
})
throw err
}
}
async function uploadBatch(files: File[]) {
// Все загрузки параллельно, каждая со своим тостом
const results = await Promise.allSettled(files.map(uploadFile))
const failed = results.filter(r => r.status === 'rejected').length
const success = results.filter(r => r.status === 'fulfilled').length
if (failed > 0) {
toast.warning(`${success} из ${files.length} файлов загружено. ${failed} с ошибкой.`, {
priority: 'high',
duration: 0,
closable: true,
})
}
}
return { uploadFile, uploadBatch }
}
toast.updateMessage меняет текст тоста без пересоздания — прогресс обновляется на месте. Кнопка «Повторить» в тосте с ошибкой вызывает uploadFile заново. Promise.allSettled гарантирует что итоговый тост появится только после завершения всех загрузок.
Тестирование
Утилиты для unit-тестов без монтирования компонентов:
ts
import { createMockToast, mockUseToast } from 'vue-toast-kit/testing'
// Мок всего API useToast — все методы как vi.fn()
describe('MyComponent', () => {
it('вызывает toast.success после сохранения', async () => {
const mockToast = mockUseToast()
vi.mock('vue-toast-kit', () => ({ useToast: () => mockToast }))
const wrapper = mount(MyComponent)
await wrapper.find('[data-test="save"]').trigger('click')
expect(mockToast.success).toHaveBeenCalledWith('Сохранено!')
})
})
// Минимальный ToastItem для тестов компонентов
const item = createMockToast({
message: 'Тестовое уведомление',
options: { type: 'success', closable: true },
})
NPM: https://www.npmjs.com/package/vue-toast-kit
GitHub: https://github.com/macrulezru/vue-toast-kit