A toast notification component with stacking, hover pause, and progress indicators
npm install @choice-ui/toastA modern toast notification system for displaying non-blocking feedback messages. Built with a global store pattern that requires no context providers, supporting multiple instances, rich content, and smooth animations powered by Framer Motion.
``tsx`
import { Toaster, toast } from "@choice-ui/react"
- No Provider Required: Works without wrapping your app in a context provider
- Multiple Instances: Support multiple Toaster components with unique IDs
- Semantic Types: Six types with distinct icons (default, info, success, warning, error, loading)
- Color Variants: Customizable background colors independent of type
- Rich Content: Titles, descriptions, HTML content, and action buttons
- Promise API: Automatic loading/success/error state management
- Flexible Positioning: Six position options with customizable offset
- Stacking: Elegant stacking with expand-on-hover behavior
- Progress Bar: Optional countdown indicator with pause-on-hover
- Layouts: Default (full) and compact (minimal) display modes
- Swipe to Dismiss: Touch-friendly dismissal with direction-aware animation
- Compound Components: Full control over toast rendering via slots
- Accessible: ARIA live regions, keyboard navigation, screen reader support
Place a Toaster component anywhere in your app with a unique ID:
`tsx
import { Toaster } from "@choice-ui/react"
function App() {
return (
<>
{/ Your app content /}
>
)
}
`
`tsx
import { toast } from "@choice-ui/react"
// Default toast
toast.use("my-app")("Hello World")
// With description
toast.use("my-app")("File saved", {
description: "Your changes have been saved successfully.",
})
`
Each type displays a distinct icon and has semantic meaning:
`tsx
// Default - no icon
toast.use("my-app")("Notification")
// Info - information icon
toast.use("my-app").info("New message received")
// Success - checkmark icon
toast.use("my-app").success("Changes saved")
// Warning - warning triangle icon
toast.use("my-app").warning("Low storage space")
// Error - error icon
toast.use("my-app").error("Failed to upload file")
// Loading - spinner icon, does not auto-dismiss
toast.use("my-app").loading("Uploading...")
`
The variant option controls background color independently from type:
`tsx
// Default - dark neutral (gray)
toast.use("my-app")("Notification", { variant: "default" })
// Accent - blue/brand color
toast.use("my-app")("Notification", { variant: "accent" })
// Success - green
toast.use("my-app")("Notification", { variant: "success" })
// Warning - yellow/amber
toast.use("my-app")("Notification", { variant: "warning" })
// Error - red
toast.use("my-app")("Notification", { variant: "error" })
// Assistive - pink/magenta
toast.use("my-app")("Notification", { variant: "assistive" })
`
Add interactive buttons with action and cancel:
`tsx
// With action button
toast.use("my-app")("Item deleted", {
description: "The item has been moved to trash.",
action: {
label: "Undo",
onClick: () => restoreItem(),
},
})
// With action and cancel buttons
toast.use("my-app").error("Connection lost", {
description: "Unable to reach the server.",
action: {
label: "Retry",
onClick: () => reconnect(),
},
cancel: {
label: "Dismiss",
},
})
`
Automatically manage loading, success, and error states:
`tsxFailed: ${err.message}
toast.use("my-app").promise(
saveData(), // Your promise
{
loading: "Saving...",
success: "Saved successfully!",
error: (err) => ,
}
)
// With full options
toast.use("my-app").promise(fetchData(), {
loading: { title: "Loading...", description: "Please wait" },
success: (data) => ({
title: "Loaded!",
description: Found ${data.length} items,`
}),
error: (err) => ({
title: "Error",
description: err.message,
}),
})
`tsx
// Quick toast (2 seconds)
toast.use("my-app").info("Quick message", { duration: 2000 })
// Long toast (10 seconds)
toast.use("my-app").info("Important message", { duration: 10000 })
// Persistent toast (no auto-dismiss)
toast.use("my-app").warning("Critical notice", {
duration: 0,
cancel: { label: "Dismiss" },
})
`
`tsx
// Store the toast ID returned by toast functions
const id = toast.use("my-app").info("Processing...")
// Dismiss specific toast by ID
toast.use("my-app").dismiss(id)
// Dismiss all toasts in a Toaster
toast.use("my-app").dismissAll()
`
Render rich content with descriptionHtml:
`tsx`
toast.use("my-app").success("Project duplicated", {
descriptionHtml:
'Copied Project A to Project B',
})
Use different Toasters for different notification types:
`tsx
function App() {
return (
<>
>
)
}
// Target specific toasters from anywhere
toast.use("system").warning("System update available")
toast.use("user").success("Profile saved")
`
Use minimal single-line toasts with dynamic width:
`tsx
layout="compact"
position="bottom-center"
showProgress
/>
toast.use("compact-toaster").success("Saved!")
`
Show countdown indicator that pauses on hover:
`tsx`
Full control over toast appearance using compound components:
`tsx`
{(type, defaultIcon) => (
{type === "success" && "āØ"}
{type === "error" && "š„"}
{type === "warning" && "ā”"}
{type === "info" && "š”"}
{type === "loading" && defaultIcon}
{type === "default" && "š¢"}
)}
{(action, cancel, close) => (
{action && (
onClick={() => {
action.onClick()
close()
}}
>
{action.label}
)}
{cancel && }
)}
`tsx
interface ToasterProps {
/**
* Unique ID for this Toaster instance.
* Use toast.use(id) to send toasts to this specific Toaster.
* @default "default"
*/
id?: string
/**
* Screen position for the toast container.
* @default "bottom-right"
*/
position?: ToastPosition
/**
* Distance from viewport edges in pixels.
* @default 16
*/
offset?: number
/**
* Default auto-dismiss duration in milliseconds.
* Individual toasts can override this.
* @default 5000
*/
duration?: number
/**
* Maximum number of visible toasts.
* Older toasts are hidden but still in queue.
* @default 3
*/
visibleToasts?: number
/**
* Show countdown progress bar on each toast.
* Progress pauses when hovering over toasts.
* @default false
*/
showProgress?: boolean
/**
* Toast display layout.
* - "default": Full-size with title, description, actions
* - "compact": Minimal single-line style with dynamic width
* @default "default"
*/
layout?: "default" | "compact"
/**
* Whether to render in a portal.
* @default true
*/
portal?: boolean
/**
* Custom portal container element.
* If not provided, uses a shared portal container.
*/
container?: HTMLElement | null
/**
* Accessibility label for the toast region.
* @default "Notifications"
*/
label?: string
/* Additional CSS class name /
className?: string
/**
* Slot components for custom rendering.
* Use Toaster.Item, Toaster.Icon, etc.
*/
children?: ReactNode
}
`
Options passed to toast functions:
`tsx
interface ToastOptions {
/**
* Custom toast ID.
* Use to update an existing toast instead of creating a new one.
*/
id?: string
/**
* Background color variant.
* Independent from toast type (which controls the icon).
*/
variant?: ToastVariant
/* Description text shown below the title /
description?: React.ReactNode
/**
* HTML description content.
* Rendered with dangerouslySetInnerHTML.
* Use for rich text formatting (bold, colors, etc.)
*/
descriptionHtml?: string
/**
* Auto-dismiss duration in milliseconds.
* Set to 0 to disable auto-dismiss.
* Loading toasts never auto-dismiss regardless of this value.
*/
duration?: number
/* Custom icon to override the default type icon /
icon?: React.ReactNode
/**
* Action button configuration.
* Button automatically closes the toast after onClick.
*/
action?: {
label: React.ReactNode
onClick: () => void
}
/**
* Cancel/dismiss button configuration.
* Button automatically closes the toast.
*/
cancel?: {
label: React.ReactNode
onClick?: () => void
}
/* Callback when toast is dismissed (manually or by action) /
onClose?: () => void
/* Callback when toast is auto-dismissed by timer /
onAutoClose?: () => void
/**
* Whether toast can be swiped to dismiss.
* @default true
*/
dismissible?: boolean
}
`
`tsx
type ToastType = "default" | "info" | "success" | "warning" | "error" | "loading"
type ToastVariant =
| "default" // Dark neutral (gray)
| "accent" // Blue/brand color
| "success" // Green
| "warning" // Yellow/amber
| "error" // Red
| "assistive" // Pink/magenta
type ToastPosition =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right"
`
Wrapper for custom toast styling:
`tsx`
{/ Slot children /}
Custom icon renderer with access to toast type and default icon:
`tsx`
{(type: ToastType, defaultIcon: React.ReactNode) => {
// Return custom icon based on type, or use defaultIcon
return type === "success" ? "ā
" : defaultIcon
}}
Custom title styling:
`tsx`
Custom description styling:
`tsx`
Custom action buttons renderer:
`tsx`
{(
action: { label: React.ReactNode; onClick: () => void } | undefined,
cancel: { label: React.ReactNode; onClick?: () => void } | undefined,
close: () => void
) => (
<>
{action && (
onClick={() => {
action.onClick()
close()
}}
>
{action.label}
)}
{cancel && }
>
)}
Returns a toast API scoped to a specific Toaster. The API is cached per ID.
`tsx
const api = toast.use("my-toaster")
// Default toast - returns toast ID
const id = api("title", options?)
// Typed toasts - return toast ID
api.info("title", options?)
api.success("title", options?)
api.warning("title", options?)
api.error("title", options?)
api.loading("title", options?)
// Promise toast - returns the original promise
api.promise(promise, { loading, success, error })
// Dismissal
api.dismiss(id) // Dismiss specific toast
api.dismissAll() // Dismiss all toasts in this Toaster
`
`tsx
interface PromiseOptions
/* Shown while promise is pending /
loading: string | (ToastOptions & { title: string })
/* Shown when promise resolves /
success:
| string
| (ToastOptions & { title: string })
| ((data: T) => string | (ToastOptions & { title: string }))
/* Shown when promise rejects /
error:
| string
| (ToastOptions & { title: string })
| ((err: unknown) => string | (ToastOptions & { title: string }))
}
`
- Default duration: 5000ms (configurable via Toaster duration prop)duration
- Individual toasts can override with optionduration: 0
- Set for persistent toaststoast.loading()
- Loading toasts () never auto-dismiss
- Timer pauses when hovering over the toast stack
- Toasts stack with visual offset and opacity fade
- Hover over stack to expand and view all toasts
- Default limit: 3 visible toasts (configurable via visibleToasts)
- Maximum 100 toasts per Toaster (oldest are removed)
- Corner positions (left/right): Swipe horizontally
- Center positions: Swipe vertically (up/down)
- Direction-aware exit animation
- Shows remaining time before auto-dismiss
- Pauses when hovering
- Not shown for loading type or duration: 0
- Different styling for default vs compact layout
- default: Full-size with description support, fixed width (288px)
- compact: Single-line, dynamic width, no description, 40px height
- ARIA live regions for screen reader announcements
- role="status" for info toasts, role="alertdialog" for warnings/errorsaria-live="polite"
- for most toasts, "assertive" for errors
- Keyboard navigation: F6 to focus, Escape to dismiss
- Focus management within toast stack
``
toast/
āāā index.ts # Public exports
āāā toaster.tsx # Main Toaster component
āāā store.ts # Global state management
āāā tv.ts # Tailwind Variants styles
āāā types.ts # TypeScript definitions
āāā components/
āāā toaster-item.tsx # Individual toast renderer
āāā toaster-slots.tsx # Compound component slots
āāā toast-progress-bar.tsx # Progress indicator
- Keep messages concise and actionable
- Use clear action button labels ("Undo", "Retry", "View")
- Match toast type to semantic meaning
- Provide descriptions for context when needed
- Don't overwhelm users with notifications
- Use appropriate durations (2-10 seconds)
- Provide dismiss option for persistent toasts
- Use loading toasts for async operations
- Toast APIs are cached per toaster ID
- Use custom id option to update existing toasts
- Clean up persistent toasts when components unmount
- Maximum 100 toasts per toaster prevents memory issues
`tsxUploading ${file.name}...
async function handleUpload(file: File) {
const toastId = toast.use("app").loading()
try {
const result = await uploadFile(file)
toast.use("app").success("Upload complete", {
id: toastId, // Updates the existing toast
description: ${file.name} uploaded successfully,`
action: {
label: "View",
onClick: () => openFile(result.url),
},
})
} catch (error) {
toast.use("app").error("Upload failed", {
id: toastId,
description: error.message,
action: {
label: "Retry",
onClick: () => handleUpload(file),
},
})
}
}
`tsx
function MonitoringComponent() {
const toastIdRef = useRef
useEffect(() => {
toastIdRef.current = toast.use("app").info("Monitoring started", {
duration: 0,
})
return () => {
if (toastIdRef.current) {
toast.use("app").dismiss(toastIdRef.current)
}
}
}, [])
return
$3
`tsx
function handleSubmit(data: FormData) {
toast.use("app").promise(submitForm(data), {
loading: "Submitting form...",
success: "Form submitted successfully!",
error: (err) => Submission failed: ${err.message},
})
}
`Notes
- Uses Framer Motion for enter/exit animations
- Renders in a shared portal by default (cleaned up when all Toasters unmount)
- Global store persists across component re-renders
- HTML content (
descriptionHtml) should be sanitized if from user input
- SSR compatible (portal created on client only)
- Supports React 18+ with useSyncExternalStore`