Progress toast
A singleton toast that updates itself as work progresses. Uses upsert() so consecutive calls mutate the same instance.
The Callable
declared once, mounted in the React treeimport { createCallable } from 'react-call'
interface Props { message: string percent?: number}
export const Toast = createCallable<Props, void>( ({ call, message, percent }) => ( <div role="status" aria-live="polite" className="pointer-events-none fixed bottom-6 right-6 z-50" > <div className="pointer-events-auto min-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-4 shadow-2xl"> <div className="flex items-start justify-between gap-3"> <p className="text-sm text-[var(--color-fg)]">{message}</p> <button type="button" onClick={() => call.end()} aria-label="Dismiss" className="-mr-1 inline-flex h-7 w-7 items-center justify-center rounded-md text-base leading-none text-[var(--color-fg-subtle)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-fg)]" > × </button> </div> {typeof percent === 'number' && ( <div className="mt-3 h-1 overflow-hidden rounded-full bg-[var(--color-bg-muted)]"> <div className="h-full bg-[var(--color-accent)] transition-all duration-200" style={{ width: `${Math.min(100, Math.max(0, percent))}%` }} /> </div> )} </div> </div> ),)Toast.displayName = 'Toast'The Root
mounted in your app tree, onceimport { Toast } from './Toast'import { DownloadButton } from './DownloadButton'
export default function App() { return ( <> <Toast /> <DownloadButton /> </> )}The caller
anywhere in your app, imperativeimport { useState } from 'react'import { Toast } from './Toast'
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
export const DownloadButton = () => { const [running, setRunning] = useState(false)
const handleClick = async () => { setRunning(true) // Capture the singleton promise so the loop can abort if the user // dismisses the toast via the × button mid-progress. Without this, // each upsert after a dismiss would create a brand-new instance and // the button would stay disabled until the loop finishes. let cancelled = false const session = Toast.upsert({ message: 'Starting download…', percent: 0 }) session.then(() => { cancelled = true })
for (let i = 10; i <= 100; i += 10) { await sleep(150) if (cancelled) { setRunning(false) return } Toast.upsert({ message: `Downloading… ${i}%`, percent: i }) } Toast.upsert({ message: 'Done!', percent: 100 }) await sleep(800) Toast.end() setRunning(false) }
return ( <button type="button" disabled={running} onClick={handleClick} className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)] disabled:opacity-50" > {running ? 'Downloading…' : 'Start download'} </button> )}Why upsert and not call
Each Toast.upsert(props) either creates the singleton or updates its
props. The returned promise is the same reference across the lifetime
of the singleton — you can await Toast.upsert(…) once and your code
resumes when the toast finally closes, even if its message changes a dozen
times in between.
Because upsert keeps a single instance, every Toast.upsert(…) —
wherever it’s called from — targets that same toast: a second caller
updates the existing one instead of opening another. That’s exactly what a
progress indicator wants — there should only ever be one. When each event
should get its own toast, reach for .call() instead and the Root stacks
them (that’s error-banner).
When to reach for it
- Progress indicators where the message updates as work advances.
- A “saving…” → “saved” status pill.
- A connection state badge that may flip many times during a session.