Drop-in replacement for Sonner. Same API, fewer bugs, no !important.
| Popser | Sonner | |
|---|---|---|
| Foundation | Base UI Toast primitives | Custom implementation (singleton Observer) |
| Version | 1.2.0 | 2.x |
| React | 18 + 19 | 18+ |
| TypeScript | Strict, verbatimModuleSyntax |
TypeScript (loose) |
| Bundle (ESM) | 13.4 KB raw / 4.8 KB gzip | 65.9 KB raw / ~13.5 KB gzip |
| CSS | Opt-in file, OKLCH tokens, styles/min flat bundle |
Auto-imported with JS, HSL gray scale |
| Dependencies | @base-ui/react (peer) |
Zero runtime deps |
| shadcn/ui | Registry (npx shadcn add @vcode-sh/popser) |
Official integration |
| Anchored toasts | Full Floating UI positioning with arrow | Not available |
| License | MIT | MIT |
| Method | Popser | Sonner |
|---|---|---|
toast("msg") |
toast("msg") |
toast("msg") |
toast.success() |
toast.success() |
toast.success() |
toast.error() |
toast.error() |
toast.error() |
toast.info() |
toast.info() |
toast.info() |
toast.warning() |
toast.warning() |
toast.warning() |
toast.loading() |
toast.loading() |
toast.loading() |
toast.promise() |
toast.promise() |
toast.promise() |
toast.close(id) |
toast.close(id) |
toast.dismiss(id) |
toast.close() (all) |
toast.close() |
toast.dismiss() |
toast.update(id, opts) |
toast.update(id, opts) |
Re-call toast() with same ID (full replace) |
toast.dismiss(id) |
toast.dismiss(id) (alias for close) |
toast.dismiss(id) |
toast.getToasts() |
toast.getToasts() → string[] |
Not available |
toast.getHistory() |
toast.getHistory() → ToastHistoryEntry[] |
Not available |
toast.clearHistory() |
toast.clearHistory() |
Not available |
toast.custom(jsx) |
toast.custom((id) => jsx) |
toast.custom(jsx) |
toast.message() |
toast.message("msg") |
toast.message("msg") |
| Returns | string (toast ID) |
string | number |
- import { toast } from "sonner";
+ import { toast } from "@vcui/popser";
- toast.dismiss(id);
+ toast.close(id);
- toast("msg", { duration: 3000 });
+ toast("msg", { timeout: 3000 });
- toast("msg", { duration: Infinity });
+ toast("msg", { timeout: 0 });95% of callsites need zero changes. The remaining 5% is mechanical rename.
| Option | Popser | Sonner | Notes |
|---|---|---|---|
description |
ReactNode |
ReactNode |
Same |
timeout / duration |
timeout: number (duration alias) |
duration: number |
Both accepted; timeout takes precedence |
id |
string |
string | number |
Popser is string-only (Base UI) |
icon |
ReactNode | false |
ReactNode |
false explicitly hides icon |
action |
{ label, onClick } or ReactNode |
{ label, onClick } or ReactNode |
Both formats supported |
cancel |
{ label, onClick } or ReactNode |
{ label, onClick } or ReactNode |
Both formats supported |
type |
PopserType | string |
Internal only | Popser exposes custom types |
priority |
"low" | "high" |
Not available | Screen reader urgency (ARIA) |
dismissible |
boolean |
boolean |
Per-toast dismiss control |
onClose |
(id: string) => void |
onDismiss |
General close callback, receives toast ID |
onAutoClose |
(id: string) => void |
onAutoClose |
Same name -- fired on timeout expiry, receives toast ID |
onDismiss |
(id: string) => void |
N/A | Fired on manual user dismiss, receives toast ID |
onRemove |
() => void |
N/A | Called after exit animation completes and DOM removal |
className |
string |
Via classNames |
Per-toast class |
classNames |
Partial<PopserClassNames> |
ToastClassnames |
Per-toast classNames (12 slots) |
unstyled |
boolean |
boolean |
Per-toast unstyled override |
richColors |
boolean |
Not available | Per-toast rich colors override |
style |
CSSProperties |
style |
Same |
data |
Record<string, unknown> |
Not available | Custom data bag |
anchor |
Element | MouseEvent | {x,y} |
Not available | Pin toast to element or coordinates |
arrow |
boolean |
Not available | Arrow pointing at anchor |
enterFrom |
"top" | "bottom" | "left" | "right" |
Not available | Per-toast animation direction |
closeButtonPosition |
"header" | "corner" |
Not available | Per-toast close button placement |
invert |
Not needed | boolean |
Popser uses theme prop |
testId |
data-popser-root always |
testId prop |
Stable selectors by default |
position |
On <Toaster> only |
Per-toast override | Architectural choice |
| Prop | Popser | Sonner | Notes |
|---|---|---|---|
position |
6 positions | 6 positions | Same |
limit |
number (default: 3) |
visibleToasts (default: 3) |
Same behavior, better name |
timeout |
number (default: 4000) |
toastOptions.duration (default: 4000) |
Top-level prop |
closeButton |
"always" | "hover" | "never" |
boolean |
3 modes vs on/off |
expand |
boolean |
boolean |
Same |
expandedLimit |
number |
Not available | Show more toasts when expanded |
richColors |
boolean |
boolean |
Same |
theme |
"light" | "dark" | "system" |
"light" | "dark" | "system" |
Same |
offset |
number | string |
string | number | object |
Simpler |
mobileOffset |
number | string |
Not available | Separate mobile offset |
gap |
number |
number |
Same |
mobileBreakpoint |
number (default: 600) |
Hardcoded 600px | Configurable |
swipeDirection |
string | string[] |
string[] |
Same |
icons |
PopserIcons |
object |
Same concept |
classNames |
PopserClassNames (12 slots) |
Element + type slots | More granular element targeting |
style |
CSSProperties |
Not available | Viewport inline styles |
unstyled |
boolean |
boolean |
Same |
toastOptions |
Partial<PopserOptions> |
object |
Global defaults for all toasts |
closeButtonPosition |
"header" | "corner" |
Not available | Global close button placement |
dir |
"ltr" | "rtl" | "auto" |
"ltr" | "rtl" |
Also supports "auto" (reads from DOM) |
historyLength |
number |
Not available | Enable toast history tracking |
hotkey |
F6 (Base UI built-in) | Alt+T |
F6 is ARIA standard |
invert |
Not needed | boolean |
Use theme instead |
toasterId |
Not needed | string |
Single manager pattern |
Sonner #729, #605: Dismissed toasts are never evicted from the internal this.toasts array. Over time, the array grows unbounded. DOM nodes are leaked too.
Popser: Base UI's reactive store properly cleans up on close. Our activeToasts Set removes IDs on close. No leaks.
Sonner: No dedicated update() method. To change a toast, you re-call toast() with the same ID, replacing the entire toast. Loading state bleeds through (#401, 10 comments). Action buttons persist on future toasts (#692).
Popser: toast.update(id, partialOptions) -- partial updates via Base UI's manager.update(). Only changed fields are updated. Type, icon, action all reset cleanly.
Sonner #654, #705: Close button is either always visible or hidden. No hover-to-show behavior (was removed, users want it back).
Popser: Three modes: "always", "hover", "never". Hover is the default. On mobile, hover-mode buttons are always visible (touch has no hover).
Sonner #376 (open since March 2024, 7 upvotes): Mobile breakpoint is hardcoded to 600px in CSS @media (max-width: 600px). No way to change it.
Popser: mobileBreakpoint prop. Uses window.matchMedia to detect mobile and sets data-mobile attribute on viewport. CSS targets [data-popser-viewport][data-mobile] instead of a fixed media query. Fully configurable at runtime.
Sonner #719, #715: When a toast is updated with different content length, the height allocation doesn't change. Causes rendering bugs.
Popser: Base UI recalculates --toast-height on content change (PR #3359). CSS-driven, no JS measurement lag.
Sonner #528 (8 comments): <section> rendered as child of <html> causes React hydration mismatch in Next.js.
Popser: Uses Toast.Portal which renders after mount. No SSR mismatch. No setTimeout workarounds needed.
Sonner #632, #633 (5 upvotes each): Critical styling is gated behind data-styled="true" selector. Custom styles require !important to override.
Popser: Headless Base UI primitives. CSS is opt-in (import "@vcui/popser/styles"). Your styles always win. OKLCH tokens via CSS variables -- override with a single :root block. The only !important usage is internal: enter/exit animations (data-starting-style/data-ending-style) use !important to override the collapsed stacking transforms. User-facing styles never need it.
Sonner #602: Broken with Tailwind v4 due to internal CSS conflicts.
Popser: All tokens use OKLCH (Tailwind v4 native color space). Every element has data-popser-* attributes for Tailwind selectors. No internal CSS to fight.
Sonner #667 (5 upvotes), shadcn/ui #2401: Toast appears above Radix UI dialogs but swipe doesn't work. Or appears below dialog backdrop.
Popser: Toast.Portal renders through proper React portal. In v1.2, the Viewport uses popover="manual" (Popover API) to render in the top layer — toasts always appear above dialogs, modals, and other stacking contexts. No z-index hacks needed.
Sonner #723: If toast() is called before <Toaster> mounts, the toast is silently dropped.
Popser: createToastManager() singleton queues toasts independently of React. When <Toast.Provider> mounts with the same manager, queued toasts flush automatically.
Sonner #718: Icon is rendered twice in promise toasts.
Popser: Single icon render path. ToastIcon component has a clear priority chain: per-toast icon > global icon > type-specific built-in. No duplication.
Sonner #713, #732: SVG icons have no accessible names. No ARIA labels.
Popser:
- All icons have
role="img"+aria-label - Close button has
aria-label="Close notification" priority: "high"maps to assertive ARIA live region- F6 keyboard navigation (ARIA standard, vs Sonner's
Alt+T) - Viewport has
role="region"+aria-label="Notifications"
Sonner #714, #742: No stable data attributes for testing. data-testid requires per-toast configuration.
Popser: Every element has a stable data-popser-* attribute: data-popser-viewport, data-popser-root, data-popser-title, data-popser-description, data-popser-close, data-popser-action, data-popser-cancel, data-popser-icon. Always present, zero config.
Sonner #678, #683, #684 (multiple issues): Toast content restricted to text width. w-auto doesn't work. Custom toasts don't get proper width.
Popser: Width controlled by --popser-width CSS variable (default 356px). Override globally with :root { --popser-width: 400px; } or per-toast with classNames.root. No --width constraint fighting.
Sonner: Collapsed stacking via JS height measurement (getBoundingClientRect) + manual offset calculation. Layout thrashing on every toast add/remove.
Popser: Collapsed stacking via Base UI CSS variables (--toast-index, --toast-frontmost-height). Toasts are position: absolute, layered with z-index: calc(100 - var(--toast-index)), scaled down with scale(1 - index * 0.05), and faded with progressive opacity. Content behind the front toast is hidden with overflow: hidden + opacity: 0. The --popser-visible-count CSS variable (from limit prop) cuts off toasts beyond the visible count. Expanded state fans toasts out using translateY() with cumulative --toast-offset-y offsets — toasts stay position: absolute in both states, animating smoothly between collapsed peek and expanded fan-out. No JS measurement.
Sonner: Not available. Toasts only appear in fixed viewport positions.
Popser: Pin toasts to DOM elements, mouse events, or {x, y} coordinates. Full Floating UI positioning via Base UI's Toast.Positioner:
toast.success("Copied!", {
anchor: buttonRef.current,
anchorSide: "top",
arrow: true,
timeout: 2000,
});Configurable side, alignment, offset, collision boundary, position method, and sticky behavior. Arrow included. Only one anchored toast visible at a time.
| Feature | Status | Notes |
|---|---|---|
invert prop |
Not planned | Use theme instead |
hotkey customization |
Base UI default (F6) | F6 is the ARIA standard |
toasterId (multiple Toasters) |
Not planned | Single manager pattern |
Multi-toaster (id prop) |
Not planned | Single manager is simpler |
pauseOnHover: false |
Deferred | Base UI limitation |
| Vanilla (non-React) support | Not planned | React-only |
Global ToastState (singleton Observer class)
└── this.toasts: Array<ToastT> (never shrinks! memory leak)
└── this.subscribers: Array<callback> (pub/sub to React)
└── this.dismissedToasts: Set<id>
└── ID generation: auto-incrementing integer counter
<Toaster>
└── useEffect subscriber (not useLayoutEffect -- toasts can be lost)
└── setTimeout(() => flushSync(() => setToasts(...))) ← "temp solution"
└── Renders <section aria-live="polite"> directly (hydration risk)
└── <ol> per position
└── <li> per toast
└── getBoundingClientRect() for height measurement (layout thrashing)
└── Manual pointer events for swipe (velocity threshold: 0.11)
└── setTimeout for dismiss animation (200ms TIME_BEFORE_UNMOUNT)
Problems:
- Mutable
toastsarray grows forever -- JSX-heavy toasts leak 10-50KB each (#729, #605) - Dual state: Observer has its own array AND React
useStatehas a copy -- sync issues setTimeout+flushSyncis a documented "temp solution" causing race conditions (#725, #730, #592)useEffectsubscription misses toasts created in siblinguseEffect/useLayoutEffect(#723)- Height tracking via
getBoundingClientRect()-- layout thrashing on every toast - Animation system is imperative (JS timeouts, not CSS-driven)
- Swipe handling is custom pointer events (not accessible, sticks to cursor #733)
<section>root causes SSR hydration mismatch (#528)
Toast.createToastManager() (Base UI singleton)
└── Reactive store with memoized selectors
└── Proper cleanup on close
└── Generic types: ToastObject<PopserToastData>
<Toast.Provider toastManager={manager} limit={N} timeout={ms}>
└── <ToasterContent>
│ └── useState(isHovering) + debounced mouse handlers (100ms)
│ └── useState(isMobile) + matchMedia listener
│ └── isExpanded = expand || isHovering
└── <Toast.Portal> (proper React portal)
└── <Toast.Viewport data-expanded data-mobile data-position data-theme>
│ └── CSS vars: --popser-offset, --popser-gap, --popser-visible-count
└── <Toast.Root> per toast
│ └── CSS vars: --toast-index, --toast-height, --toast-frontmost-height
│ └── Collapsed: position:absolute, stacking via transforms + z-index
│ └── Expanded: position:absolute, translateY fan-out via --toast-offset-y
│ └── data-starting-style / data-ending-style for enter/exit
│ └── Native swipe handling (accessible, Base UI)
└── <Toast.Positioner> for anchored toasts
└── Floating UI positioning (side, align, offset, collision)
└── <Toast.Arrow> optional arrow element
- CSS auto-imported with the JS package
- Uses
data-styled="true"to gate default styles - HSL color values
!importantrequired for overrides- Hardcoded
600pxmobile breakpoint --widthCSS variable constrains toast width- Custom properties not documented
- No opt-out mechanism
- CSS is a separate opt-in file (
import "@vcui/popser/styles") - OKLCH color space (Tailwind v4 native)
- CSS custom properties documented and prefixed (
--popser-*) - Token file importable separately (
import "@vcui/popser/tokens") - User styles never need
!important mobileBreakpointprop drives JSmatchMedia+data-mobileattributeunstyledmode for zero default stylesclassNamesprop with 12 target slots- Dark mode via
[data-theme="dark"]or.darkclass
| Slot | Popser | Sonner |
|---|---|---|
viewport |
classNames.viewport |
Not available |
root / toast |
classNames.root |
classNames.toast |
content |
classNames.content |
Not available |
header |
classNames.header |
Not available |
title |
classNames.title |
classNames.title |
description |
classNames.description |
classNames.description |
icon |
classNames.icon |
Not available |
actionButton |
classNames.actionButton |
classNames.actionButton |
cancelButton |
classNames.cancelButton |
classNames.cancelButton |
closeButton |
classNames.closeButton |
classNames.closeButton |
actions |
classNames.actions |
Not available |
arrow |
classNames.arrow |
Not available |
default |
Per-type via data-type |
classNames.default (buggy #744) |
Popser: 12 element slots. Sonner has fewer element-targeting slots and mixes in per-type class overrides (and default is broken — #744).
| # | Issue | Popser Fix |
|---|---|---|
| #729 | Memory leak -- dismissed toasts never cleaned up | Base UI reactive store with proper cleanup |
| #605 | Toast DOM nodes are leaked | onRemove callback + store eviction |
| #719 | Height not recalculated on content update | --toast-height CSS variable, auto-recalc |
| #715 | Height from shorter toast preserved | Same as above |
| #692 | Action button persists on future toasts | Fresh data per toast, no shared state |
| #401 | Loading state not cleared with same ID | update() properly resets type |
| #718 | Icon rendered twice in promise | Single render path in ToastIcon |
| #528 | Hydration error (<section> in <html>) |
Toast.Portal renders after mount |
| #723 | Toast dropped if called before mount | Manager queues independently |
| #730 | Update during dismiss animation skipped | Base UI handles state transitions |
| #725 | Re-creation after dismissal prevents re-render | Clean ID lifecycle |
| #592 | Dismissing toast timing desync | CSS transitions, not JS timeouts |
| #667 | Toast above dialogs but can't swipe | Proper portal + z-index management |
| #733 | Toast sticks to cursor when swiping | Base UI native swipe handling |
| # | Issue | Popser Fix |
|---|---|---|
| #744 | classNames.default applies to all types |
Type-based data-type attribute |
| #633 | Styling gated behind data-styled="true" |
No gating -- CSS is opt-in import |
| #632 | Challenges customizing sonner | Headless primitives, no !important |
| #630 | Custom toast width misleading | width: 356px in default CSS, overridable |
| #683 | Toast content restricted to text width | Natural content sizing |
| #678 | Toast not centered with w-auto |
CSS flexbox layout |
| #684 | Custom toast not getting width | Inherits from [data-popser-root] |
| #696 | Description color in light theme | OKLCH tokens, opacity: 0.75 |
| #685 | CSS variables for icon hover | data-type on icon for styling |
| #682 | Headless centering broken | data-position with CSS selectors |
| #602 | Broken with Tailwind v4 | OKLCH native, no CSS conflicts |
| # | Issue | Popser Implementation |
|---|---|---|
| #376 | Custom mobile breakpoint | mobileBreakpoint prop |
| #654 | closeButtonPosition |
closeButton: "always" | "hover" | "never" + closeButtonPosition: "header" | "corner" |
| #705 | Restore hover-to-show close | Default mode in popser |
| #714 | data-toast-id stable attribute |
data-popser-root + data-type |
| #741 | Stable attribute on toaster | data-popser-viewport |
| #713 | SVG accessibility (aria-hidden) | role="img" + aria-label on all icons |
| #732 | Fix SVG accessibility | Same as above |
| #666 | Update toast and change ID | toast.update(id, opts) |
| #734 | clearHistory method |
toast.getHistory() + toast.clearHistory() with historyLength prop |
| #464 | Per-state config in toast.promise() |
Extended results with { title, description, timeout, icon, action } |
| — | Animation duration customization | --popser-transition-duration and --popser-anchored-transition-duration CSS properties |
| — | Popover API for dialog layering | popover="manual" on Viewport, toasts render above dialogs |
| — | Expanded limit | expandedLimit prop — show more toasts on hover |
| — | AbortSignal for promise toasts | signal, aborted, onAbort on toast.promise() |
| — | Per-toast entry direction | enterFrom: "top" | "bottom" | "left" | "right" |
| — | RTL support | dir: "ltr" | "rtl" | "auto" on Toaster |
| # | Issue | Why Popser is Unaffected |
|---|---|---|
| #306 | ARIA live regions conditionally rendered | Base UI always-present ARIA live regions |
| #357 | z-index behind Radix dialog | Toast.Portal with proper stacking context |
| #591 | Tailwind v4 classNames overrides require !important |
OKLCH tokens + headless primitives |
| #596 | Toasts not showing after v2.0 upgrade | CSS is separate opt-in file, not injected |
| #322 | Double toast in React Strict Mode | Manager singleton queues independently of React lifecycle |
| #479 | Multiple toasts all closing when last timer fires | Base UI manages individual toast timers |
| #476 | Loading state blocks close button | Close button always functional |
| #450 | Server Actions error handling awkward | toast.promise() + callbacks |
| #396 | Mobile offsets hardcoded to 16px | mobileBreakpoint prop + mobileOffset prop |
- Zero memory leaks -- Base UI's reactive store with proper cleanup
- No
!important-- headless primitives, opt-in CSS - OKLCH colors -- Tailwind v4 native, modern color space
update()API -- partial updates, not full replacement- 3-mode close button -- always / hover / never
- Configurable mobile breakpoint -- prop, not hardcoded
- CSS-driven animations --
data-starting-style/data-ending-style, direction-aware enter/exit - CSS-driven stacking --
--toast-index,--toast-frontmost-height,--popser-visible-count, no JS layout - JS-driven expansion -- debounced hover with 100ms timeout, prevents flicker loops
- ARIA-first accessibility -- F6 nav, priority system, assertive announcements
- Stable test selectors --
data-popser-*on every element - 12 classNames slots -- vs Sonner's 6 (one broken)
- No hydration errors -- proper portal rendering
- shadcn registry --
npx shadcn add @vcode-sh/popser - Anchored toasts -- pin to elements, mouse events, or coordinates with arrow support
- Clean architecture -- Base UI primitives, thin wrapper, no state hacks
- RTL support --
dir="rtl"or"auto", flips positions/swipe/animations - Toast history --
toast.getHistory()+toast.clearHistory()with configurable retention - AbortSignal for promises -- cancel loading toasts cleanly with
signal+abortedcontent - Animation duration control --
--popser-transition-durationCSS custom property - Popover API -- toasts render above dialogs via
popover="manual"
- Peer dependency on
@base-ui/react-- additional install - Larger install footprint -- Base UI adds to node_modules
- Newer library -- less community adoption than Sonner
- No vanilla JS support -- React-only
- No
invertprop -- intentional (usethemeinstead) - IDs are strings only -- Base UI constraint (sonner allows numbers)
- No multiple Toaster instances -- single manager pattern
| Raw | Gzipped | |
|---|---|---|
| Popser JS (ESM) | 15.7 KB | 5.6 KB |
Popser CSS (styles/min) |
16.6 KB | 2.7 KB |
| Popser total | 32.3 KB | 8.3 KB |
| Sonner JS (ESM, CSS bundled in) | 65.9 KB | ~13.5 KB |
Popser ships about half the gzipped weight of Sonner, and the CSS is a separate opt-in file — your bundler tree-shakes what it wants.
Two CSS import paths:
// Modular: 6 files linked via @import directives
import "@vcui/popser/styles";
// Flat: single inlined, minified file (no @imports)
import "@vcui/popser/styles/min";For apps already using @base-ui/react (shadcn v2 direction), the marginal cost of popser is minimal. @base-ui/react is tree-shakable — only Toast primitives are included.
// components/ui/sonner.tsx (shadcn ships this)
import { Toaster as Sonner } from "sonner";
function Toaster(props) {
const { theme } = useTheme();
return <Sonner theme={theme} className="toaster group" {...props} />;
}// components/ui/popser.tsx (via npx shadcn add @vcode-sh/popser)
import { Toaster as PopserToaster } from "@vcui/popser";
function Toaster(props) {
const { theme } = useTheme();
return (
<PopserToaster
theme={theme}
richColors
style={{
"--popser-bg": "var(--popover)",
"--popser-fg": "var(--popover-foreground)",
"--popser-border": "var(--border)",
"--popser-radius": "var(--radius)",
}}
{...props}
/>
);
}CSS variables bridge shadcn's design tokens to popser's token system. No theme file hacking.