Toasts

Dispatch transient notifications to the parent portal instead of rendering them inside your iframe.

A portal block runs in an <iframe>. A toast shown inside it renders against the iframe, not the page: it's clipped to the iframe rectangle and stacks in the wrong place relative to everything else on the portal.

toast moves the rendering up. Your block calls it, the parent portal renders the toast in its own toaster over the whole page, and interaction (action clicks, dismissals) comes back to your block. Only data crosses the boundary, never markup; the callbacks stay in the block.

The API mirrors sonner for the serializable subset, so a block can swap import { toast } from 'sonner' for import { toast } from '@epilot/spark-sdk' and keep its calls.

Try it

These fire against the same sonner-based toaster the portal renders with, so this is what your block's calls produce. In a block you'd import toast from @epilot/spark-sdk; here the demo uses the UI package directly.

toast(title, options?)

import { toast } from '@epilot/spark-sdk'

toast.success('Wallbox switched to manual mode')

Returns the toast id synchronously. toast(title) shows a default toast; toast.success / .info / .warning / .error / .loading set the type; toast.message is an alias of toast.

Calling any of them again with an existing id upserts: it updates that toast in place instead of stacking a new one.

type ToastOptions = {
  id?: string
  description?: string
  duration?: number // ms; Infinity = sticky
  closeButton?: boolean
  dismissible?: boolean
  action?: { label: string; onClick?: () => void; dismissOnClick?: boolean }
  cancel?: { label: string; onClick?: () => void }
  onDismiss?: (toast: ActiveToast) => void
  onAutoClose?: (toast: ActiveToast) => void
}

Every visible string comes from the block; the host owns no translations (same as dialogs, see Localization).

Actions

An action renders a button on the toast. sonner's event.preventDefault() can't cross the postMessage boundary, so keep-open intent is declared up front with dismissOnClick (default true):

toast('Wallbox switched to manual mode', {
  action: {
    label: 'Undo',
    onClick: () => switchBackToSmart(),
    dismissOnClick: false, // keep the toast open after the click
  },
})

Promises

toast.promise is sugar over upsert: the promise is awaited inside your block, and each phase (loading → success/error) is sent as an ordinary toast under one id. The host never sees a promise.

const { unwrap } = toast.promise(switchToManual(device), {
  loading: 'Switching to manual mode…',
  success: (d) => `${d.name} switched to manual mode`,
  error: (e) => `Couldn't switch: ${String(e)}`,
})

await unwrap() // settles like the original promise; rejects if it did

The success/error message can be a string or a (sync or async) function of the resolved value, and may return the extended { message, ...options } form (the message key matches sonner). If a phase's option is omitted, that phase dismisses the toast instead. The return is a boxed string (sonner's shape) whose value is the id and which carries unwrap().

Dismissing

toast.dismiss(id) // dismiss one
toast.dismiss() // dismiss all of this block's toasts

toast.dismiss returns the dismissed id. It fires nothing locally; the host echoes the removal, which is what runs your onDismiss. getToasts() returns the block's currently active toasts.

Not supported

Everything non-serializable or presentational is excluded at the type level, so using it is a compile error, not a silent no-op:

toast.custom, JSX / ReactNode anywhere (titles, descriptions, and labels are string), icon, position, richColors, invert, unstyled, style / className / classNames, useSonner, toast.getHistory. These are host-owned presentation or can't cross the postMessage boundary. Two ergonomic deviations from sonner: ids are string-only (sonner allows string | number), and toast.promise's loading and options argument are required.

Wire protocol

You don't need this to use toast; it's here for understanding and debugging. Four messages flow over the postMessage bridge:

// block → parent, on toast() / toast.success() / … (upsert by id)
{ "source": "spark-bridge", "event": "spark-block:show-toast", "id": "…", "payload": { "type": "success", "title": "Wallbox switched to manual mode" } }

// block → parent, on toast.dismiss(id?); no id dismisses all of this block's toasts
{ "source": "spark-bridge", "event": "spark-block:dismiss-toast", "id": "…" }

// parent → block, when the user clicks the action or cancel button
{ "source": "portal", "event": "spark-bridge:toast-control", "id": "…", "control": "action" }

// parent → block, on every removal (runs onDismiss / onAutoClose)
{ "source": "portal", "event": "spark-bridge:toast-dismissal", "id": "…", "reason": "user" }

show-toast is an upsert: an unknown id shows a new toast, a known id updates it in place. toast-dismissal is sent exactly once per removal with a reason: user (close button / swipe), auto (duration elapsed), action (removed by a control click), or programmatic (the block dismissed it). The SDK routes auto to onAutoClose and every other reason to onDismiss.

The host

You don't build a host. The parent portal (end-customer-portal) runs one toaster and a handler that listens for spark-block:show-toast / spark-block:dismiss-toast, checks the origin, scopes every toast to the block that sent it (a block can only update or dismiss its own), renders over the page, and echoes control and dismissal back to the block that asked. Rendering with sonner maps 1:1, but the host owns presentation (position, theming, icons) either way.

The bridge is inert until the portal ships support: a toast dispatched to a host without it is a silent no-op, with no local fallback and no capability handshake. toast.promise still runs and unwrap() still settles; only the visible toast is absent. This mirrors how dialogs fail safe until the host catches up.

On this page