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

