SuperAPI AI Directory

notifications

Design & creation

View repository

Updated 10-15 19:52

About this skill

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:

  1. The user has to act on it within this session, and
  2. 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

  1. 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 to critical.

  2. 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: critical intentionally duplicates across channels, because the cost of a miss exceeds the cost of the annoyance.

  3. 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: critical never coalesces; two failed payments are two problems.

  4. 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.

  5. A toast carrying an action or an error does not auto-dismiss. An Undo that 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.

  6. 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.

  7. Read and dismissed state is per-user, server-side, and syncs across devices. Store read_at and dismissed_at on 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.

  8. Never notify a user about their own foreground action. The UI changing is the confirmation; a Saved toast 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.

  9. 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: critical bypasses quiet hours, and that is the entire reason the tier exists.

  10. Every channel is independently controllable, and bulk mail is unsubscribable in one click. Ship List-Unsubscribe with List-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 critical bypasses
  • Transactional and marketing streams separated; List-Unsubscribe-Post on bulk mail
  • Toast motion handed to motion; permission timing handed to onboarding