Issue №126 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
frontend · 4 min read
frontendJune 1, 2026 · 4 min

headless-toast

Tiny, fully-accessible toast primitive

headless-toast — live demo screenshot
01
frontend

I built a toast library that renders nothing

Every time I add toasts to a project, the same thing happens. I install a popular library, fire off toast.success('Saved!'), and it looks… fine. Then design wants the corners a little rounder, the icon swapped, the success colour to match the brand, the exit animation to feel snappier. An hour later I'm three !important overrides deep, reverse-engineering someone else's class names and shadow DOM.

The problem isn't any one library. It's that toast libraries bundle two very different jobs into one package: the behaviour (when does a toast appear, how long does it live, how is it announced to a screen reader) and the presentation (the markup, the CSS, the animations). The behaviour is genuinely fiddly and worth reusing. The presentation is the part I always want to own.

So I split them. headless-toast is the behaviour, and nothing else.

What it actually does

The whole public surface is small. There's an imperative API:

toast.success('Saved!');
toast.error('Could not connect');
const id = toast.loading('Uploading…');
toast.success('Done', { id }); // updates that same toast in place

And a hook that hands you the data plus two prop getters:

const { toasts, getRegionProps, getToastProps, dismiss } = useToaster();

return (
  <div {...getRegionProps()}>
    {toasts.map((t) => (
      <div key={t.id} {...getToastProps(t)}>
        {t.message}
        <button onClick={() => dismiss(t.id)}>×</button>
      </div>
    ))}
  </div>
);

That's the entire integration. The library never calls createPortal, never injects a stylesheet, never decides where a toast lives on screen. You map over an array and render your own components. If your design system already has a Card, your toast is a Card.

The payoff is that the bits that are easy to get wrong are handled for you. getToastProps returns role="status" with aria-live="polite" for normal toasts, and role="alert" with aria-live="assertive" for errors, plus aria-atomic="true" so the whole message is read out rather than a diff. getRegionProps wires hover, focus and blur so the auto-dismiss timer pauses while someone is actually reading. You get accessible-by-default without thinking about it, and you still own every pixel.

The one genuinely interesting bit: pausable timers

Auto-dismiss sounds trivial — setTimeout(dismiss, 4000) — until you add pause-on-hover. Now you can't just clear and re-create the timer, because a fresh setTimeout would restart the full duration. Hover for a second three times and a 4-second toast lives forever.

The fix is to track how much time is actually left. Each toast's timer keeps a startedAt timestamp and a remaining budget:

const pause = (entry) => {
  clearTimeout(entry.handle);
  entry.remaining = Math.max(0, entry.remaining - (Date.now() - entry.startedAt));
  entry.paused = true;
};

const resume = (entry) => {
  entry.startedAt = Date.now();
  entry.handle = setTimeout(() => dismiss(id), entry.remaining);
  entry.paused = false;
};

Pause subtracts the elapsed time from the budget; resume schedules a new timer for whatever's left. A toast that's been hovered three times still dismisses at the right moment. This bookkeeping lives in a plain framework-agnostic store — no React in sight — which made it delightful to test. The whole timer engine is exercised with fake timers, no DOM required, and the suite sits at 100% branch coverage.

React's footgun I deliberately avoided

The other decision worth calling out is what I didn't do. The library tracks its toast list with useSyncExternalStore and reads prefers-reduced-motion inside a single useEffect. It's tempting to set up browser observers in a ref callback and tear them down in an effect — but under React 19's StrictMode that splits the lifecycle in two: the cleanup runs on the simulated unmount, the ref callback doesn't re-fire on the remount, and your observer silently dies in development. Keeping all subscription setup and teardown inside the same effect sidesteps the whole class of bug. jsdom won't catch it for you, so it's worth being disciplined about up front.

Try it

pnpm add github:kea0811/headless-toast
# or, once it's on npm:
pnpm add headless-toast

There's a live demo with the reference styling, a playground, and the five-second integration snippet. It's ~1.8 kB gzipped, ships ESM + CJS + types, and depends on nothing but React.

What's next

A few things on my list: a toast.promise() helper that wraps the loading → success/error flow into one call, optional keyboard navigation through the toast region for power users, and a couple of copy-paste reference renderers (a minimal one and a fancier animated one) so you can start from something rather than a blank div. But the core is intentionally finished — it's a primitive, and the best primitives stay small.

If you've ever fought a toast library's CSS, I'd love for you to try rendering your own instead.


End of essay

About the author
Er An Khoo