В 2017 году эта статья была про Noty и мой плагин к ней для Vue. Noty с тех пор помечена как deprecated, плагин под Vue 2 мертв, статья лежала в черновиках. Вместо правки старого текста написал библиотеку и переписал статью целиком под нее. @mrcs/toastkit это API в стиле sonner без React, ноль зависимостей, 2.7 КБ JS и 2.0 КБ CSS в gzip, и ни одной строки стилей из JavaScript. Попробовать можно на демо.



Привет, Хабр!


Это та же статья, что вышла 13 июля 2017 года под заголовком «Notyjs — шикарная javascript библиотека для создания уведомлений. А также готовый плагин для Vuejs», переписанная целиком, вместе с заголовком. Тогда она собрала 17K охвата и 131 закладку, и почти все, что в ней было, устарело. Последняя версия Noty вышла в 2021 году и называется 3.2.0-beta-deprecated, плагин был написан под Vue 2, а ссылки вели на репозитории, которых больше нет.


Чинить старый текст смысла не было, Noty больше никто не развивает. Я посмотрел, какие библиотеки тостов живы сейчас, подходящей не нашел и написал свою.


Что случилось с тостами за девять лет


Снял срез по 16 пакетам через registry npm, bundlephobia и GitHub API. Последняя строка это мой пакет, на момент публикации ему один день, поэтому загрузок нет. Вес у всех gzip: у sonner стили лежат внутри JS, у toastify-js и у меня CSS отдельным файлом, для честного сравнения у toastkit указаны оба.


Пакет Последний релиз Фреймворк Вес, gzip Загрузок в неделю
toastr 2017-12 jQuery 29.3 КБ 169 839
toastify-js 2022-07 нет 2.1 КБ 89 507
notyf 2021-06 нет 27.5 КБ 30 495
noty 2021-02, deprecated нет 8.7 КБ 23 798
izitoast 2018-08 нет 620 КБ, весь пакет 15 431
notiflix 2025-01 нет 16.5 КБ 11 558
sonner 2026-08 React 9.4 КБ 37 931 079
react-hot-toast 2026-09 React 4.8 КБ 2 749 393
vue-sonner 2025-10 Vue 1.0 КБ 1 172 472
mrcs/toastkit 2026-09 нет 2.7 КБ JS + 2.0 КБ CSS, gzip —

Ванильное поле остановилось в 2021–2022 годах. Все, что развивается после, это sonner под React и его порты под Vue и Svelte. API sonner стал стандартом жанра — toast(), toast.promise(), кнопка действия, стопка, свайп. У порта под Vue миллион загрузок в неделю, спрос на «sonner в другом окружении» есть. А для ванильного JS, Web Components, Astro-островов и HTMX выбор такой — toastify-js 2022 года без promise-тостов и без типов, либо notiflix на 16 КБ.


Так что «легкая и без зависимостей» это не отличие, у всех ванильных пакетов из таблицы ноль зависимостей, у четырех gzip меньше 3 КБ. Отличие в другом. Если коротко, toastkit это toastify-js по весу, sonner по API, и ни один из них по стилям и доступности.


API


import { mount, toast } from '@mrcs/toastkit'
import '@mrcs/toastkit/toastkit.css'

mount({ position: 'bottom-right', theme: 'auto', duration: 4000, max: 5, stack: 'collapse' })

toast('Changes saved')
toast.success('Payment confirmed')
toast.error('Could not connect', { description: 'Check your network and try again.' })

await toast.promise(upload(file), {
  loading: 'Uploading…',
  success: (name) => `Uploaded ${name}`,
  error: (e) => `Upload failed: ${e.message}`,
})

toast('Message deleted', { action: { label: 'Undo', onClick: restore } })
toast('', { content: buildElement() })

Ядро императивное. mount() создает регион один раз, первый toast() сделает это сам с настройками по умолчанию. Из этого следует, что адаптеры под фреймворки не нужны — в React это useEffect(() => mount(), []) в корне и вызовы toast() откуда угодно, в Vue то же в onMounted. Отдельный пакет под React я делать не стал, там уже стоит sonner, конкурировать с ним на его поле бессмысленно.


