pyreon

@pyreon/toast provides imperative toast notifications. Call toast() from anywhere in your app — event handlers, effects, async functions, even non-component modules — with no provider or context to set up. Mount a single <Toaster /> at your app root and it renders the active stack via a Portal, with CSS transitions, auto-dismiss timers, pause-on-hover-and-focus, and type-aware accessibility built in. Toasts are backed by a module-level signal, so creating one is a plain function call and updating one patches only the affected DOM node.

@pyreon/toaststable

Installation

npm install @pyreon/toast
bun add @pyreon/toast
pnpm add @pyreon/toast
yarn add @pyreon/toast

Peer dependency: @pyreon/runtime-dom — required because <Toaster /> JSX emits _tpl() calls. Declare it in your app's dependencies.

Quick Start

Two pieces: mount <Toaster /> once, then call toast() anywhere.

import { toast, Toaster } from '@pyreon/toast'

function App() {
  return (
    <>
      <Toaster position="top-right" />
      <button onClick={() => toast.success('Saved!')}>Save</button>
    </>
  )
}
Toast Notifications

Why imperative toasts?

Most reactive UI libraries make notifications a declarative tree of components you conditionally render. That works, but it forces every toast trigger to thread state up to a shared owner — exactly the prop-drilling toasts exist to avoid.

@pyreon/toast takes the opposite approach. The toast stack lives in a module-level signal, so toast() is a free function you can import and call from anywhere:

// in a store action
async function saveTodo(todo: Todo) {
  await api.save(todo)
  toast.success('Todo saved')
}

// in an event handler
<button onClick={() => toast('Copied to clipboard')}>Copy</button>

// in an effect
effect(() => {
  if (connection() === 'lost') toast.error('Connection lost')
})

The single <Toaster /> reads from that shared store. Because each toast is keyed by id and read field-by-field, updating or dismissing one toast patches only its own DOM node — there is no full re-render of the stack.

Imperative API — toast()

toast(message, options?) creates a toast and returns its id (a string). Pass the id to toast.update() or toast.dismiss() later.

import { toast } from '@pyreon/toast'

// Basic toast — defaults to type "info"
const id = toast('Something happened')

// The id lets you update or dismiss it later
toast.dismiss(id)

The message accepts a plain string or any VNodeChild (an SVG, an <Icon>, a composed fragment), so you are not limited to text:

toast(<span>Saved <strong>3 files</strong></span>)

Preset variants

Five shortcuts set the type for you (and toast.loading also makes the toast persistent):

toast.success('File uploaded')        // type: "success"
toast.error('Connection failed')      // type: "error"
toast.warning('Disk almost full')     // type: "warning"
toast.info('New version available')   // type: "info"

// Persistent loading toast (duration: 0 — no auto-dismiss)
const id = toast.loading('Uploading...')

Each preset takes the same options as toast() (minus type, which it sets). toast.loading additionally fixes duration to 0, so its options omit both type and duration.

Toast options

Every toast() call accepts an options object. The full ToastOptions shape:

toast('Item deleted', {
  type: 'warning',
  duration: 8000,
  description: 'You can undo this for 30 seconds.',
  icon: <TrashIcon />,
  dismissible: true,
  action: {
    label: 'Undo',
    onClick: () => restoreItem(id),
  },
  onDismiss: () => console.log('gone'),
})
OptionTypeDefaultDescription
type'info' | 'success' | 'warning' | 'error''info'Toast variant — controls styling and live-region urgency.
durationnumberToaster's default (4000)Auto-dismiss delay in ms. 0 = persistent (no auto-dismiss).
descriptionstring | VNodeChildSecondary line rendered under the message.
iconVNodeChildLeading icon (any VNode — an SVG, <Icon>, etc.).
dismissiblebooleantrueWhether the toast shows a × dismiss button.
action{ label: string; onClick: () => void }Action button rendered beside the message (undo/retry patterns).
onDismiss() => voidCalled when the toast is dismissed — manually, by the action, by timeout, or when evicted by the queue cap.

Description, icon, and action

A toast can carry a secondary line, a leading icon, and an action button at the same time:

toast.success('Backup complete', {
  description: '2.4 GB · 1,204 files',
  icon: <CheckCircleIcon />,
  action: { label: 'View', onClick: () => openBackup() },
})

