notifications
Designing a Notification System
Notifications are a shared budget spent against one person's attention, and the default is ambient: it lands in an inbox, it raises a count, and the user finds it when they look. An interrupt — a toast, a push, an email in the middle of the day — has to be argued for, per event, in writing. Design the system before the surface: a toast component built without a priority tier and a dedupe key becomes a firehose the moment a second feature starts emitting. Boundary with motion: how a toast enters, stacks, and swipes away is motion's (see motion/references/patterns.md); how often it fires, what tier it carries, and whether it coalesces is this skill's. Boundary with ui-states: an error rendered where the data would have been is ui-states; an error thrown at the user somewhere else is this skill. Push-permission timing belongs to onboarding.
Find the emitters before writing any component. Grep for what already sends: a toast library (sonner, react-hot-toast, @radix-ui/react-toast), a push service worker and Notification calls, a mail sender (resend, postmark, sendgrid, nodemailer), an existing notifications table, a job queue, and any per-user preference or timezone column. Most products already have three uncoordinated emitters and no shared tier, and the real work is routing them through one emit path — not adding a fourth. Match the project's existing template and styling systems for both in-app surfaces and email.
Quick Reference
| Topic | Where |
|---|---|
| Once an event's tier is known and you need to route it to concrete surfaces — which channels fire, what batches, what quiet hours do, what the user can turn off | priority-matrix.md |
| Before writing or reviewing any transactional email markup — client constraints, table layout, inline CSS, dark mode, images, width, preheader, unsubscribe headers | html-email.md |
Decision Framework: interrupt or ambient?
An event may interrupt only if both are true: