
A notification center has one job: give people a calm, trustworthy place to catch up on what they missed, without burying them in it. The bell, the badge, the dropdown of rows, that part is quick to build. What makes one people actually open, and keep opening, comes down to a lot of small design choices.
This guide walks through those choices element by element, from the row and the unread state to grouping, real-time updates, and the empty and error states, then shows three ways to build them with Courier Inbox. It's written for the designers, PMs, and front-end engineers who shape that surface.

A good in-app notification center is designed for scanning, not reading. That means a badge that means something, rows with a strict title, body, and timestamp hierarchy, one quiet unread signal, and grouping so a busy feed stays legible. The states most teams treat as afterthoughts, empty, loading, and error, are where the polish actually shows.
Courier Inbox lets you hit that bar at three depths: theme the built-in components to match your brand, swap individual pieces like the row with render props, or go fully headless with the useCourier() hook and build any layout on Courier's real-time and state layer.

An in-app notification center is the in-product home for a user's notifications: the bell, the feed behind it, and the read state that ties them together. Every one is the same handful of parts, arranged well or badly:
The rest of this guide is about getting each of these right.
People scan a feed for the one thing that matters, then leave, so a row has to answer "what is this, and do I care?" at a glance. Give it a strict hierarchy: a leading icon or avatar that signals type or sender before any text is read, a title that stands on its own, a body clamped to one or two lines so rows stay uniform, and a relative timestamp (2h, Yesterday). Add a button only for a real action ("Approve," "View invoice"), never for symmetry.
Unread is the feed's most important signal and the easiest to overdo. One small dot and a slightly heavier title is enough; a dot plus a tint plus bold text plus a left border is four ways of saying one thing. Mark read on open, and give people an easy "mark as unread" for anything worth returning to.
Volume is the enemy of every notification center. Start at the badge: cap it (9+, not 247) and make "unread" mean "worth acting on," not "technically new." A badge that's always lit teaches people to stop looking.
Inside the feed, three tools keep a busy list legible. Time grouping ("Today," "Yesterday") instantly orders a long feed. Bundling collapses repeats into one row ("Maya and 8 others reacted" beats nine) and is the highest-impact fix for noise, as much a content decision as a UI one. Tabs ("All," "Unread," "Mentions") separate notifications that differ in kind. Underneath, per-category and per-channel preferences let people tune what arrives at all; a feed nobody can turn down is one they learn to ignore.
Real-time is what separates a notification center from an email folder: when something arrives while the app is open, the feed updates and the badge ticks up with no refresh. For time-sensitive events, pair the feed with a toast, a small popup that surfaces the notification as it lands, then leaves. Toasts say "now," the inbox says "later," so keep toasts rare enough to matter.
Make the feed something people act on. Put the two actions they reach for most, mark read/unread and archive, where the hand already is: on hover on desktop, on swipe on touch. Bulk actions go in the header. And keep every action instant by updating optimistically and reconciling in the background; a center that waits for a round-trip to clear a dot feels broken even when it works.
These are the states teams skip, and where polish shows. An empty panel reads as broken; "You're all caught up" with a light illustration reads as finished, and it's the first thing a new user sees. Loading should hold the layout still with skeleton rows shaped like real ones. An error state needs a way out: what happened, in plain language, and a retry, not a raw stack trace.
Your notification center will be used in dark mode, on small screens, and with a keyboard or screen reader, so design for all three up front. Dark mode isn't an inverted palette: unread states, dividers, and shadows each need their own dark values to hold the hierarchy. On a narrow screen the dropdown becomes a full-screen sheet, tap targets grow to at least 44px, and hover actions move to tap or swipe. For accessibility: WCAG AA contrast in both modes, every row and action keyboard-operable with visible focus, the bell labeled with its unread count, and new arrivals announced through a polite live region that respects prefers-reduced-motion.
Native apps add their own conventions (system integration, push, platform list patterns), but every decision above holds regardless of platform.
Principles are easy to agree with and easy to forget under deadline. Run this against the real thing, with real data, before it goes out:
9+) or does it render 247?If it survives all eight, you've built a notification center people will actually open.
Those principles are vendor-agnostic. Here's how to execute them with Courier Inbox, which gives you three levels of control. You can mix all three in the same app.
useCourier() hook hands you the messages, unread counts, and actions with no UI at all, so you can build any layout you can imagine.Most teams start at level one and drop to level two for the row. Level three is there when you need it.
The fastest path is a CourierInboxTheme, a single object that mirrors the structure of the inbox. You target a region by following the nesting and set only the properties you want to change. Everything you leave out falls back to the default, so a theme is usually small.
import { CourierInbox, type CourierInboxTheme } from "@trycourier/courier-react";const theme: CourierInboxTheme = {inbox: {header: {tabs: {selected: {unreadIndicator: {backgroundColor: "#5A40C7", // unread badge on the selected tab},},},},list: {backgroundColor: "#ffffff",item: {unreadIndicatorColor: "#5A40C7", // unread dot on each row},},},};return <CourierInbox lightTheme={theme} darkTheme={theme} mode="light" />;
Three props drive theming:
lightTheme and darkTheme define each mode independently. Pass the same object to both if you only want one look.mode picks which renders: "light", "dark", or "system" to follow the user's OS setting. Shipping both modes is a few extra lines here, not a rebuild.Beyond color, the theme also covers fonts, icons, and text, so you can match your typography and even reword built-in labels. Check the CourierInboxTheme reference for the full set of tokens.
If you'd rather tweak the built-in look than define a theme from scratch, import defaultLightTheme and merge your overrides on top with mergeTheme. Your values win; everything else stays default.
import {CourierInbox,defaultLightTheme,mergeTheme,type CourierInboxTheme,} from "@trycourier/courier-react";const overrides: CourierInboxTheme = {inbox: {list: {item: {unreadIndicatorColor: "#5A40C7",},},},};const theme = mergeTheme(defaultLightTheme, overrides);return <CourierInbox lightTheme={theme} mode="light" />;
There's a matching defaultDarkTheme for dark mode, and defaultToastLightTheme / defaultToastDarkTheme with a mergeToastTheme helper for styling toasts the same way.
By default the inbox sizes to its content. Pin it to a fixed height when it lives in a sidebar or dashboard panel:
return <CourierInbox height="600px" lightTheme={theme} />;
For the bell-and-dropdown pattern, swap in <CourierInboxPopupMenu />, which gives you the button, the unread badge, and the popup for free. Control where it opens and how big it is with popupAlignment, popupWidth, and popupHeight:
import { CourierInboxPopupMenu } from "@trycourier/courier-react";return (<CourierInboxPopupMenupopupAlignment="top-right"popupWidth="420px"popupHeight="560px"/>);
A theme gets you brand-matched in an afternoon. When you need pixel-level control over how a notification looks, move to render props.
Render props let you replace individual parts of the inbox with your own React components while Courier keeps doing the heavy lifting: real-time updates, read state, pagination, and actions. You get design freedom exactly where you want it and keep the defaults everywhere else.
The one that matters most is the row. renderListItem receives each message and lets you return whatever markup you like. This is where the "design the row for scanning" principle becomes code:
import {CourierInbox,type CourierInboxListItemFactoryProps,} from "@trycourier/courier-react";function NotificationRow({ message }: CourierInboxListItemFactoryProps) {const isUnread = !message.read;return (<divstyle={{display: "flex",gap: 12,padding: "14px 16px",alignItems: "flex-start",background: isUnread ? "#F7F5FF" : "transparent",}}>{/* Leading icon encodes the type before any text is read */}<divaria-hiddenstyle={{width: 36,height: 36,borderRadius: 10,background: "#EDE9FE",display: "grid",placeItems: "center",flexShrink: 0,}}>🔔</div><div style={{ minWidth: 0, flex: 1 }}><divstyle={{fontWeight: isUnread ? 600 : 500,color: "#171717",fontSize: 14,}}>{message.title}</div>{/* Clamp the body so every row is the same height */}<divstyle={{color: "#525252",fontSize: 13,lineHeight: 1.4,display: "-webkit-box",WebkitLineClamp: 2,WebkitBoxOrient: "vertical",overflow: "hidden",}}>{message.body}</div></div>{/* One quiet unread signal, not four */}{isUnread && (<spanaria-label="Unread"style={{width: 8,height: 8,borderRadius: "50%",background: "#5A40C7",flexShrink: 0,marginTop: 6,}}/>)}</div>);}export default function App() {// Courier authentication and other component code...return (<CourierInboxrenderListItem={(props: CourierInboxListItemFactoryProps) => (<NotificationRow {...props} />)}/>);}
The same pattern covers the other parts you'll want to own:
renderHeader replaces the header. Use it for a custom title, your own filter chips, or an unread pill that matches your design system. It receives the feeds with their selection state and unread counts.renderEmptyState, renderLoadingState, and renderErrorState cover the three states most teams skip. This is where the "empty is a design opportunity" principle lives: return an illustration and a friendly line instead of a blank panel.renderMenuButton (on the popup menu) replaces the bell so it matches the rest of your nav, and gives you the unread count to render your own badge.Here's a finished empty state, intentional rather than blank:
import {CourierInbox,type CourierInboxStateEmptyFactoryProps,} from "@trycourier/courier-react";function EmptyState(_: CourierInboxStateEmptyFactoryProps) {return (<div style={{ textAlign: "center", padding: "48px 24px", color: "#525252" }}><div style={{ fontSize: 40, marginBottom: 12 }}>ğŸ“</div><div style={{ fontWeight: 600, color: "#171717", marginBottom: 4 }}>You're all caught up</div><div style={{ fontSize: 13 }}>New notifications will show up here as they arrive.</div></div>);}return (<CourierInboxrenderEmptyState={(props) => <EmptyState {...props} />}/>);
You can also organize the feed with tabs and feeds (the "grouping" principle) without any custom rendering, by passing a feeds array that defines filtered views like All, Unread, and Mentions. That's configuration, not code you maintain.
When you need a feed that looks nothing like the default, notifications inline in a timeline, a command-palette-style overlay, a custom mobile sheet, drop the components entirely and use useCourier(). The hook exposes the same data and actions the components use, so you build the UI and Courier handles everything behind it.
import { useCourier, type InboxMessage } from "@trycourier/courier-react";function CustomFeed() {const { inbox } = useCourier();const messages = inbox.feeds["all_messages"]?.messages ?? [];return (<div><header style={{ display: "flex", justifyContent: "space-between" }}><h2>Notifications</h2><button onClick={() => inbox.readAllMessages()}>Mark all read</button></header>{messages.map((message: InboxMessage) => (<buttonkey={message.messageId}onClick={() => inbox.clickMessage(message)}style={{ fontWeight: message.read ? 400 : 600 }}>{message.title}</button>))}</div>);}
You get reactive state (inbox.totalUnreadCount, inbox.feeds) and the full set of actions (inbox.readMessage(message), inbox.archiveMessage(message), inbox.readAllMessages(), and more). Everything the principles above ask for, optimistic updates, grouping, custom actions, is yours to arrange, without rebuilding the real-time and state layer underneath.
Yes, for most products. Read-on-open keeps friction low, and pairing it with an easy "mark as unread" lets people flag anything they want to come back to. Save read-only-on-explicit-action for cases where the unread count drives real work, like support queues or approvals, where a self-clearing badge would hide things that still need doing.
Bundle as soon as the same kind of event repeats, not at some volume threshold. "Maya and 8 others reacted to your post" as a single row beats nine separate rows every time. Bundling is the highest-impact change you can make to a noisy feed, and it's a content decision as much as a UI one.
A toast is "tell me now": a small, transient popup for something time-sensitive while the app is open. The inbox is "let me catch up later": the persistent feed that holds history and read state. Use both, and keep toasts rare so they stay meaningful.
Buy it, unless notifications are your core product. The bell and the list are a weekend of work; the real cost is the real-time backend, cross-device read state, storage, pagination, and the on-call that never ends. A drop-in component hands that reliability problem to a vendor while still letting you own the design through theming and render props. For the full breakdown, see best in-app notification centers in 2026.
A good notification center is a stack of small, deliberate decisions: a badge that means something, a row built for scanning, one quiet unread signal, real grouping, states that don't look broken, and actions where the hand already is. The design work is the same whether you build or buy.
Courier Inbox lets you execute that design at whatever depth you need, brand-match with a theme, replace the row with renderListItem, or take the whole thing headless with useCourier(), without rebuilding the real-time and state layer every notification center needs underneath. Start with a theme, drop to render props where the defaults aren't enough, and you'll have something polished faster than you'd build the bell from scratch.
Related resources:

How Apple's on-device AI works, and what it changes for your users
Apple's 2026 on-device models (AFM 3) are good enough to read, rank, and summarize everything that lands on your phone, locally and for free. Here's how they actually work, in plain terms, and how a model that reads every message before your users do changes what you should send.

Apple Intelligence finally got smart. Here's how it changes the notifications your users see.
Apple Intelligence's on-device models got dramatically better in iOS 27, and they now summarize, rank, and filter notifications before your users ever see them. There's no API to control that, so the words you send are the only lever you have. Here's how notification summaries work and how to write for them.

Push Notifications vs In-App Messages: Key Differences and When to Use Each
Push notifications reach users outside your app; in-app messages engage them inside it. Learn the key differences, when to use each, and how to run push, in-app inbox, email, and SMS from one Courier API.
© 2026 Courier. All rights reserved.