description and icon accept any VNodeChild, so the description can itself be rich markup. The icon, action, and dismiss button are read once when the toast mounts (they are immutable for a toast's lifetime); only message, type, duration, and description can change via toast.update().

Updating a toast — toast.update()

toast.update(id, updates) modifies an existing toast in place. You can change the message, type, duration, and description. Updating restarts the auto-dismiss timer using the new (or unchanged) duration.

const id = toast.loading('Saving...')

try {
  await saveData()
  toast.update(id, { type: 'success', message: 'Done!', duration: 4000 })
} catch {
  toast.update(id, { type: 'error', message: 'Save failed' })
}

This is the canonical loading-to-result pattern: start a persistent loading toast, then promote it to success or error on completion. Because the update touches the same toast id, the visible toast morphs in place rather than appearing as a second notification.

Promise pattern — toast.promise()

toast.promise(promise, messages) shows a persistent loading toast and auto-transitions it to success or error when the promise settles. It returns the original promise so you can chain or await it.

toast.promise(saveDocument(), {
  loading: 'Saving...',
  success: 'Document saved!',
  error: 'Failed to save',
})

success and error can be functions that receive the resolved value / rejection reason, for dynamic messages:

toast.promise(fetchUser(id), {
  loading: 'Loading user...',
  success: (user) => `Welcome, ${user.name}!`,
  error: (err) => `Error: ${err instanceof Error ? err.message : 'unknown'}`,
})

Because it returns the promise, you can keep using the result:

const user = await toast.promise(fetchUser(id), {
  loading: 'Loading...',
  success: 'Loaded',
  error: 'Failed',
})
// `user` is the resolved value — the toast handling is transparent

Under the hood, the loading toast is created with duration: 0 (persistent) and then updated on settle to a success/error toast that uses the Toaster's default duration.

Dismissing — toast.dismiss() (soft) and toast.remove() (hard)

const id = toast('Dismissable')

toast.dismiss(id)   // SOFT: play the leave animation, then remove
toast.dismiss()     // soft-dismiss all toasts

toast.remove(id)    // HARD: remove instantly, no leave animation
toast.remove()      // hard-remove all toasts

dismiss is the default you want — the toast plays its CSS leave transition (fade + collapse in place) and is removed once the animation finishes; its siblings reflow up smoothly. onDismiss fires immediately in both cases, and the toast's auto-dismiss timer is cleared. This is the same dismiss (soft, animated) / remove (hard, instant) split react-hot-toast uses — reach for remove when you need a toast gone right now (replacing it, or tearing down on unmount).

Auto-dismiss (a toast's duration elapsing) goes through the soft dismiss path too, so timed-out toasts animate out the same way manual dismissals do.

The Toaster component

<Toaster /> is the render container. Mount it once at your app root. It renders the active stack into a Portal (a <div> host appended to document.body), injects its CSS once, runs the auto-dismiss timers, and manages pause-on-hover-and-focus.

import { Toaster } from '@pyreon/toast'

function App() {
  return (
    <>
      <Toaster
        position="bottom-right"
        max={3}
        gap={12}
        offset={24}
        duration={5000}
      />
      <MyApp />
    </>
  )
}

Toaster props

PropTypeDefaultDescription
positionToastPosition'top-right'Corner the stack renders in (see Positions).
maxnumber5Maximum number of toasts shown at once — the most recent max are visible.
gapnumber8Gap between stacked toasts, in pixels.
offsetnumber16Distance from the viewport edge, in pixels.
durationnumber4000Default auto-dismiss duration (ms) for toasts that don't set their own. 0 makes toasts persistent by default.

The duration prop sets the app-wide default that any toast without an explicit duration uses. Because the store is module-level, the Toaster writes this default on setup and toast() reads it for new toasts.

Positions

ToastPosition is one of six corners:

'top-left'    | 'top-center'    | 'top-right'
'bottom-left' | 'bottom-center' | 'bottom-right'

Bottom positions stack in reverse (newest nearest the edge) so the freshest toast is always closest to its corner.

The queue cap

The store caps the active stack at a hard limit (well above any sensible max). When the cap is exceeded — for example a runaway loop or a WebSocket surfacing every message as a toast — the oldest toast is evicted (its timer cleared and onDismiss fired) and the newest wins. Past a couple dozen visible toasts the user can't read them anyway, so this keeps memory and per-write cost bounded. The max prop controls how many are visible; the cap is a backstop against unbounded growth.

Pause on hover and focus

Auto-dismiss timers pause when the pointer enters the toast region and resume when it leaves — so a toast won't vanish while the user is reading or reaching for its action button. The same applies to keyboard focus: tabbing into a toast (e.g. onto its close button) pauses the timers via the bubbling focus events, and they resume on blur. This is built into <Toaster /> with no configuration.

The pause is precise: it tracks the remaining time, so a toast that was 1 second from dismissal still has 1 second left when the cursor leaves.

Action buttons

Add an action button for undo/retry flows. The button renders beside the message and runs onClick when pressed:

function deleteItem(item: Item) {
  removeFromList(item)
  toast('Item deleted', {
    type: 'warning',
    duration: 6000,
    action: {
      label: 'Undo',
      onClick: () => restoreToList(item),
    },
  })
}

A common pattern is to dismiss the toast inside the action so it disappears once the user acts:

const id = toast('Message archived', {
  action: {
    label: 'Undo',
    onClick: () => {
      unarchive()
      toast.dismiss(id)
    },
  },
})

Rich content

Because message, description, and icon all accept VNodeChild, toasts are not limited to plain strings:

toast.info(<span>Updated to <code>v2.1.0</code></span>, {
  icon: <SparkleIcon />,
  description: <a href="/changelog">See what's new →</a>,
})

Accessibility

Toast accessibility is type-aware, matching the urgency of the message:

  • Error and warning toasts use role="alert" — an assertive live region that interrupts the screen reader to announce immediately.

  • Info and success toasts use role="status" — a polite live region that announces at the next pause.

Each toast also sets aria-atomic="true" so the whole toast is announced as one unit. The role implies its own aria-live, so the container does not add aria-live — doing so would double-announce every toast. The container is a labeled landmark: <section aria-label="Notifications">. The dismiss button has aria-label="Dismiss", and a decorative icon is hidden from assistive tech (aria-hidden).

An info → error update also upgrades the live-region urgency, because role is reactive to the toast's current type.

Animation

Toasts animate on both enter and leave via CSS transitions — no external animation library. A new toast fades + slides in (entering → visible, promoted on the next frame). A dismissed toast fades and collapses in place (--exiting, max-height → 0) for ~200ms before it is removed, so its siblings reflow up smoothly rather than snapping. The store owns this timing (dismiss schedules the removal), so the leave still completes even without a mounted animation runtime. Use toast.remove() to skip the animation.

Scope — deliberate non-goals

@pyreon/toast is signal-native, framework-integrated, and a11y-first — not a port of a React toast library. A few features common elsewhere are intentional non-goals:

  • Swipe-to-dismiss / draggable toasts (sonner, react-toastify) — a touch-gesture affordance; use the × button (dismissible) or an action.

  • Collapsed stacking with hover-to-expand (sonner's signature look) — an opinionated visual; max controls how many render at once.

  • Per-toast position — position is a <Toaster> prop; mount two Toasters if you need two corners.

Rich/custom content is fully supported the idiomatic way: message, description, and icon all accept any VNodeChild.

SSR

<Toaster /> returns null on the server, so it is safe to include in SSR layouts — nothing renders until the client mounts. toast() calls during SSR are harmless: they write into the module-level signal, which is simply discarded server-side (no DOM, no Portal). Notifications begin rendering once the Toaster mounts on the client.

TypeScript

All public types are exported from the package root:

import type {
  Toast,
  ToastOptions,
  ToastPosition,
  ToastType,
  ToasterProps,
  ToastPromiseOptions,
  ToastState,
} from '@pyreon/toast'
  • ToastType'info' | 'success' | 'warning' | 'error'

  • ToastPosition — the six corner literals

  • ToastOptions — the options object accepted by toast() and the preset variants

  • ToasterProps — the <Toaster /> props

  • ToastPromiseOptions<T> — the { loading, success, error } shape for toast.promise(), generic over the resolved value T

  • Toast — the full internal toast record (id, message, type, state, timers, …)

  • ToastState — the lifecycle phase: 'entering' | 'visible' | 'exiting'

Common mistakes

API Reference

toast(message, options?)

SignatureReturnsDescription
toast(message: string | VNodeChild, options?: ToastOptions)stringCreate a toast. Returns its id for later update/dismiss. Defaults to type 'info'.

The toast function also exposes these methods:

MethodSignatureReturnsDescription
toast.success(message, options?: Omit<ToastOptions, 'type'>)stringCreate a success toast.
toast.error(message, options?: Omit<ToastOptions, 'type'>)stringCreate an error toast.
toast.warning(message, options?: Omit<ToastOptions, 'type'>)stringCreate a warning toast.
toast.info(message, options?: Omit<ToastOptions, 'type'>)stringCreate an info toast.
toast.loading(message, options?: Omit<ToastOptions, 'type' | 'duration'>)stringCreate a persistent (duration: 0) info toast. Returns its id for later update.
toast.update(id: string, updates: { message?, type?, duration?, description? })voidModify an existing toast in place. Restarts its timer. No-op if the id is gone.
toast.dismiss(id?: string)voidSoft: dismiss one toast by id (or all) — plays the leave animation, then removes. Fires each toast's onDismiss immediately.
toast.remove(id?: string)voidHard: remove one toast by id (or all) instantly, no leave animation. Fires onDismiss if the toast wasn't already dismissed.
toast.promise<T>(promise: Promise<T>, opts: ToastPromiseOptions<T>)Promise<T>Show a loading toast that transitions to success/error on settle. Returns the original promise.

<Toaster />

SignatureReturnsDescription
Toaster(props?: ToasterProps)VNodeChildRender container for the toast stack. Mount once at the app root. Returns null on the server.

See Toaster props for the full ToasterProps table.

Types

TypeDescription
ToastOptionsOptions for toast() and presets — type, duration, description, icon, dismissible, action, onDismiss.
ToasterProps<Toaster /> props — position, max, gap, offset, duration.
ToastPromiseOptions<T>{ loading, success, error } for toast.promise(). success/error may be functions of the resolved value / rejection reason.
ToastType'info' | 'success' | 'warning' | 'error'.
ToastPosition'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'.
ToastThe full toast record stored internally (id, message, type, state, timers, …).
ToastStateLifecycle phase: 'entering' | 'visible' | 'exiting'.
Toast