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:
- The user has to act on it within this session, and
- not seeing it now causes a loss they cannot recover later.
One yes is not enough. "Their teammate replied" fails (2). "Your export finished" fails (1) — that is a badge. "Your payment failed and service stops in 24 hours" passes both. Everything failing the test is ambient: inbox row, badge count, or an entry in a digest.
Then place the surface by tier:
| Tier | In-app | Push / email | Persists? |
|---|---|---|---|
critical |
Blocking or persistent banner | Both, bypasses quiet hours | Until resolved, not until seen |
actionable |
Toast with an action, plus inbox row | Push if the user opted in; batched email | Inbox row until acted on |
informational |
Inbox row, badge count | Digest only | Until read |
ambient |
Count only | Never | Auto-expires |
Core Principles
-
Assign the tier at emit and store it on the event. Routing, batching, quiet hours, digesting, and the user's preference screen all key off one field; deciding tier inside the toast component means every channel re-derives it differently and they drift within a quarter. Tier is one of
critical | actionable | informational | ambient. Exception: a user preference may demote a tier; nothing but the system may promote anything tocritical. -
One event produces one notification across all channels. Fan out from a single emit carrying a shared
dedupe_key, so the same comment cannot arrive as a toast and a push and an email and an inbox row. If the user was looking at the screen when it happened, the other channels are suppressed. Exception:criticalintentionally duplicates across channels, because the cost of a miss exceeds the cost of the annoyance. -
Coalesce on
(actor, verb, object)inside a window; render counts, never repeats. Five comments on one document is one row reading "Sam and 4 others commented on Roadmap", not five rows — an inbox that repeats is an inbox people stop opening. Newest activity refreshes the row's timestamp and un-reads it. Exception:criticalnever coalesces; two failed payments are two problems. -
Compute toast duration from word count at
200–250 wpm; never ship a flat value for every message. A nine-word warning and a two-word confirmation cannot share a timer — one is unreadable and the other overstays. Pause the timer while the toast is hovered or focused, and while the tab is hidden, so a message emitted in a background tab is still there on return. Exception: a burst of identical confirmations collapses into one slot at the shortest computed duration rather than stacking. -
A toast carrying an action or an error does not auto-dismiss. An
Undothat expires while the user is still reading it is a trap, and an error that vanishes leaves someone with no way to find out what happened. Give both an explicit close. Exception: an undo whose window is genuinely time-bounded by the backend may auto-dismiss when that window closes — and it must show the window, not hide it. -
A badge counts only things the user can act on, and it clears on view, not on visit. The count is
unread AND actionable, nothing else — a number padded with marketing announcements teaches people the number is a lie, after which it is decoration. Entering the surface is not reading; clear per item as each is seen. Exception: an unbounded or expensive-to-count set gets a dot with no number rather than a wrong number. -
Read and dismissed state is per-user, server-side, and syncs across devices. Store
read_atanddismissed_aton the(user, notification)row; dismissing on a laptop and finding it unread on a phone is the most common trust failure in this surface. Exception: transient toasts, which are the one notification class that legitimately dies with the tab — anything that must survive a reload was never a toast. -
Never notify a user about their own foreground action. The UI changing is the confirmation; a
Savedtoast on top of a visibly saved document is noise, and it is how a product ends up with three toasts per interaction. Exception: actions whose result is invisible or arrives later (a background export, an async invite), and any action with an undo window. -
Quiet hours defer; they never drop. Compute from the user's stored timezone, not the server's, and release the queue at the boundary as a batch rather than a burst. A dropped notification is a bug the user reports as data loss. Exception:
criticalbypasses quiet hours, and that is the entire reason the tier exists. -
Every channel is independently controllable, and bulk mail is unsubscribable in one click. Ship
List-UnsubscribewithList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058) on bulk sends, and keep transactional and marketing on separate streams and separate preference toggles — unsubscribing from a newsletter must never stop a password-reset email. Exception: none. A transactional message that cannot be turned off must genuinely be transactional.
Smell / Fix
| Smell | Fix |
|---|---|
| Toast, push, email and inbox row for one comment | One emit, one dedupe key, suppress the rest |
duration: 3000 on every toast |
Derive from word count at 200–250 wpm |
| Undo toast that auto-dismisses | Actions and errors never auto-dismiss |
| Badge that counts announcements | Count only actionable items; clear per item on view |
Read state in localStorage |
Per-user, server-side, synced |
| Five rows for five comments on one doc | Coalesce on (actor, verb, object) |
| "Saved" toast over a visibly saved document | Delete it; the UI change is the confirmation |
| Quiet hours implemented as a drop | Defer and release at the boundary |
| Tier decided inside the toast component | Assign at emit, store on the event |
| Quiet hours computed in server time | Use the user's stored timezone |
| One "notifications" on/off switch | Per-channel and per-tier controls |
| Inline error thrown as a toast | Render it where the data was — ui-states |
Output format
Spec a notification system as one row per event type, before any component is written:
| Event | Tier | Channels | Dedupe key | Coalesce on / window | Dismiss | Quiet hours |
|---|
Channels lists only what actually fires after suppression. Dismiss is one of auto (computed), explicit, or on resolve. Quiet hours is defer or bypass — and bypass requires the tier to be critical.
Checklist
- Every event has a tier assigned at emit and stored on the record
- Interrupt-vs-ambient test applied per event, both conditions required
- One event, one notification; dedupe key present; in-session suppression works
- Coalescing key and window defined for every repeatable event
- Toast durations computed from word count; timer pauses on hover, focus, and hidden tab
- Toasts with actions or errors do not auto-dismiss
- Badge counts actionable items only and clears per item on view
- Read/dismissed state per-user and server-side
- Quiet hours defer, computed in the user's timezone; only
criticalbypasses - Transactional and marketing streams separated;
List-Unsubscribe-Poston bulk mail - Toast motion handed to
motion; permission timing handed toonboarding