sonner-a11y
v1.1.0
Published
An accessible toast component for pure JavaScript: screen-reader announcements, full keyboard operation, WCAG 2.2 AA. Fork of sonner-js.
Downloads
298
Maintainers
Readme
✨ Features
- 🚀 Zero Dependencies - Pure JavaScript implementation, no frameworks required
- 📱 Responsive Design - Perfect adaptation for mobile and desktop
- 🎨 Multiple Styles - Support for success, error, warning, info and more types
- ⚡ Lightweight - Small bundle size with excellent performance
- 🔧 Highly Customizable - Rich configuration options
- 🌙 Theme Support - Built-in light and dark themes
- 📦 ESM - tree-shakeable (
sideEffects: false), consumed by every modern bundler - ♿ Accessible - Screen reader announcements, full keyboard operation, WCAG 2.2 AA
🚀 Quick Start
Installation
npm install sonner-a11yBasic Usage
import toast from "sonner-a11y";
// Simple toast
toast("Hello World!");
// Toast with description
toast("Operation successful", {
description: "Your data has been saved",
});📖 Usage Guide
Different Toast Types
// Success toast
toast.success("Operation successful");
// Error toast
toast.error("Operation failed");
// Info toast
toast.info("This is an information");
// Warning toast
toast.warning("Please note");Toast with Action Buttons
toast("Confirm action", {
action: {
label: "Confirm",
onClick: () => console.log("User clicked confirm"),
},
});
// With cancel button
toast("Confirm deletion", {
action: {
label: "Cancel",
onClick: () => console.log("User cancelled operation"),
cancel: true,
},
});Promise Handling
const fetchData = () => fetch("/api/data");
toast.promise(fetchData, {
loading: "Loading...",
success: "Data loaded successfully",
error: "Failed to load data",
});Update and Dismiss Toasts
// Create toast and get ID
const toastId = toast("Processing...");
// Update toast
toast.success("Processing complete", { id: toastId });
// Dismiss specific toast
toast.dismiss(toastId);
// Dismiss all toasts
toast.dismiss();Permanent Toasts
toast("Important notice", {
duration: 0, // Set to 0 for permanent display
});🌐 CDN Usage
<script type="module">
import toast from "https://cdn.jsdelivr.net/npm/sonner-a11y/+esm";
toast("Hello from ESM!");
</script>⚙️ Configuration Options
import toast from "sonner-a11y";
// Global configuration
toast.config({
theme: "dark", // 'light' | 'dark'
expand: true, // Expand animation
visibleToasts: 3, // Number of visible toasts
gap: 8, // Toast spacing
offset: 16, // Margin
mobileOffset: 16, // Mobile margin
dir: "ltr", // Text direction
toastOptions: {
position: "top-right", // Position
duration: 4000, // Duration in milliseconds
closeButton: true, // Show close button
richColors: true, // Rich colors
invert: false, // Invert the colours of the toast
important: "auto", // Screen-reader politeness, see Accessibility
titleAsHtml: false, // Interpret `title` as HTML
},
});
toast.config()merges into the current configuration, so you can call it more than once and only pass what changes. Usetoast.resetConfig()to go back to the shipped defaults.
🎨 Theming
Toasts render inside a shadow root, so your stylesheets cannot reach them: a class name you passed in
would be styled by rules that live in the document, and document rules never cross the shadow
boundary. Inherited custom properties do. Theming is therefore a set of tokens you declare on the
host element — or on any ancestor of it, :root included — which the library reads from inside.
/* Your own stylesheet. `[data-sonner-toasters]` is the host the library appends to <body>. */
[data-sonner-toasters] {
--sonner-success-bg: #0f2e1d;
--sonner-success-text: #7ee2a8;
--sonner-border-radius: 4px;
}Every token falls back to the shipped default, so declare only the ones you want to change.
| Token | Default (light / dark) | Applies to |
| ----------------------------------------- | -------------------------------------------------- | --------------------------------------- |
| --sonner-normal-bg | #fff / #000 | Toast background, and the close button |
| --sonner-normal-bg-hover | — / hsl(0, 0%, 12%) | Hovered action button |
| --sonner-normal-border | --sonner-gray4 / hsl(0, 0%, 20%) | Toast border |
| --sonner-normal-border-hover | — / hsl(0, 0%, 25%) | Hovered action button border |
| --sonner-normal-text | --sonner-gray12 / --sonner-gray1 | Title, close button, focus ring |
| --sonner-normal-text2 | #3f3f3f / #e8e8e8 | Description |
| --sonner-normal-cancel-bg | rgba(0, 0, 0, 0.08) / rgba(255, 255, 255, 0.3) | Cancel button background |
| --sonner-success-bg, -border, -text | greens | success toasts, with richColors |
| --sonner-info-bg, -border, -text | blues | info toasts, with richColors |
| --sonner-warning-bg, -border, -text | ambers | warning toasts, with richColors |
| --sonner-error-bg, -border, -text | reds | error toasts, with richColors |
| --sonner-width | 356px | Toast width, above a 600px viewport |
| --sonner-border-radius | 8px | Toast and button corners |
| --sonner-gray1 … --sonner-gray12 | neutral ramp | Loader, buttons, and the defaults above |
Three things worth knowing before reaching for them:
- The severity colours need
richColors. WithouttoastOptions: { richColors: true }every toast uses the--sonner-normal-*set whatever its type, and the four severity groups are inert. - One token covers both themes.
theme: "light"andtheme: "dark"ship different defaults for the same role, but a token you declare wins in both — and oninverted toasts too. So give it a value that is already theme-aware on your side, such as a design-system variable that changes under your own dark-mode selector. --sonner-widthstops applying below 600px, where the toast is laid out full-width minusmobileOffsetinstead.
♿ Accessibility
Toasts are announced to screen readers, fully operable from the keyboard, and honour the user's motion and contrast preferences.
Keyboard
| Key | Effect | | ----------------------------------------------------- | -------------------------------------------------------------- | | Alt+T | Move focus to the most recent toast and expand the stack | | Tab / Shift+Tab | Walk through the toasts and their buttons | | ↓ → / ↑ ← | Next / previous toast | | Home / End | First / last toast | | Delete / Backspace | Dismiss the focused toast (the keyboard equivalent of swiping) | | Esc | Collapse the stack and return focus to where it was |
Auto-dismiss timers pause while the pointer is over the toasts, while focus is inside them, and
while the tab is hidden. Use duration: 0 for a toast that never closes on its own.
Screen reader announcements
Announcements go through a dedicated live region kept in the light DOM, outside the shadow root.
Errors interrupt (aria-live="assertive"), everything else is announced politely. Override it per
toast with important:
toast.error("Payment failed", { important: false }); // announce politely
toast("Build finished", { important: true }); // interruptThe severity is also carried as text for screen readers, since an icon and a colour alone are not perceivable (“Error. Payment failed. Card declined.”).
Translating the labels
Defaults are in English. Everything a screen reader reads can be replaced:
toast.config({
a11y: {
hotkey: ["altKey", "KeyN"], // modifier properties and/or KeyboardEvent.code values
labels: {
region: "Notifications", // `{hotkey}` is substituted, otherwise appended in parentheses
close: "Fermer la notification",
action: "Action",
types: {
success: "Succès",
error: "Erreur",
info: "Information",
warning: "Avertissement",
loading: "Chargement",
},
},
},
});Per toast, typeLabel overrides the severity label:
toast.error("HTTP 502", { typeLabel: "Erreur serveur" });Other a11y options
| Option | Default | Effect |
| ----------------------- | ------------------------- | ---------------------------------------------------------------- |
| announce | true | Announce toasts through the live region |
| announceClearDelay | 1000 | How long the announced text stays in the region, in ms |
| hotkey | ['altKey', 'KeyT'] | Key combination that focuses the most recent toast |
| pauseOnHover | true | Pause the timers while the pointer is over the toasts |
| pauseOnFocus | true | Pause the timers while focus is inside a toast |
| pauseOnDocumentHidden | true | Pause the timers while the tab is hidden |
| dismissOnEscape | false | Make Esc dismiss the focused toast instead of leaving |
| dismissKeys | ['Delete', 'Backspace'] | Keys that dismiss the focused toast ([] disables it) |
Differences from Sonner (React)
- Toasts carry no
aria-liveorrole="status": since this port renders into a shadow root whose container is created and destroyed on demand, a live region there is not reliably announced. A single dedicated region in the light DOM is used instead, which also rules out double announcements. toast.errorinterrupts by default; Sonner only looks atimportant.- The hotkey focuses the most recent toast rather than the list, so the message is read straight away.
- Esc also restores focus to the element that had it before.
Notes
- The toast title is inserted as plain text. Pass
titleAsHtml: trueto opt back into HTML — the caller is then responsible for sanitising it. - A fixed-position toast can cover the element that currently has focus (WCAG 2.4.11). If that
matters for your layout, raise
offsetor use atop-*position.
🤝 Contributing
Bug reports, accessibility reports and pull requests are welcome. Start with
CONTRIBUTING.md: it covers the test bench served by pnpm dev, how to verify a
change with a screen reader and a keyboard, and the invariants in the code that are load-bearing.
By participating you agree to the Code of Conduct. To report a vulnerability, see SECURITY.md.
🚀 Releasing
Version numbers are managed by Changesets — never edit the
version field by hand.
Every pull request that changes the published package must ship a changeset describing its intent:
pnpm changeset # pick patch / minor / major, write the summary
pnpm changeset:status # what is pendingThe summary lands verbatim in CHANGELOG.md, so write it for the consumer: what they can now do, in
the present tense. For a docs- or CI-only pull request, add #skip-changeset to the title instead.
Once merged into main, a chore: version packages pull request is opened (or updated) with the
version bump and the changelog entry. Merging it releases nothing on its own.
CI never publishes. Publishing runs from a real machine, so the tarball that reaches npmjs is the one that was verified locally, and no long-lived npm token has to live in CI:
pnpm release:dry # run every check, publish nothing
pnpm release # publish to npmjs, then tag <name>@<version>The script refuses to publish a dirty tree, a branch other than main, a main out of sync with the
remote, or a version whose sources changed after Changesets set it — otherwise npm, the changelog and
git would describe different trees. A version already on npm is a no-op, not an error, and the tag is
only pushed once the publish succeeded.
📄 License
MIT License - see LICENSE file for details.
