kanpai-toast
v0.1.0
Published
Dependency-free, accessible toast notifications. No framework required.
Maintainers
Readme
kanpai-toast
Toast notifications that get announced to screen readers, work from the keyboard, and don't leak timers. No framework, no dependencies.
I built this after watching yet another "accessible" toast library fall apart the moment you touch it with a keyboard. Most toast components on npm are a div with an opacity transition and a prayer. This one isn't. It came out of real GDPR/accessibility work at a company where that wasn't optional.
Install
npm install kanpai-toastimport toast from 'kanpai-toast';
import 'kanpai-toast/kanpai.css';
toast.success('Changes saved.');
toast.error('Could not save changes. Try again.');
toast('Custom message', { variant: 'info', duration: 8000 });Written in plain JavaScript with JSDoc annotations. The build spits out TypeScript definitions (.d.ts) for autocomplete, but you don't need a TypeScript toolchain to use it.
Install (CSS)
The stylesheet drives everything visual off CSS custom properties, override them in :root to reskin it:
:root {
--kanpai-bg: #1f1f1f;
--kanpai-fg: #ffffff;
--kanpai-radius: 6px;
--kanpai-success-bg: #1a7f37;
--kanpai-error-bg: #c1121f;
--kanpai-warning-bg: #ffcc4d;
--kanpai-warning-fg: #1f1f1f;
--kanpai-gap: 0.5rem;
--kanpai-z: 1000;
--kanpai-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}API
toast(message: string, options?: ToastOptions): string // returns toast id
toast.success(message, options?)
toast.error(message, options?)
toast.warning(message, options?)
toast.info(message, options?)
toast.dismiss(id: string): void
toast.dismissAll(): void
toast.configure({ position }): voidToastOptions:
| Option | Default | Notes |
|---|---|---|
| variant | 'info' | info | success | warning | error |
| duration | 5000 | ms. 0 = stays until dismissed |
| id | auto-generated | supply your own if you need to reference it later |
configure({ position }) accepts top-right | top-left | top-center | bottom-right | bottom-left | bottom-center.
Why this isn't just a div with opacity: 0
Two live regions, not one. info/success go into a polite region (role="status"); warning/error go into a separate assertive region (role="alert"). One region for everything means you're either interrupting people constantly or getting ignored. Screen readers announce alert immediately and status whenever the user's idle, and that split isn't cosmetic, it changes what actually gets heard.
aria-atomic="false". Set it to true and every new toast makes the screen reader re-read the entire region, old toasts included. false means each one announces once, on arrival, and shuts up after that.
Auto-dismiss pauses on hover and focus. Most implementations only listen for mouseenter. A keyboard or screen-reader user tabbing onto the close button never fires that event, so the toast can vanish mid-navigation. This version pauses on focusin/focusout too, and actually tracks remaining time instead of resetting the full duration every time you hover.
Escape dismisses the focused toast. This is table stakes for anything that acts like a notification. No excuse to skip it.
prefers-reduced-motion is respected. Instant removal for people who asked for it, not a fade they didn't.
Capped at 5 visible toasts, and eviction is instant. Fire 20 in a loop, a bad retry handler, a careless bulk action, and most libraries just stack them forever off-screen, quietly racking up live, orphaned timers. This one evicts the oldest immediately, no exit animation, so the cap is an actual guarantee and not "5, plus whatever's still fading out."
Timers actually get cleared. dismiss() clears the pending setTimeout before the element goes away, and the transitionend cleanup has a fallback timer for when transitionend never fires. An ancestor going display: none mid-transition will silently kill toasts forever otherwise.
No zombie timers from a dismiss-during-hover race. Dismiss a toast via the close button while the pointer's still over it, and the eventual mouseleave used to call resume() and schedule a dismiss timer for a toast that no longer exists. resume() now checks the toast's still active before it reschedules anything.
Global keyboard shortcut. Alt+T jumps focus straight to the most recent toast, so keyboard users don't have to hunt for it before it expires.
Focus restoration that's actually robust. Dismiss a focused toast and focus falls to the next open toast, then to a stack of prior focus targets, then to wherever you were before you ever touched a toast. Stale entries (an element that's since been removed from the DOM) get skipped rather than eaten silently. And when there's truly nowhere left to send focus, it releases it properly instead of calling .focus() on <body>, which is a no-op in every browser and would've quietly left focus stranded on an element mid-fade.
Hot module replacement (HMR) safe, all the way through. During development, both the live regions and the global Alt+T/focus-tracking listeners survive a module reload without duplicating. Reload the module 10 times over a dev session and you still get exactly one set of listeners and one set of regions, not 10.
High contrast mode works. Transparent borders keep toasts visible under Windows High Contrast / forced colors.
What's intentionally out of scope
- No rich HTML in the message.
textContentonly. Toasts aren't the place for interactive content beyond a dismiss button; if you need actions, build a different component. - No stacking animation choreography. You get
.kanpai--hideand transitions, bring your own motion library if you want more. - No framework wrapper. It's plain DOM. Drops into React/Vue/Svelte behind a thin wrapper around
toast(). Wasn't going to ship React as a peer dependency for something this small. Open to adding wrappers if people actually want them.
Development
npm install
npm run test # vitest + jsdom, 25 cases
npm run build # bundle to dist/ (esm + cjs + .d.ts)
npm run serve # local server at http://localhost:8080, opens demo/index.htmlTests cover: live-region routing by variant, auto-dismiss timing and custom durations, persistent (duration: 0) toasts, pause/resume on hover and keyboard focus, the dismiss-during-hover race above, Escape-to-dismiss, close-button behavior, the 5-toast eviction cap and its immediate (non-animated) removal, dismissAll, reduced-motion's instant removal, configure(), focus restoration (including a stale/disconnected focus-stack entry, and falling through to another open toast), and HMR safety for both live regions and global listeners.
License
MIT
