Skip to content

Latest commit

 

History

History
544 lines (418 loc) · 26.2 KB

File metadata and controls

544 lines (418 loc) · 26.2 KB

Popser vs Sonner

Drop-in replacement for Sonner. Same API, fewer bugs, no !important.


At a Glance

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

API Comparison

Imperative API

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

Migration from Sonner

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


Toast Options

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

Toaster Props

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

What We Do Differently

1. No Memory Leaks

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.

2. Proper update() API

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.

3. Close Button Modes

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

4. Configurable Mobile Breakpoint

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.

5. Height Recalculation

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.

6. No Hydration Errors

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.

7. No !important Required for User Styles

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.

8. Tailwind v4 Compatible

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.

9. Z-index / Dialog Layering

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.

10. Toast Queued Before Mount

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.

11. Promise Toast Icon Duplication

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.

12. Accessibility First

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"

13. Stable Test Selectors

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.

14. Width Not Constrained

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.

15. CSS-Driven Stacking

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.

16. Anchored Toasts

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.


What Sonner Does That We Don't

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

Architecture Comparison

Sonner

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 toasts array grows forever -- JSX-heavy toasts leak 10-50KB each (#729, #605)
  • Dual state: Observer has its own array AND React useState has a copy -- sync issues
  • setTimeout + flushSync is a documented "temp solution" causing race conditions (#725, #730, #592)
  • useEffect subscription misses toasts created in sibling useEffect/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)

Popser

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 Architecture Comparison

Sonner

  • CSS auto-imported with the JS package
  • Uses data-styled="true" to gate default styles
  • HSL color values
  • !important required for overrides
  • Hardcoded 600px mobile breakpoint
  • --width CSS variable constrains toast width
  • Custom properties not documented
  • No opt-out mechanism

Popser

  • 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
  • mobileBreakpoint prop drives JS matchMedia + data-mobile attribute
  • unstyled mode for zero default styles
  • classNames prop with 12 target slots
  • Dark mode via [data-theme="dark"] or .dark class

classNames Slots

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


Sonner Issues We Address

Bugs (Fixed by Architecture)

# 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

Styling (Fixed by CSS Architecture)

# 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

Features (Implemented)

# 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

Additional Sonner Issues We Address (by Architecture, some now closed)

# 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

Popser Pros

  1. Zero memory leaks -- Base UI's reactive store with proper cleanup
  2. No !important -- headless primitives, opt-in CSS
  3. OKLCH colors -- Tailwind v4 native, modern color space
  4. update() API -- partial updates, not full replacement
  5. 3-mode close button -- always / hover / never
  6. Configurable mobile breakpoint -- prop, not hardcoded
  7. CSS-driven animations -- data-starting-style / data-ending-style, direction-aware enter/exit
  8. CSS-driven stacking -- --toast-index, --toast-frontmost-height, --popser-visible-count, no JS layout
  9. JS-driven expansion -- debounced hover with 100ms timeout, prevents flicker loops
  10. ARIA-first accessibility -- F6 nav, priority system, assertive announcements
  11. Stable test selectors -- data-popser-* on every element
  12. 12 classNames slots -- vs Sonner's 6 (one broken)
  13. No hydration errors -- proper portal rendering
  14. shadcn registry -- npx shadcn add @vcode-sh/popser
  15. Anchored toasts -- pin to elements, mouse events, or coordinates with arrow support
  16. Clean architecture -- Base UI primitives, thin wrapper, no state hacks
  17. RTL support -- dir="rtl" or "auto", flips positions/swipe/animations
  18. Toast history -- toast.getHistory() + toast.clearHistory() with configurable retention
  19. AbortSignal for promises -- cancel loading toasts cleanly with signal + aborted content
  20. Animation duration control -- --popser-transition-duration CSS custom property
  21. Popover API -- toasts render above dialogs via popover="manual"

Popser Cons

  1. Peer dependency on @base-ui/react -- additional install
  2. Larger install footprint -- Base UI adds to node_modules
  3. Newer library -- less community adoption than Sonner
  4. No vanilla JS support -- React-only
  5. No invert prop -- intentional (use theme instead)
  6. IDs are strings only -- Base UI constraint (sonner allows numbers)
  7. No multiple Toaster instances -- single manager pattern

Bundle Size

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.


shadcn/ui Integration

Sonner (current)

// 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} />;
}

Popser (drop-in replacement)

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