Skip to main content
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 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.

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. 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.

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, so a plain wire:model="unreadCount" 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 carries tabindex="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-label and title the 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 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. 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. The actionLabel hint 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.
  • title names the trigger, together with the unread count. An aria-label or aria-labelledby of 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/wirekit.php:

'components' => [
    'notification-center' => [
        'group-by' => 'time',
        'filters' => true,
    ],
],

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store