DEV Community

Cover image for I Built a Toast Notification. It Turned Into a Whole Accessibility System.
Che' J. Holloway
Che' J. Holloway

Posted on

I Built a Toast Notification. It Turned Into a Whole Accessibility System.

TL;DR:

I set out to build one accessible toast component. It turned into tatami-a11y, a framework-agnostic system of 6 shared primitives and 16 components, backed by 700+ unit tests plus real-browser Storybook tests audited with axe-core, zero violations, zero runtime dependencies. Here's how a weekend project turned into the thing I'd actually put in front of a hiring manager.

Every frontend dev has built a toast component at some point. It usually takes an afternoon. A div, an opacity transition, a setTimeout. Ship it, move on.

Mine took three days. Then it grew a second library. Then that library grew into a whole system of shared primitives with more than a dozen components sitting on top of them, including a date picker and a command palette, two of the more miserable patterns in the whole ARIA spec. I've been doing this long enough that my first real love was ActionScript, back when "interactive" meant a Flash banner ad with a mouse-follow effect, so I should know better than to underestimate how far a "quick component" can wander. I still fell for it.

The itch

I was at an American company headquartered in the Netherlands, where GDPR wasn't a checkbox, it was a running conversation, and accessibility got pulled into that conversation more than once. Somewhere in the middle of that work I went looking for an "accessible" toast component so I wouldn't have to build one myself.

Plenty of options were labeled that way. Not one of them survived actual contact with a keyboard or a screen reader. One aria-live="polite" region doing double duty for everything, so a "changes saved" message and a failed-payment error got announced with the same flat urgency. If you tabbed away instead of hovering with a mouse, some of them didn't announce anything at all. Every one of these looked fine on a Lighthouse score. None of them held up for five minutes with a real user.

So I built my own. Called it kanpai-toast (kanpai, as in the toast you raise with a drink, and yes, I noticed the pun before I committed to it). Two live regions instead of one, split by urgency, so an error interrupts and a confirmation doesn't. Pause on hover and on focus, because a keyboard user tabbing onto the close button never fires mouseenter. Escape dismisses. Reduced motion is respected. Timers actually get cleared instead of quietly piling up the moment someone's retry logic fires in a loop.

The pattern under the pattern

Here's where it stopped being "a toast component" for me. Announcing something without stealing focus. Restoring focus to the right place when the original element might already be gone. Surviving a hot reload in dev without duplicating a global listener. None of that is specific to toasts. A modal runs into the same three problems. So does a dropdown. So does a tooltip.

Most component libraries solve each of these once per component, a little bit wrong, over and over. That's honestly the real mechanism behind most accessibility bugs: the tenth time someone reimplements "restore focus on close" from scratch is exactly when they forget the stale-node case and quietly strand a keyboard user on <body>.

So I pulled the shared mechanics out into their own thing and called it tatami-a11y. Tatami mats are cut to a standard size so any mat fits any room built to code. Felt like the right name for the part everything else gets laid on top of.

Diagram comparing reimplementing accessibility per component vs. building on shared tested primitives

What's actually in it now

The shared layer has grown since I first wrote this up. Alongside the original live-region announcer, focus trap, focus restoration, and reduced-motion detection, there's now a dedicated roving-tabindex primitive, the piece that handles arrow-key list navigation with wrapping. I went back and retrofitted the components that predate it (dropdown, menu button, combobox) onto that shared primitive instead of leaving them on their own one-off implementations, because leaving them alone would've been exactly the mistake this whole project exists to prevent.

That primitive also made a few harder components possible that weren't in the original lineup: a tree view with real nested keyboard navigation, and a reorderable list with a keyboard-accessible drag equivalent, since native HTML drag events have no keyboard story at all. That second one might be the single most broken accessible pattern still on the web, and I still haven't seen anyone else fix it properly.

The date picker and command palette are still the two I'd put in front of a hiring manager first. If you've ever built a date picker, you already have a very specific, tired relationship with the phrase "edge case." Mine has a working grid with roving tabindex, aria-current on today, aria-selected on whatever's chosen, a live region that announces the month change, and it never once drops focus doing any of it. The command palette tracks a highlighted result through aria-activedescendant without ever moving real DOM focus off the input.

I also stopped trusting my own test suite to prove what I wanted it to prove. Unit tests checking that aria-selected gets set on the right cell tell you the DOM state is correct. They don't tell you a screen reader announces it the way you're hoping. So every component now also runs as a Storybook story, rendered in a real Playwright browser, audited with axe-core through Storybook's accessibility addon, and CI blocks the build if any of it fails. That's a different, better kind of proof than "the assertions pass."

Why it isn't finished

It's not a framework, and it's not a kit of a hundred pre-styled variants you drop in and forget about. It's the boring part underneath: the mechanics every accessible interactive thing needs, no matter what it looks like or what's rendering it.

What I build next tells you where I think the real gaps still are. Bundle size and tree-shaking need real numbers attached instead of a "zero dependencies" claim standing in for them. It's also not built for server-side rendering right now, that's a real limitation, not a footnote to gloss over. I'd rather ship a shorter list of things that are actually solid than a longer list of things I'm hoping hold up.

Why I'm writing this down

This is still the part I like best. Not shipping the feature. Hunting one specific bug until somebody finally names it and builds the thing that makes it stop happening.

If you've fought these same battles (live regions, focus traps, the stuff that's deceptively hard instead of obviously hard), tell me how you handled it in the comments. And if you're building something that has to work for a keyboard or screen reader user, not just pass an automated audit, kanpai-toast and tatami-a11y are both on GitHub and npm, MIT licensed.

I'm also open to new opportunities right now. If the problems in this post are the kind your team is dealing with, reach out.

Top comments (0)