Строки рендерятся через textContent, а не innerHTML. Нужна разметка внутри тоста — передаете элемент или функцию, которая его вернет.


CSS как API


Библиотека не трогает стили вообще. Один файл toastkit.css, и каждое визуальное решение в нем это кастомное свойство на .tk.


.tk {
  --tk-bg: #fff;
  --tk-fg: #111;
  --tk-border: #e4e4e4;
  --tk-radius: 10px;
  --tk-width: 356px;
  --tk-success: #16a34a;
  --tk-error: #dc2626;
  --tk-duration: 0.25s;
  --tk-exit: 0.15s;
  --tk-stack-offset: 10px;
  /* всего 22 переменные */
}

Своя тема это несколько переменных под своим именем. Библиотека только записывает имя в data-theme и ничего про тему не знает.


.tk[data-theme="brand"] { --tk-bg: #1b1f3a; --tk-fg: #fff; --tk-radius: 4px; }

mount({ theme: 'brand' })

Из этого же следует живой редактор CSS на демо. Там лежит блок переменных из поставляемого файла, правишь его, тосты на экране меняются. Это не трюк страницы, а свойство архитектуры — тот же файл будет темой в продакшене.


Стопка «за новым тостом» тоже сделана только на CSS. С stack: 'collapse' регион становится гридом, два тоста за новым уменьшены и сдвинуты через :nth-last-child(), старше третьего скрыты, наведение или фокус раскрывают список. В JS для этого нет ни строки.


Доступность, свайп, вес


Регион это section с role="region" и aria-label="Notifications". Тост — role="status" с aria-live="polite", ошибка — role="alert" с assertive. Кнопка действия и крестик это настоящие button. Тосты фокусируемы, Escape закрывает тот, что в фокусе, таймеры стоят, пока фокус или курсор внутри стопки. prefers-reduced-motion убирает анимации.


Свайп сделан на Pointer Events, одна реализация для мыши и пальца. Тянешь в сторону, тост следует за курсором и гаснет, отпустил дальше 40% ширины, улетает в ту же сторону. Драг не начинается с кнопки или ссылки, touch-action: pan-y оставляет вертикальный скролл телефону. Сорок строк и 425 байт gzip.


Вес это контракт, а не заявление. Скрипт npm run size считает gzip собранных файлов и падает при превышении 3 000 байт для JS и 2 000 для CSS, он стоит в CI и в prepublishOnly. Сейчас 2 717 и 1 986. Сборка одним tsc в ESM плюс .d.ts, без бандлера, devDependencies три штуки — typescript, vitest, jsdom.


Грабли


Скрытая иконка ломала грид. Первая верстка тоста была гридом на четыре колонки — иконка, текст, кнопка, крестик. У обычного тоста иконки нет, display: none, и грид молча сдвигал крестик во вторую колонку. Переписал на flex с margin-left: auto у крестика.


Трансформированные соседи рисуются сверху. В свернутой стопке тосты за новым получают transform, и браузер рисует их поверх нетрансформированного новейшего, хотя тот последний в DOM. Трансформация создает контекст наложения, а элементы с контекстом рисуются после обычного потока. Лечится явным z-index на трех верхних.


Живой CSS и темы. Блок из редактора на демо это .tk { … }, а темная тема это .tk[data-theme="dark"], она специфичнее и маскирует правки. В светлой теме правки видны, в темной нет, выглядит как баг светлой темы. На демо блок теперь вставляется как .tk, .tk[data-theme], чтобы вести себя одинаково.


Pointer Events в jsdom. В тестах на свайп setPointerCapture в jsdom нет, ширина элемента ноль, и порог 40% от нуля срабатывает на любом сдвиге. В коде el.setPointerCapture?.() и el.offsetWidth || 320, второе не только для тестов, это защита от вызова до раскладки.


Чего нет


Кастомное содержимое только элементом или функцией, JSX туда не передать. Если такая задача будет востребована, появится сабпуть @mrcs/toastkit/react с createPortal, репо все равно одно.


Итог


Репо — github.com/nmrcs/toastkit, пакет — @mrcs/toastkit, демо со страницей настроек и редактором CSS — nmrcs.github.io/toastkit.