Skip to main content
WireKit
Copy for LLM

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.

Notification bell with unread badge

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.

Grouped by time

Type Filters

Add filters for a single-select filter row (a radiogroup — arrow keys move and select) that narrows the list by notification type.

Type filters

Empty State

With no notifications, the panel shows a friendly empty state.

Empty state

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 labeled role="region" with tabindex="0" and a focus ring (WCAG 2.1.1).
  • The type filter is a role="radiogroup" of role="radio" buttons with aria-checked + roving tabindex — 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. The actionLabel hint 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,
    ],
],

Further Reading