Notification Center
A bell trigger with an unread badge that opens a panel of grouped, actionable notifications — the pattern nearly every multi-user app needs. It manages read / unread state, an optional type filter, time- or type-grouping, an empty state, and optimistic realtime insertion. Read changes emit events (and mirror the unread count to a hidden input), so wiring it to Livewire is one line.
The Bell
Closed by default, the bell shows the live unread count and announces it to screen readers ("Notifications, 3 unread"). Click it to open the panel.
Notifications
Grouped Panel
group-by="time" buckets items by each item's group value, and
group-by="type" by its type; a group is labeled with that value as you pass it.
Items without one are listed under Other, the one label the component writes itself,
so it follows the application's locale like the component's other labels. Click the bell
to open the panel, then a notification to mark it read — or use Mark all read,
which hides once nothing is unread and hands the focus to the first notification.
Notifications
Type Filters
Add filters for a single-select filter row (a radiogroup — arrow keys move and select) that narrows the list by notification type. Each radio is labeled with a type value as the items carry it, and so is each heading under group-by="type", so pass type in the language your readers use.
Notifications
Empty State
With no notifications, the panel shows a friendly empty state.
Notifications
Livewire & Realtime
Bind read-state changes to Livewire, and optionally let Laravel Echo push new items in via a window event:
{{-- 1. Seed from the server; listen for read-state events --}}
<x-wirekit::notification-center
:items="$this->notifications"
group-by="time"
realtime-event="notification"
x-on:notification-read="$wire.markRead($event.detail.id)"
x-on:notification-read-all="$wire.markAllRead()"
/>
// 2. Server side — mark the Laravel notification read
public function markRead(string $id): void
{
auth()->user()->notifications()->find($id)?->markAsRead();
}
public function markAllRead(): void
{
auth()->user()->unreadNotifications->markAsRead();
}
// 3. Push a new notification in from Echo (optimistic prepend + unread bump)
Echo.private(`App.Models.User.${userId}`)
.notification((n) => {
window.dispatchEvent(new CustomEvent('notification', {
detail: { id: n.id, type: n.type, title: n.title, timeLabel: 'just now' },
}));
});
The center also emits notification-new when a realtime item arrives, and
mirrors the live unread count to a hidden input, so a plain
wire:model="unread works too, with or without a name. A name also
submits the count with a surrounding form.
Notification Item Shape
| Key | Required | Description |
|---|---|---|
id |
yes | Unique identifier (used for dedup + mark-read). An integer id above Number.MAX_SAFE_INTEGER, such as a 64-bit id, travels as its digits, so each event hands it back exactly |
title |
yes | The primary line |
type |
for filters/grouping | Category (mention, comment, …) |
body |
no | Secondary line |
timeLabel |
no | Relative time string (2m ago) |
read |
no | true hides the unread dot |
group |
for group-by="time" |
Time-bucket label (Today, Earlier) |
href |
no | Renders the row as a real link — clicking marks it read, fires notification-action, and navigates natively (middle-click works). Only http, https, mailto, tel and relative addresses become links. A URL with any other scheme, such as javascript:, is dropped, on the server's list and on a realtime item alike, and the row renders as a button |
actionLabel |
no | A short call-to-action line at the bottom of the row (View order), also appended to the row's accessible name |
The panel writes its notifications into the page for its script, each with the keys above and no others, so a notification that is a model carries nothing else of it into the page.
Clicking any row marks it read and dispatches a bubbling notification-action
event carrying { id, href } — listen for it to open a detail panel, route
through your SPA navigation, or call a Livewire action:
<x-wirekit::notification-center
:items="$items"
x-on:notification-action="$wire.openNotification($event.detail.id)"
/>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items |
array | [] |
Notifications (see item shape) |
groupBy |
string | 'none' |
none · time · type |
title |
string|null | null |
Panel heading + bell accessible name (falls back to 'Notifications'), and the switch that makes the scrollable list a screen-reader landmark — see Accessibility |
filters |
bool | false |
Show the type-filter row |
realtimeEvent |
string|null | null |
Window event name to listen for new items |
open |
bool | false |
Start with the panel open (inline embeds) |
seeAllHref |
string|null | null |
Footer "see all" link target |
seeAllLabel |
string | 'See all' |
Footer link text |
emptyText |
string | "You're all caught up" |
Empty-state message |
name |
string|null | null |
Hidden-input name mirroring the unread count. Without an id the center's id derives from it; a second center with the same name on the page gets -2, so each panel is named by its own heading |
scope |
string|null | null |
Scoped personalization name |
Accessibility
- The bell's accessible name carries the unread count in words ("3 unread"), so the state never depends on the badge color alone. Unread items also pair the dot with a visually hidden "Unread." prefix inside the row. Under forced colors, which repaints every fill in the page's own color, the unread dot is drawn in the text color, so an unread row stays marked even under a theme whose heading weight is the body weight.
- A row carries no
aria-label— its accessible name is computed from what it shows, so a screen reader gets the title, the body and the relative time in the order they are read on screen, not the headline alone. - Each group is a list, named by its label. A screen reader announces the groups of a grouped panel as lists, such as "Today, list, 2 items", so a reader learns which notifications a label stands over and how many there are, and can move from one to the next. An ungrouped panel is one list without a name.
- The panel is a
role="dialog"labeled by its title; Escape closes it and returns focus to the bell. The scrollable list carriestabindex="0"and a focus ring (WCAG 2.1.1) whether or not you set a title. - The footer link is named with the panel's title. Its text alone, "See all",
would not say where it goes in a screen reader's list of links, so its name is
its own text followed by the title: "See all Notifications", or your
see-all-labelandtitlethe same way (WCAG 2.4.4). The visible text stays as you wrote it and leads the name, so voice control finds it. - The list becomes a screen-reader landmark only when you set
title. It is then arole="region"named after it ("Alerts list"). Without one it stays a plain focusable scroller — the derived name would be identical on every unnamed center on the page, which axe reports aslandmark-unique. The bell, the panel and the heading keep their "Notifications" default either way. - The type filter is a
role="radiogroup"ofrole="radio"buttons witharia-checked+ rovingtabindex— arrow keys move and select, wrapping at the ends. The checked filter is a fill, and under forced colors, which repaints every fill in the page's own color, it is framed in the system's selection color. - Each notification is ONE keyboard-operable control — a real link when it carries
href(native navigation + middle-click), a button otherwise. TheactionLabelhint is part of the row, never a nested control. - Tabbing past either end of the panel closes it and continues from the bell, rather than from wherever the panel's markup sits in the document. The panel is drawn beside the bell but rendered at the end of the page, so the browser's own answer would send you somewhere else entirely with the flyout still open.
titlenames the trigger, together with the unread count. Anaria-labeloraria-labelledbyof yours names the panel, so the count stays on the trigger.
Keyboard Interaction
| Key | Action |
|---|---|
Enter / Space |
Open the panel (on the bell) or activate an item (mark read + notification-action) |
Tab |
Move through the panel's controls and notifications; past either end it closes the panel and continues from the bell |
Arrow keys |
On the type filter: move between the filter radios and select (wraps at the ends) |
Escape |
Close the panel, returning focus to the bell. A modal or a drawer around the bell stays open |
Design Tokens
| Element | Token |
|---|---|
| Bell hover surface | --color-wk-bg-active |
| Unread badge | --color-wk-danger |
| Panel surface | --color-wk-bg-elevated |
| Panel shadow | --shadow-wk-lg |
| Unread dot | --color-wk-accent |
| Active filter tab | --color-wk-bg-inverse |
| Focus ring | --color-wk-ring |
Customization
Override the grouping and filter defaults via config/:
'components' => [
'notification-center' => [
'group-by' => 'time',
'filters' => true,
],
],