sonner
Sonner is a toast component for React. You render a single <Toaster /> somewhere near the root of your app, then call toast('Saved') from anywhere, including outside React components, and a notification stacks into the corner. It ships the parts people usually rebuild by hand: success, error, warning, info and loading variants, a promise helper that swaps the message when your async call settles, action and cancel buttons, swipe to dismiss, keyboard focus handling, pause on hover, a light and dark theme, and stacking with expand on hover. Styling comes from CSS variables and data attributes, and every element can be given your own class names.
The best default for React toasts right now: the defaults are the ones you would have picked, and the promise helper alone saves real code. The honest caveat is release cadence, so pin the version and read the repo before you assume a bug is fixed.
Use it if
- You want good-looking toasts in a React app in two lines of code, with sensible animation, stacking, and hover-to-expand behavior already decided for you
- You show a lot of async feedback and want toast.promise to handle the loading, resolved, and rejected states of a single fetch without three separate calls
- You care about the details most toast libraries get wrong: swipe dismissal, focus not being stolen, timers pausing when the window is hidden, and an accessible live region for screen readers
- You are on Tailwind or a design system and want to restyle every part through classNames or drop the built-in CSS entirely with unstyled
- You are not on React. The peer range is react 18 or 19 plus react-dom, and there is no framework-agnostic core; Vue and Svelte users need separate community ports
- Releases have gone quiet on npm even though the repo has not: 2.0.7 landed in August 2025 and nothing has been published since, so fixes merged on main can sit unreleased for a long time
- It injects its own stylesheet into document.head at runtime. A strict style-src Content Security Policy without unsafe-inline will block it, and there is no nonce option; you would have to import dist/styles.css yourself and accept whatever the runtime injection still tries to do
- You need a notification center rather than transient toasts. There is no persistence, no read state, and getHistory only holds what happened in this page session
- The design is deliberately opinionated. If your spec calls for a different stacking model, entrance animation, or position behavior, you end up overriding CSS variables and data attributes rather than configuring them, and at some point toast.custom means you are drawing the whole thing yourself
- You need it in a React Server Component: toast and <Toaster /> are client-side, so a file calling them needs 'use client' and a server action cannot fire one directly
Setup reality
npm install sonner, drop <Toaster /> in your root layout, call toast(). There is no CSS import step because the styles are injected at runtime, which is convenient until a CSP or a style-order requirement gets in the way. In Next.js App Router the layout is a server component, so <Toaster /> must live in a file marked 'use client', and any component calling toast has to be a client component too; server actions have to return a value the client turns into a toast. Two Toaster instances mounted at once means every toast renders twice, which is easy to do when a shared layout and a page both add one; if you genuinely need two, give each an id and pass toasterId on the toast. Dark mode is not automatic, theme defaults to light, so with next-themes you pass theme={resolvedTheme} yourself.
Patterns
Mount the Toaster and show a toastmount-and-fire
'use client';
import { Toaster, toast } from 'sonner';
export default function App() {
return (
<>
<Toaster position="bottom-right" richColors closeButton />
<button onClick={() => toast('Settings saved')}>Save</button>
</>
);
}Exactly one <Toaster /> per app. Mounting a second one, for example in both a layout and a page, renders every toast twice with no error to tell you why.
Use the built-in status variantstoast-variants
import { toast } from 'sonner';
toast.success('Profile updated');
toast.error('Could not reach the server');
toast.warning('Your trial ends tomorrow');
toast.info('A new version is available');
toast.message('Plain toast with a description', {
description: 'Monday, January 3rd at 6:00pm',
});Variants only get colored backgrounds when richColors is set on the Toaster; without it they are the neutral style with a colored icon.
Track an async call with one toasttoast-promise
import { toast } from 'sonner';
toast.promise(saveUser(form), {
loading: 'Saving...',
success: (user) => `${user.name} saved`,
error: (err) => `Save failed: ${err.message}`,
finally: () => setSubmitting(false),
});toast.promise does not rethrow, so the rejection is considered handled. If you need the value or the error in your own code, call .unwrap() on the return value and await it inside a try/catch.
Add an undo actionaction-and-cancel-buttons
import { toast } from 'sonner';
toast('Message archived', {
action: {
label: 'Undo',
onClick: () => restoreMessage(id),
},
cancel: {
label: 'Dismiss',
onClick: () => {},
},
duration: 10000,
});Clicking the action dismisses the toast as well as running onClick. Give undo toasts a longer duration than the default 4 seconds, otherwise the button is gone before anyone reads it.
Update a toast in placeupdate-existing-toast
import { toast } from 'sonner';
const id = toast.loading('Uploading...');
await upload(file);
toast.success('Upload complete', { id });Passing the same id replaces the content and type without a new entrance animation. This is the manual version of toast.promise, and the only way to drive a toast from progress events.
Dismiss one toast or all of themdismiss-toasts
import { toast } from 'sonner';
const id = toast('Connecting...', { duration: Infinity });
toast.dismiss(id); // just this one
toast.dismiss(); // every visible toast
toast('Never auto-closes', {
duration: Infinity,
onDismiss: (t) => console.log('closed', t.id),
onAutoClose: (t) => console.log('timed out', t.id),
});duration: Infinity means the user must close it, so pair it with closeButton or an action. onDismiss fires for manual closes, onAutoClose for the timer; they are not interchangeable.
Render your own component as a toastcustom-toast-jsx
import { toast } from 'sonner';
toast.custom((id) => (
<div className="rounded-lg border bg-white p-4 shadow">
<p className="font-medium">Deploy finished</p>
<button onClick={() => toast.dismiss(id)}>Close</button>
</div>
), { duration: 8000 });toast.custom hands you the id and expects you to build everything, including the close affordance. You keep positioning, stacking, and swipe behavior; you lose the default styling, icons, and buttons.
Restyle toasts with your own classesstyle-with-classnames
import { Toaster } from 'sonner';
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast: 'flex w-full gap-2 rounded-lg border bg-white p-4 shadow-lg',
title: 'text-sm font-medium',
description: 'text-sm text-gray-500',
actionButton: 'rounded bg-black px-2 py-1 text-xs text-white',
error: 'border-red-300 bg-red-50',
},
}}
/>unstyled removes the built-in look but keeps layout and animation. Without it your classes fight the injected CSS and you end up writing !important; with it you must style every state yourself, including success and error.
Follow the app's dark modetheme-with-next-themes
'use client';
import { useTheme } from 'next-themes';
import { Toaster } from 'sonner';
export function Toasts() {
const { resolvedTheme } = useTheme();
return <Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />;
}theme defaults to 'light', not 'system', so a dark app shows white toasts until you wire this up. Use resolvedTheme rather than theme so 'system' resolves to an actual value.
Control how many toasts show and how they stacktune-stacking-behavior
import { Toaster } from 'sonner';
<Toaster
expand
visibleToasts={5}
gap={12}
duration={6000}
offset={{ top: 24, right: 24 }}
mobileOffset={16}
swipeDirections={['right']}
/>Without expand, toasts collapse into a stack and only fan out on hover. visibleToasts caps what is rendered; extra toasts queue rather than being dropped, so a burst of errors takes a while to drain.
Read the current toasts from Reactread-active-toasts
import { useSonner } from 'sonner';
function UnreadBadge() {
const { toasts } = useSonner();
return toasts.length ? <span>{toasts.length}</span> : null;
}useSonner reflects what is on screen right now. For everything that has been shown this session there is toast.getHistory(), which is memory only and resets on reload.
Fire a toast from an API client or storetoast-from-non-react-code
// api-client.ts, no React involved
import { toast } from 'sonner';
export async function apiFetch(url: string, init?: RequestInit) {
const res = await fetch(url, init);
if (!res.ok) {
toast.error(`${res.status} ${res.statusText}`);
throw new Error(res.statusText);
}
return res.json();
}toast() writes to a module-level store, so it works outside components and hooks. It is still a no-op on the server, so guard calls in code that also runs during SSR or in a route handler.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-hot-toast | npm | You want a similar hooks-friendly API with a smaller feature surface and a longer release history. |
| react-toastify | npm | You need heavy configurability, containers per region, and progress bars, and do not mind a larger and more verbose API. |
| @radix-ui/react-toast | npm | You want an unstyled accessible primitive and intend to build the visual layer and queueing yourself. |