Popser wraps Base UI Toast primitives into a production-ready imperative API with Sonner-compatible DX. This doc maps what comes from Base UI, what popser adds, and where the boundaries are.
| Popser | Base UI Toast | |
|---|---|---|
| Type | Library (npm package) | Primitive components (part of @base-ui/react) |
| API style | Imperative (toast.success()) |
Declarative (React components + hooks) |
| Ready to use | Yes -- import and call | No -- requires assembly |
| Built-in icons | 5 SVGs + CSS spinner | None |
| Built-in styles | Opt-in CSS file | None |
| shadcn/ui registry | Yes | No |
| Close button | Built-in with 3 modes | Bare <Toast.Close> |
| Action buttons | { label, onClick } API |
actionProps config object |
| Close all | toast.close() |
Manual iteration or PR #3979 |
update() |
toast.update(id, opts) |
manager.update(id, opts) |
| Promise toasts | toast.promise() |
manager.promise() |
| Priority system | priority: "high" |
priority: "high" |
| Anchored toasts | anchor prop with full positioning options |
Toast.Positioner + Toast.Arrow |
Popser is built on Base UI Toast. Every popser component wraps a Base UI primitive:
Popser → Base UI
─────────────────────────────────────
<Toaster> → <Toast.Provider> + <Toast.Portal> + <Toast.Viewport>
<PopserToastRoot> → <Toast.Root> + <Toast.Content>
(anchored) → + <Toast.Positioner> + <Toast.Arrow>
<Toast.Title> → <Toast.Title>
<Toast.Description> → <Toast.Description>
<ToastCloseButton> → <Toast.Close>
<ToastActions> → <Toast.Action> + <Toast.Close>
toast() → manager.add()
toast.close() → manager.close()
toast.update() → manager.update()
toast.promise() → manager.promise()
getManager() → Toast.createToastManager()
useToaster() → Toast.useToastManager()
Popser adds: imperative API, built-in icons, CSS tokens, classNames, close button modes, mobile responsive, anchored toast configuration, shadcn integration, and the data-popser-* attribute system.
| Feature | How Popser Uses It |
|---|---|
Toast.createToastManager<T>() |
Singleton in manager.ts, shared between imperative API and Provider. Generic since Base UI v1.2.0. |
Toast.useToastManager() |
Re-exported as useToaster() + used in <ToasterContent> |
| Reactive store | Drives toast list rendering, no manual subscription |
| Memoized selectors | Prevents unnecessary re-renders |
Toast.Provider |
Wraps everything, receives limit, timeout, toastManager |
Toast.Portal |
Renders viewport outside DOM tree, no hydration issues |
Toast.Viewport |
ARIA landmark region, F6 keyboard target |
Toast.Root |
Individual toast wrapper, swipe handling, transitions |
Toast.Content |
Inner container, data-behind for stacking |
Toast.Positioner |
Floating UI anchor positioning for anchored toasts |
Toast.Arrow |
Arrow element pointing at anchor |
Toast.Title |
Semantic <h2>, ARIA announcement text |
Toast.Description |
Semantic <p>, ARIA announcement text |
Toast.Close |
Dismiss button, properly removes from store |
Toast.Action |
Action button, ARIA-compliant |
| Variable | Purpose | Used in Popser |
|---|---|---|
--toast-height |
Measured natural height (px) | Stacking calculation |
--toast-index |
Position in list (0 = frontmost) | Z-order, scale, opacity |
--toast-offset-y |
Vertical offset when expanded (px) | Expanded layout |
--toast-swipe-movement-x |
Horizontal swipe offset | Swipe animation |
--toast-swipe-movement-y |
Vertical swipe offset | Swipe animation |
--toast-frontmost-height |
Height of front toast | Collapsed uniform height, viewport sizing |
| Attribute | Element | Purpose |
|---|---|---|
data-starting-style |
Root | Entry animation trigger |
data-ending-style |
Root | Exit animation trigger |
data-expanded |
Viewport, Root | Expanded state |
data-limited |
Root | Removed due to limit |
data-swiping |
Root | Active swipe gesture |
data-swipe-direction |
Root | Direction being swiped |
data-behind |
Content | Behind frontmost toast |
data-type |
Root, Title, Description, Action, Close | Toast type value |
| Feature | Implementation |
|---|---|
| ARIA live region | Viewport announces new toasts to screen readers |
| Priority system | "high" = assertive announcement, "low" = polite |
| F6 keyboard navigation | Focus jumps to viewport landmark |
| Heading hierarchy | <Toast.Title> renders as <h2> |
| Swipe handling | Touch-accessible, data-swipe-ignore for interactive content |
Base UI requires React hooks and components:
// Base UI (raw) -- requires React component context
function MyComponent() {
const { add } = Toast.useToastManager();
const handleClick = () => {
add({
title: "Photo uploaded",
description: "12 photos added.",
type: "success",
data: { icon: <CheckIcon /> },
});
};
return <button onClick={handleClick}>Upload</button>;
}Popser provides an imperative API callable from anywhere:
// Popser -- works outside React
import { toast } from "@vcui/popser";
toast.success("Photo uploaded", {
description: "12 photos added.",
icon: <CheckIcon />,
});Base UI renders no icons. You must provide everything.
Popser includes 5 inline SVGs (success, error, info, warning, close) + a CSS spinner (loading). Zero external dependencies. All use currentColor for theming.
Override chain: toast.icon > Toaster.icons[type] > built-in SVG > null.
Base UI ships zero CSS. You style from scratch.
Popser ships three opt-in CSS imports:
import "@vcui/popser/styles"; // Modular (6 files via @import)
import "@vcui/popser/styles/min"; // Flat, minified, single file
import "@vcui/popser/tokens"; // Just CSS custom propertiesOKLCH color space. Light + dark themes. Rich color variants for all 5 types. Loading spinner animation. Mobile responsive. Position variants. Enter/exit transitions.
Base UI uses className (string or function) on each component individually.
Popser adds a classNames prop on <Toaster> that cascades to all toast elements:
<Toaster
classNames={{
viewport: "fixed bottom-4 right-4",
root: "rounded-lg bg-popover border p-4",
title: "text-sm font-medium",
description: "text-sm text-muted-foreground",
actionButton: "bg-primary text-primary-foreground",
cancelButton: "border",
closeButton: "opacity-50 hover:opacity-100",
icon: "mr-2",
header: "flex gap-3",
content: "flex flex-col",
actions: "flex gap-2 mt-3",
arrow: "fill-popover",
}}
/>12 named slots. Applied globally. Per-toast className merges with global.
Base UI provides a bare <Toast.Close> element. No icon, no visibility modes.
Popser wraps it into <ToastCloseButton> with:
- Built-in X icon (stroke SVG, 14x14)
- Three modes:
"always"|"hover"|"never" - Hover mode: hidden by default, visible on toast hover/focus
- Mobile: hover-mode buttons are always visible (no hover on touch)
aria-label="Close notification"- Custom icon override via
icons.close
Base UI uses actionProps -- a config object passed to Toast.Action:
// Base UI (raw)
manager.add({
title: "Deleted",
actionProps: { children: "Undo", onClick: handleUndo },
});Popser uses { label, onClick } -- simpler, Sonner-compatible:
// Popser
toast.error("Deleted", {
action: { label: "Undo", onClick: handleUndo },
cancel: { label: "Dismiss" },
});Cancel button wraps Toast.Close (auto-dismisses).
Base UI provides Toast.Positioner and Toast.Arrow as raw components that need manual wiring.
Popser wraps this into a declarative anchor prop:
toast.success("Copied!", {
anchor: buttonRef.current, // Element, MouseEvent, or {x, y}
anchorSide: "top",
anchorAlign: "center",
anchorOffset: 8,
arrow: true,
arrowPadding: 5,
anchorCollisionBoundary: "clipping-ancestors",
anchorCollisionPadding: 5,
anchorPositionMethod: "absolute",
anchorSticky: false,
});For MouseEvent and {x, y} anchors, popser creates a temporary fixed-position element at those coordinates and cleans it up on toast close. Only one anchored toast is visible at a time.
Base UI provides data-type, data-starting-style, etc.
Popser adds a parallel data-popser-* system for stable CSS targeting:
| Attribute | Element | Always Present |
|---|---|---|
data-popser-viewport |
Viewport | Yes |
data-popser-root |
Toast root | Yes |
data-popser-content |
Content wrapper | Yes |
data-popser-header |
Header row | Yes |
data-popser-text |
Text wrapper | Yes |
data-popser-title |
Title | Yes |
data-popser-description |
Description | When present |
data-popser-icon |
Icon wrapper | When present |
data-popser-close |
Close button | When present |
data-popser-actions |
Actions container | When present |
data-popser-action |
Action button | When present |
data-popser-cancel |
Cancel button | When present |
data-popser-arrow |
Anchor arrow | When anchored + arrow |
data-popser-spinner |
Loading spinner | When loading |
data-popser-spinner-bar |
Spinner bar (x12) | When loading |
data-position |
Viewport | Yes |
data-theme |
Viewport | Yes |
data-rich-colors |
Viewport | When enabled |
data-expanded |
Viewport | When expanded |
data-mobile |
Viewport | When below mobileBreakpoint |
data-close-button |
Close button | Yes (mode value) |
data-anchored |
Toast root | When anchored |
Base UI provides no mobile handling.
Popser detects mobile via JS window.matchMedia and sets a data-mobile attribute on the viewport:
mobileBreakpointprop (default: 600px) drivesmatchMedialistenermobileOffsetprop for separate mobile offsetdata-mobileattribute enables mobile CSS: full-width toasts, bottom positioning, always-visible close buttons- Toast width:
calc(100vw - 2 * var(--popser-offset, 16px))
Base UI provides data-expanded attribute but relies on CSS :hover or manual state.
Popser implements JS-driven expansion in <ToasterContent>:
isHoveringReact state withhandleMouseEnter/handleMouseLeavecallbacks- 100ms debounce timeout on
mouseLeaveto prevent flicker when moving between toasts - Mouse handlers attached to
<Toast.Viewport>(moved from per-toast in v0.4.0) isExpanded = expand || isHoveringdrivesdata-expandedon viewport
Base UI provides --toast-index and --toast-frontmost-height but no default stacking styles.
Popser implements a full collapsed card stack:
- Toasts are
position: absolute, anchored to the bottom (or top) of the viewport z-index: calc(100 - var(--toast-index, 0))for front-to-back layeringheight: var(--toast-frontmost-height, auto)+overflow: hiddenfor uniform card heighttransform: translateY(-index * gap) scale(1 - index * 0.05)for peek + shrink effect- Content of non-front toasts hidden with
opacity: 0 - Expanded state: viewport switches to
display: flex+column-reversewithoverflow-y: auto
Base UI has no registry integration.
Popser ships as npx shadcn add @vcode-sh/popser:
- Pre-configured wrapper with
next-themesintegration - CSS variable bridge to shadcn design tokens
- Drop-in replacement for shadcn's sonner component
Base UI sets data-type but ships no color system.
Popser ships full rich color tokens for all 5 types (success, error, info, warning, loading) in both light and dark themes. Enabled via richColors prop. OKLCH color space.
| # | Issue | Status | Popser Solution |
|---|---|---|---|
| #2809 | Toast.Description doesn't render with render prop |
Open | We use standard children, not render prop for Description |
| #3335 | actionProps is config-object, not ReactNode |
Open | Wrapped in { label, onClick } object, rendered as <Toast.Action> children |
| #3437 | Type augmentation needed for custom types | Open | type?: PopserType | (string & {}) allows any string while providing autocomplete |
| #3952 | Module augmentation for type extension | Open | Same solution -- union type with string escape hatch |
| #3979 | Close all toasts | Open | toast.close() without args iterates activeToasts Set |
| #3026 | Anchor toast to an element | Shipped | Implemented via anchor prop with full positioning options |
| # | PR | Benefit |
|---|---|---|
| #3464 | Reactive store refactor | Foundation for our manager singleton |
| #3882 | Generic useToastManager<T> and createToastManager<T> |
Type-safe custom data access (v1.2.0) |
| #3359 | Height recalculation on layout change | Content updates work correctly |
| #4040 | Prevent dismissed promise toast from reopening | Promise transitions are clean |
| #3469 | Fix <Toast.Close> aria-hidden warning |
Close button accessibility |
| #3564 | Fix timers not rescheduled on update | toast.update() resets timeout |
| #3443 | Fix flushSync dev error |
Clean React 19 compatibility |
| #3392 | Fix multiple swipe directions on same axis | Multi-direction swipe works |
| #3096 | Anchored toast support (Toast.Positioner) |
Foundation for anchored toasts |
| #2929 | ReactNode for title/description |
Rich content in toasts |
| #2742 | Variable height stacking | Mixed-height toasts stack correctly |
| #2770 | Reduce stickiness of expanded state | Better hover expand/collapse |
| #2769 | Freeze toast transform during swipe | Smooth swipe animations |
| #2246 | Viewport as announce container | ARIA live region for screen readers |
| Popser | Base UI | Notes |
|---|---|---|
toast("title", opts) |
manager.add({ title, ...opts }) |
Wraps in toManagerOptions() |
toast.success("title") |
manager.add({ title, type: "success" }) |
Convenience method |
toast.close(id) |
manager.close(id) |
Also removes from activeToasts |
toast.close() |
Loop manager.close(id) |
Iterates all active IDs |
toast.update(id, opts) |
manager.update(id, opts) |
Converts to Base UI shape |
toast.promise(p, opts) |
manager.promise(p, opts) |
Converts string/fn success/error handlers |
| Popser Component | Base UI Components Used | What's Added |
|---|---|---|
<Toaster> |
Provider, Portal, Viewport | limit, timeout, manager injection, data attributes, CSS vars |
<ToasterContent> |
Viewport (renders inside Provider) | isHovering + debounce, isMobile via matchMedia, mouse handlers |
<PopserToastRoot> |
Root, Content, Positioner, Arrow | classNames merging, data-popser attributes, anchor configuration, mouse handler passthrough |
<ToastIcon> |
None | 5 SVGs, spinner, icon override chain |
<ToastActions> |
Action, Close | { label, onClick } API, cancel auto-dismisses |
<ToastCloseButton> |
Close | 3 visibility modes, built-in icon, aria-label |
useToaster() |
useToastManager() |
Thin re-export |
getManager() / resetManager() / clearManager() |
createToastManager() |
Singleton lifecycle (lazy init, reset, clear for tests) |
| Feature | Base UI | Popser Status |
|---|---|---|
render prop |
Custom element rendering | Not exposed (use toast.custom() instead) |
data-limited |
Alternate exit animation | CSS available, not styled yet |
data-swipe-direction on exit |
Direction-specific exit | CSS available, not styled yet |
- Zero assembly -- import, render
<Toaster>, calltoast(). Done. - Imperative API -- works outside React components, event handlers, async code
- Built-in icons -- 5 SVGs + spinner, zero deps, full override
- Built-in CSS -- OKLCH tokens, light/dark, rich colors, responsive, animations
- Sonner-compatible DX -- familiar API for millions of developers
- classNames system -- 12 named slots, cascading from Toaster
- Close button modes -- 3 modes with mobile awareness
- Stable selectors --
data-popser-*on every element - shadcn registry -- one-command install
- Mobile responsive -- built-in, configurable breakpoint
- Rich colors -- type-specific color tokens, opt-in
- Anchored toasts -- declarative anchor prop wrapping Positioner + Arrow
- Opinionated DOM structure -- fixed layout (icon + text + close in header, actions below)
- Less flexible rendering -- no
renderprop access (usetoast.custom()for full control) - String IDs only -- Base UI constraint passed through
- Single manager -- one toast stack, not multiple
- Fixed component hierarchy -- can't rearrange toast internals
- Additional abstraction layer -- thin but present
- You need fully custom toast rendering (no default structure)
- You need multiple independent toast stacks
- You're already building a design system and want maximum control
- You don't want any imperative API pattern
- You want Sonner DX with Base UI foundations
- You're migrating from Sonner
- You want built-in icons, styles, and accessibility
- You're using shadcn/ui
- You need
toast.success()callable from anywhere - You want production-ready out of the box
- You want anchored toasts with a simple API
┌─────────────────────────────────────────────────────┐
│ Your App │
│ │
│ toast.success("Done!") <Toaster position="...">│
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ toast.ts │──────┐ │ toaster.tsx │ │
│ │ (API) │ │ │ (Component) │ │
│ └─────────┘ │ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────┐ │
│ │ manager.ts │ │
│ │ (Singleton) │ │
│ │ │ │
│ │ Toast.createToastManager() ◄── Base UI│
│ └─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Base UI Primitives │ │
│ │ │ │
│ │ Toast.Provider │ │
│ │ └── Toast.Portal │ │
│ │ └── Toast.Viewport │ │
│ │ └── Toast.Root (per toast) │ │
│ │ ├── Toast.Positioner │ │
│ │ │ └── Toast.Arrow│ │
│ │ └── Toast.Content │ │
│ │ ├── Toast.Title│ │
│ │ ├── Toast.Description │
│ │ ├── Toast.Close│ │
│ │ └── Toast.Action │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Popser Additions │ │
│ │ │ │
│ │ toast-icon.tsx (5 SVGs + spinner) │ │
│ │ toast-close.tsx (3 modes) │ │
│ │ toast-action.tsx ({ label, onClick }) │ │
│ │ toast-root.tsx (assembly + classNames) │ │
│ │ anchor-resolver (Element/Event/{x,y}) │ │
│ │ styles/ (OKLCH tokens + CSS) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