embertoast 2026 Updated
A toast library you can call from anywhere, including outside React
An open source notification primitive: a framework-agnostic store with an imperative facade, a promise that morphs one toast in place, and an accessibility model that announces without ever taking focus.
- Role
- Author, open source
- Timeline
- 2026, published on npm
- Team
- Solo build
- Stack
- TypeScript, React, tsup, Vitest, Playwright
Overview
One function, one renderer, no context between them
embertoast is a toast library for React with one idea underneath it: the thing that produces a notification and the thing that draws it should not be the same thing. Calling code uses an ordinary function, from anywhere, including a module that has never heard of React. A single mounted component subscribes to a store and renders whatever is in it.
That split is not a style preference. It decides what the library can do: whether an error handler in a fetch wrapper can raise a toast, whether a promise can morph through its own lifecycle without being replaced, and whether a renderer for another framework is a new subscriber or a rewrite.
- 0
- Runtime dependencies in either package
- 4.5 kB
- Size budget for the React package, enforced in CI
- 65
- Tests across the store and the renderer
Problem
A hook cannot be called from the places that need it most
The usual shape for this is a hook. A hook can only run inside a component during render, which means every notification has to originate from a component and thread context or a ref down to whatever draws it. The places that most want to raise a notification are exactly the places a hook cannot reach: a request interceptor, a websocket handler, a retry helper, a plain utility three files from any component.
The second problem is the promise. Nearly every library models a promise as three separate toasts, so a loading card disappears and a success card slides in behind it. The user sees a flit. What they should see is the same card changing its mind.
- Announcing is not the same as showingA toast that appears silently to a screen reader is not a notification. One that steals focus to be heard interrupts whatever the person was doing.
- Three animations, one elementThree gestures want the same element at the same time, and run naively they overwrite each other.
- Right to left is not a stylesheetMost libraries leave mirroring to the consumer, so a right-aligned toast in an Arabic layout arrives on the wrong side of the screen and swipes the wrong way.
- Timers lie when nobody is watchingA toast whose four seconds elapse in a backgrounded tab was never seen, and hover has to actually stop the clock rather than restart it.
Discovery
What the split forces you to decide
Once the store is a module-level singleton and the component is only a subscriber, a question appears immediately: where does a leave animation live? The store cannot own it. An exiting flag in the store would mean a dependency-free engine had to know about animation events, motion preferences and fallback timers, and worse, the snapshot would lie for a few hundred milliseconds about what is on screen.
So the store removes synchronously and stays honest, and the renderer grows a small presence layer that diffs the live list against the previous one. An id that just left is held mounted, marked as exiting, and retired when the animation reports it finished, with a timer as a backstop because a backgrounded tab may never fire that event at all. Re-adding the same id cancels an exit in flight. Under reduced motion the whole phase is skipped.
The other discovery was about directions. Writing positions as screen edges means writing mirroring twice. Writing them as the inline axis and putting the box geometry in logical properties means right to left is the same code path, and the only work left is reading the document direction and watching it for changes at runtime.
System design
A store that knows nothing about the screen
Nothing to the left of the store knows what a pixel is. Calling code talks to a facade, the facade talks to a store, and the store notifies subscribers. Everything to the right of the store is rendering, and everything to the left of it is a function call that could have come from anywhere.
Calling code
Core
Renderer
The page
Calling code
- A componentThe ordinary case
- toast()
- Any other moduleInterceptor, worker, utility
- toast()
Core
- toast()success, error, loading, promise, custom
- Store
- StoreIds, timers, pause, queue, pub/sub
- Subscription hook · notify
Renderer
- ToasterPositions, stacking, reflow, live region
- Presence layer
- Toast item · reflow
- Live region
- Subscription hookConcurrent-safe external store read
- Toaster
- Presence layerHolds a dismissed toast for its exit
- Toast item
The page
- Toast itemCountdown, swipe, role, exit
- Store · pause, resume, dismiss
- Live regionAnnounces, never takes focus
Solid lines are calls. The dashed line is the subscription: the renderer is told that something changed and reads the new snapshot itself, which is what keeps the store free of React and makes a renderer for another framework a new subscriber rather than a rewrite. The dashed line back to the store is a toast item reporting a pause on hover, a resume on leave, or a dismiss on click.
The promise method is the only part of the facade with real logic. It opens one toast pinned open, keeps the id, awaits the promise, and on settle updates that same id with the new type and content. One id, one node, from loading straight through to success. The morph is the whole point: nothing is replaced, so nothing flits.
Reflow is the hot path, because when a toast leaves, the survivors have to move and they have to do it at frame rate. The renderer measures where each card is, lets layout settle, measures again, applies the inverting transform and then releases it, so only transform and opacity ever animate and the browser never lays out per frame. The collapsed deck uses scale, the swipe uses its own transform, and the reflow uses translate, which is why three gestures can share one element without one cancelling another.
Timers track elapsed time rather than being recreated, so a hover that pauses at 2.3 seconds resumes with 1.7 left rather than starting over. They also pause when the tab is hidden or the window loses focus, because four seconds that elapse while nobody is looking were never a notification.
| Call | Why, concretely | What it costs | How that cost is paid |
|---|---|---|---|
| A module-level store rather than a hook or context | A notification is raised from wherever the thing went wrong, which is usually not a component. A singleton store makes the producer independent of React entirely, and lets many modules feed one renderer. | A singleton is global state, and server rendering has to be given a snapshot rather than allowed to touch it. | The React bridge is the framework's own external-store API, which is concurrent-safe and has a server snapshot, so there is no tearing and no window object on the server. |
| Synchronous removal, with the exit animation in the renderer | The store should describe what is live. A dismissed toast that lingers in the snapshot for the length of an animation makes every consumer of that snapshot slightly wrong. | The renderer needs its own presence bookkeeping, and a ghost could in principle be stranded if the animation event never arrives. | The presence layer retires on the animation event with a timer as a backstop, and skips the phase entirely under reduced motion, so a hidden tab cannot leave a card behind. |
| Positions named by the inline axis, geometry in logical properties | Right to left becomes the same code path as left to right instead of a second stylesheet the consumer maintains, and the swipe axis mirrors with it. | The names are less obvious to someone thinking in screen edges, and the direction has to be resolved and watched at runtime. | The default reads the document direction and observes changes, so a language switch mid-session mirrors without a remount, and the styled layer stays optional. |
| CSS with custom properties rather than a style runtime | Zero dependencies, nothing to execute per toast, and a consumer can retheme without learning an API. It also keeps the package tree-shakeable. | No dynamic styling escape hatch, and consumers who want radical changes write their own markup. | The headless path is first class: the store and the facade carry no styling at all, and the stylesheet is a separate import that a headless user never loads. |
The four calls that shaped the library. Every one buys something specific and charges for it somewhere else.
Correctness
The parts that are easy to get quietly wrong
Accessibility is treated as a feature rather than a pass at the end. Each renderer owns one live region, and toasts are inserted into it so assistive technology announces them without the visual container deciding announcement order. Politeness follows severity: errors and warnings interrupt, everything else waits its turn. Focus is never moved, because a notification that takes the cursor out of a form is a bug wearing a feature's clothes, and an interactive toast is reachable by keyboard without trapping anyone in it.
Motion is a preference, not a default. Under reduced motion the enter, exit and reflow transitions collapse to nothing, and the presence layer skips its exit phase rather than waiting for an animation that will never play. The library is fully usable with every animation off, which is the actual test of whether motion was decoration or structure.
The tests split along the same seam as the code. The store's tests are plain unit tests with no DOM: ids, queue promotion, pause and resume arithmetic, the promise lifecycle. The renderer's tests cover what only exists once there is a document: the live region and its politeness, the mirroring, presence and reflow behaviour, and the swipe. The gesture is the one thing a unit test cannot honestly claim, so it is verified in real browsers, including mobile Safari, where pointer events behave least like the others.
There is a small discipline in the overflow rule that is easy to miss. Only a fixed number of toasts render per position and the rest queue, so a burst of twenty cannot bury the page, and promotion is first in first out so the order is something you can reason about rather than a race.
Decisions
What I deliberately did not build
Most of the restraint in this library is about what the core is allowed to know. It does not know what a pixel is, whether a document exists, or that animations have durations. Every time something wanted to leak into it, that was the signal it belonged in the renderer instead, and the presence layer exists because of exactly one such attempt.
I also left out the temptation to ship adapters for other frameworks. The store makes them possible, which is the part worth claiming; shipping renderers I would not maintain would be a worse promise than not shipping them at all.
Lessons
What a small library teaches about boundaries
The useful lesson is how much a single boundary decides. Splitting the producer from the renderer answered questions I had not asked yet: where exit animations live, why the promise can morph, how another framework would attach, and what the core is allowed to import. When a design keeps answering later questions without being revisited, that is usually a sign the seam was cut in the right place.
The other lesson is that the unglamorous requirements are the ones that make a component library trustworthy. Announcing correctly, honouring reduced motion, pausing a timer when the tab is hidden and mirroring properly are not features anyone lists when they pick a toast library, and they are exactly what someone notices when they are missing.


