sonner-a11y

An accessible toast component for pure JavaScript: screen-reader announcements, full keyboard operation, WCAG 2.2 AA. Fork of sonner-js.


Keywords
a11y, accessibility, aria, keyboard, notification, screen-reader, toast, wcag, javascript, keyboard-navigation, typescript, zero-dependency
License
MIT
Install
npm install sonner-a11y@1.1.0

Documentation

๐Ÿž Sonner-a11y

An accessible toast component designed for Pure JavaScript

CI npm version npm downloads License: MIT

An accessibility-focused fork of sonner-js by huanfei, itself built on Sonner


โœจ 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-a11y

Basic 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. Use toast.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. Without toastOptions: { 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" and theme: "dark" ship different defaults for the same role, but a token you declare wins in both โ€” and on inverted 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-width stops applying below 600px, where the toast is laid out full-width minus mobileOffset instead.

โ™ฟ 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 }); // interrupt

The 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-live or role="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.error interrupts by default; Sonner only looks at important.
  • 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: true to 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 offset or use a top-* 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 pending

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