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 | 'Notifications' |
Panel heading + bell accessible name |
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 an "Unread." prefix in each item's
aria-label. - The panel is a
role="dialog"labeled by its title; Escape closes it and returns focus to the bell. The scrollable list is a labeledrole="region"withtabindex="0"and a focus ring (WCAG 2.1.1). - 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,
],
],