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 under each item's group label. Click the bell
to open the panel, then a notification to mark it read — or use Mark all read.
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.
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 when you pass a name — so a
plain wire:model="unreadCount" works too.
Notification Item Shape
| Key | Required | Description |
|---|---|---|
id |
yes | Unique identifier (used for dedup + mark-read) |
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) |
actionLabel |
no | A short call-to-action line at the bottom of the row (View order), also appended to the row's accessible name |
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 |
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.
- 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. - 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 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. - 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.
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 |
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 |
Design Tokens
| Element | Token |
|---|---|
| Bell hover surface | --color-wk-bg-muted |
| 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/wirekit.php:
'components' => [
'notification-center' => [
'group-by' => 'time',
'filters' => true,
],
],