---
title: Notification Center
description: Bell trigger with unread badge and a grouped, realtime-capable notification panel
visibility: guest
draft: false
---

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

:::preview{title="Notification bell with unread badge"}
<x-wirekit::notification-center :items="[
    ['id' => 1, 'type' => 'mention', 'title' => 'Alex mentioned you in #general', 'timeLabel' => '2m ago', 'read' => false],
    ['id' => 2, 'type' => 'comment', 'title' => 'New comment on the Q3 roadmap', 'timeLabel' => '1h ago', 'read' => false],
    ['id' => 3, 'type' => 'system', 'title' => 'Your export is ready', 'timeLabel' => 'Yesterday', 'read' => true],
]" />
:::

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

:::preview{title="Grouped by time"}
<x-wirekit::notification-center
    group-by="time"
    :items="[
        ['id' => 1, 'type' => 'mention', 'title' => 'Alex mentioned you', 'body' => 'in #general', 'timeLabel' => '2m ago', 'read' => false, 'group' => 'Today', 'href' => '#thread', 'actionLabel' => 'Open thread'],
        ['id' => 2, 'type' => 'comment', 'title' => 'New comment on the roadmap', 'timeLabel' => '1h ago', 'read' => false, 'group' => 'Today'],
        ['id' => 3, 'type' => 'system', 'title' => 'Your export is ready', 'timeLabel' => 'Yesterday', 'read' => true, 'group' => 'Earlier'],
        ['id' => 4, 'type' => 'mention', 'title' => 'Sam replied to your thread', 'timeLabel' => '2d ago', 'read' => true, 'group' => 'Earlier'],
    ]"
    see-all-href="#all"
/>
:::

## Type Filters

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

:::preview{title="Type filters"}
<x-wirekit::notification-center
    filters
    :items="[
        ['id' => 1, 'type' => 'mention', 'title' => 'Alex mentioned you', 'timeLabel' => '2m ago', 'read' => false],
        ['id' => 2, 'type' => 'comment', 'title' => 'New comment on the roadmap', 'timeLabel' => '1h ago', 'read' => false],
        ['id' => 3, 'type' => 'comment', 'title' => 'Jordan commented on Budget', 'timeLabel' => '3h ago', 'read' => true],
        ['id' => 4, 'type' => 'mention', 'title' => 'Sam replied to your thread', 'timeLabel' => '2d ago', 'read' => true],
    ]"
/>
:::

## Empty State

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

:::preview{title="Empty state"}
<x-wirekit::notification-center :items="[]" empty-text="You're all caught up" />
:::

## Livewire & Realtime

Bind read-state changes to Livewire, and optionally let Laravel Echo push new
items in via a window event:

```blade
{{-- 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()"
/>
```

```php
// 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();
}
```

```javascript
// 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:

```blade
<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`:

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

## Further Reading

- [WAI-ARIA Authoring Practices — Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
- [Laravel — Database Notifications](https://laravel.com/docs/notifications#database-notifications)
- [Laravel Echo — Receiving broadcasts](https://laravel.com/docs/broadcasting#receiving-broadcasts)
