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|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 carries tabindex="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 a role="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 as landmark-unique. The bell, the panel and the heading keep their "Notifications" default either way.
  • 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

Was this page helpful?

Voting requires cookies or local storage. What we store