Skip to content
Brok UI

Loading…

No results

Notifications

Open source

In-app notification surfaces — a stacked feed panel, a single content card, a dense inbox row, and a corner count badge.

Version
v1.2.2
Stability
stable
License
MIT
Related
Notification Badge
Notification Card
Toast

Notification Panel — A summary header with an unread count and a chevron toggle that collapses a list of notifications via a CSS grid-rows transition, with aria-expanded / aria-controls wiring.

Preview

  • Scheduled downtime Unread 5 min ago Servers will be offline tonight at 11 PM.
  • System update Unread 20 min ago Your app was updated to v2.3.1.
  • New invite 1 hour ago You've been added to the Design team.
  • Security alert 6 hours ago Unrecognized login attempt detected.

previews.components.notification-panel.default.blade.php Blade
<div class="w-full p-6">
    <x-ui.notification-panel :open="true" mark-all-url="#" view-all-url="#" />
</div>

Installation

terminal
php artisan ui:add notification-panel

Note

This component ships an Alpine behavior module at resources/js/ui/notification-panel.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './notification-panel.js';

Registry contract

php artisan ui:add notification-panel writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/notification-panel.blade.php
  • js resources/js/ui/notification-panel.js
Registry dependencies
badge avatar
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

notification-panel.md
# Brok UI: Notification Panel (`notification-panel`)

A summary header with an unread count and a chevron toggle that collapses a list of notifications via a CSS grid-rows transition, with aria-expanded / aria-controls wiring.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add notification-panel
```

## Usage

```blade
<div class="w-full p-6">
    <x-ui.notification-panel :open="true" mark-all-url="#" view-all-url="#" />
</div>
```

## Props

- `notifications` (list<Notification>, default `[…]`) — {id, title, description, timestamp, icon (server|refresh|users|shield), unread, href?, actor? {name, initials, avatar}}. An actor renders an avatar instead of the glyph.
- `open` (bool, default `false`) — Start expanded. Always open when chrome is false.
- `title` (string|null, default `null`) — Header title.
- `markAllUrl` (string|null, default `null`) — Footer Mark all as read target. A link by default; with markAllMethod=post a button that POSTs {ids} there and clears every unread row optimistically.
- `viewAllUrl` (string|null, default `null`) — Footer View all link.
- `chrome` (bool, default `true`) — false renders the list only: no summary header, no collapse.
- `markReadUrl` (string|null, default `null`) — POST endpoint receiving {id} when an unread row's Mark as read control is pressed; the row flips optimistically and reverts on a non-2xx response.
- `markAllMethod` ('get'|'post', default `get`) — How Mark all as read reaches markAllUrl.
- `markedReadLabel` (string, default `Marked as read.`) — Live-region copy after one row is marked.
- `markedAllReadLabel` (string, default `All notifications marked as read.`) — Live-region copy after mark all.
- `markReadFailedLabel` (string, default `Could not update the notification. Try again.`) — Live-region copy when the host refuses.

## Use when

- Use for important messages that should stay visible long enough to read and act on.
- A compact notification list with an unread count that collapses, or (chrome=false) the list of a full-page notification centre with in-place read state.

## Avoid when

- Do not downgrade important guidance into transient messaging users can miss.
- A single transient message; use toast or alert.

## Anti-patterns

- Downgrading important outcomes to transient feedback

## Rules

- Use the `<brok:notification-panel>` tag (or `<x-ui.notification-panel>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/notification-panel
- Registry JSON (files, props, contract): https://brokui.dev/r/open/notification-panel.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
notifications list<Notification> […] {id, title, description, timestamp, icon (server|refresh|users|shield), unread, href?, actor? {name, initials, avatar}}. An actor renders an avatar instead of the glyph.
open bool false Start expanded. Always open when chrome is false.
title string | null null Header title.
markAllUrl string | null null Footer Mark all as read target. A link by default; with markAllMethod=post a button that POSTs {ids} there and clears every unread row optimistically.
viewAllUrl string | null null Footer View all link.
chrome bool true false renders the list only: no summary header, no collapse.
markReadUrl string | null null POST endpoint receiving {id} when an unread row's Mark as read control is pressed; the row flips optimistically and reverts on a non-2xx response.
markAllMethod 'get' | 'post' get How Mark all as read reaches markAllUrl.
markedReadLabel string Marked as read. Live-region copy after one row is marked.
markedAllReadLabel string All notifications marked as read. Live-region copy after mark all.
markReadFailedLabel string Could not update the notification. Try again. Live-region copy when the host refuses.

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

notification-panel notification-panel-item notification-panel-mark-all notification-panel-mark-read

Behavior

  • Unread ids live in the Alpine scope; the header badge, the dot and the soft fill follow it.
  • markRead/markAll POST JSON with the X-CSRF-TOKEN from <meta name="csrf-token">; failures revert and announce.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Persistent message and notification

Keep an important message visible near its context.

Use when

  • Use for important messages that should stay visible long enough to read and act on.
  • A compact notification list with an unread count that collapses, or (chrome=false) the list of a full-page notification centre with in-place read state.

Avoid when

  • Do not downgrade important guidance into transient messaging users can miss.
  • A single transient message; use toast or alert.

Use instead

  • Toast for low-priority confirmation

Anti-patterns

  • Downgrading important outcomes to transient feedback
Anatomy
notification-panel notification-panel-item notification-panel-mark-all notification-panel-mark-read
Theming hooks
notification-panel notification-panel-item notification-panel-mark-all notification-panel-mark-read

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • The toggle exposes aria-expanded and aria-controls; unread rows carry a visually hidden Unread label.
  • Read-state changes are announced through a polite live region without moving focus.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="notification-panel-{{ $record->id }}">
    <div class="w-full p-6">
        <x-ui.notification-panel :open="true" mark-all-url="#" view-all-url="#" />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:notification-panel
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/notification-panel.blade.php Blade
@props([
    // Each notification: id, title, description, timestamp, icon (glyph name),
    // unread (bool), href (optional), actor (optional: name, initials, avatar —
    // renders an avatar in place of the glyph).
    'notifications' => [
        ['id' => 'maintenance', 'icon' => 'server', 'title' => 'Scheduled downtime', 'description' => 'Servers will be offline tonight at 11 PM.', 'timestamp' => '5 min ago', 'unread' => true],
        ['id' => 'update', 'icon' => 'refresh', 'title' => 'System update', 'description' => 'Your app was updated to v2.3.1.', 'timestamp' => '20 min ago', 'unread' => true],
        ['id' => 'invite', 'icon' => 'users', 'title' => 'New invite', 'description' => "You've been added to the Design team.", 'timestamp' => '1 hour ago'],
        ['id' => 'alert', 'icon' => 'shield', 'title' => 'Security alert', 'description' => 'Unrecognized login attempt detected.', 'timestamp' => '6 hours ago'],
    ],
    // Start expanded?
    'open' => false,
    'title' => null,
    // Footer links. Either may be omitted.
    'markAllUrl' => null,
    'viewAllUrl' => null,
    // false renders the list only: no summary header, no collapse. For a
    // full-page notification centre that brings its own heading.
    'chrome' => true,
    // POST endpoint that receives `{ id }` when an unread row's "Mark as read"
    // control is pressed. The row flips optimistically and reverts when the
    // request fails. Unset, rows carry no control (the released behaviour).
    'markReadUrl' => null,
    // 'get' keeps "Mark all as read" a plain link to markAllUrl; 'post' turns
    // it into a button that POSTs there and clears every unread row in place.
    'markAllMethod' => 'get',
    // Localized status copy for the live region.
    'markedReadLabel' => 'Marked as read.',
    'markedAllReadLabel' => 'All notifications marked as read.',
    'markReadFailedLabel' => 'Could not update the notification. Try again.',
])

@php
    $glyphs = [
        'server' => 'M3 5h18v6H3zM3 13h18v6H3zM7 8h.01M7 16h.01',
        'refresh' => 'M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5',
        'users' => 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M23 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8',
        'shield' => 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z',
    ];
    $notifications = array_values(array_filter((array) $notifications, 'is_array'));
    $count = count($notifications);
    $unreadIds = array_values(array_map(
        static fn (array $n): string => (string) ($n['id'] ?? ''),
        array_filter($notifications, static fn (array $n): bool => (bool) ($n['unread'] ?? false)),
    ));
    $unread = count($unreadIds);
    $title ??= __('Notifications');
    $listId = 'notification-panel-'.\Illuminate\Support\Str::random(6);
    $chrome = (bool) $chrome;
    $markAllPosts = $markAllMethod === 'post' && filled($markAllUrl);
@endphp

{{-- Notification Panel: a header (title, unread count, chevron toggle) over a
     collapsible list of notifications — unread rows carry a ring-accent dot and
     a soft fill — and a footer with "Mark all as read" / "View all". Collapse is
     a CSS grid-rows transition; the toggle exposes aria-expanded / aria-controls.
     Read state lives in the Alpine scope (`unread` ids) so a row can be marked
     read in place and the header count follows. --}}
<div
    data-slot="notification-panel"
    x-data="uiNotificationPanel({ open: {{ ($open || ! $chrome) ? 'true' : 'false' }}, unread: @js($unreadIds), markReadUrl: @js($markReadUrl), markAllUrl: @js($markAllPosts ? $markAllUrl : null), labels: @js(['read' => __($markedReadLabel), 'all' => __($markedAllReadLabel), 'failed' => __($markReadFailedLabel)]) })"
    {{ $attributes->merge(['class' => 'mx-auto w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-xs']) }}
>
    @if ($chrome)
        <button
            type="button"
            @click="toggle()"
            :aria-expanded="open ? 'true' : 'false'"
            aria-controls="{{ $listId }}"
            class="flex w-full items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
        >
            <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
                    <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" />
                </svg>
            </span>
            <span class="flex min-w-0 flex-1 items-center gap-2">
                <span class="truncate text-sm font-medium text-foreground">{{ $title }}</span>
                <x-ui.badge variant="default" size="sm" class="tabular-nums" x-show="unread.length > 0" :x-cloak="$unread === 0"><span x-text="unread.length">{{ $unread }}</span><span class="sr-only"> {{ __('unread') }}</span></x-ui.badge>
            </span>
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none" :class="open && '-rotate-180'" aria-hidden="true">
                <path d="m6 9 6 6 6-6" />
            </svg>
        </button>
    @endif

    {{-- CSS-only collapse: grid rows animate 0fr → 1fr. --}}
    <div
        id="{{ $listId }}"
        @if ($chrome)
            class="grid transition-[grid-template-rows] duration-200 ease-standard motion-reduce:transition-none"
            :class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
        @else
            class="grid grid-rows-[1fr]"
        @endif
    >
        <div class="overflow-hidden">
            <ul role="list" class="divide-y divide-border {{ $chrome ? 'border-t border-border' : '' }}">
                @foreach ($notifications as $n)
                    @php($id = (string) ($n['id'] ?? ''))
                    @php($isUnread = (bool) ($n['unread'] ?? false))
                    @php($tag = ! empty($n['href']) ? 'a' : 'div')
                    @php($actor = is_array($n['actor'] ?? null) ? $n['actor'] : null)
                    <li class="flex items-start">
                        <{{ $tag }}
                            @if ($tag === 'a') href="{{ $n['href'] }}" @endif
                            data-slot="notification-panel-item"
                            @if ($isUnread) data-unread="true" @endif
                            @if ($id !== '') :data-unread="isUnread(@js($id)) ? 'true' : null" @endif
                            class="flex min-w-0 flex-1 items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/60 data-[unread=true]:bg-accent/40 motion-reduce:transition-none"
                        >
                            @if ($actor)
                                <x-ui.avatar :alt="$actor['name'] ?? ''" :fallback="$actor['initials'] ?? null" :src="$actor['avatar'] ?? null" size="sm" class="mt-px shrink-0" />
                            @else
                                <span class="mt-px flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
                                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
                                        <path d="{{ $glyphs[$n['icon'] ?? 'shield'] ?? $glyphs['shield'] }}" />
                                    </svg>
                                </span>
                            @endif
                            <span class="flex min-w-0 flex-1 flex-col gap-1">
                                <span class="flex items-baseline justify-between gap-2">
                                    <span class="truncate text-sm font-medium text-foreground">
                                        {{ __($n['title']) }}
                                        @if ($isUnread)
                                            <span @if ($id !== '') x-show="isUnread(@js($id))" @endif class="ms-1 inline-block size-2 -translate-y-px rounded-full bg-ring" aria-hidden="true"></span>
                                            <span @if ($id !== '') x-show="isUnread(@js($id))" @endif class="sr-only">{{ __('Unread') }}</span>
                                        @endif
                                    </span>
                                    <span class="shrink-0 text-xs text-muted-foreground">{{ __($n['timestamp']) }}</span>
                                </span>
                                <span class="truncate text-sm text-muted-foreground">{{ __($n['description']) }}</span>
                            </span>
                        </{{ $tag }}>
                        @if ($isUnread && $id !== '' && filled($markReadUrl))
                            <button
                                type="button"
                                data-slot="notification-panel-mark-read"
                                x-show="isUnread(@js($id))"
                                @click="markRead(@js($id))"
                                :disabled="pending.includes(@js($id))"
                                aria-label="{{ __('Mark :title as read', ['title' => __($n['title'])]) }}"
                                class="me-2 mt-2 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring disabled:opacity-50 motion-reduce:transition-none"
                            >
                                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
                            </button>
                        @endif
                    </li>
                @endforeach
            </ul>

            @if ($markAllUrl || $viewAllUrl)
                <div class="flex items-center justify-between gap-2 border-t border-border px-4 py-2 text-xs">
                    @if ($markAllPosts)
                        <button type="button" data-slot="notification-panel-mark-all" @click="markAll()" :disabled="unread.length === 0 || busy" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50">{{ __('Mark all as read') }}</button>
                    @elseif ($markAllUrl)
                        <a href="{{ $markAllUrl }}" class="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">{{ __('Mark all as read') }}</a>
                    @else
                        <span></span>
                    @endif
                    @if ($viewAllUrl)
                        <a href="{{ $viewAllUrl }}" class="font-medium text-foreground underline-offset-4 hover:underline">{{ __('View all') }}</a>
                    @endif
                </div>
            @endif
        </div>
    </div>

    <p role="status" aria-live="polite" class="sr-only" x-text="status"></p>
</div>
resources/js/ui/notification-panel.js JS
/**
 * Notification Panel behaviour (collapsible notification list).
 *
 * A header that summarises the unread count with a chevron toggle; pressing it
 * expands/collapses the list. The collapse is pure CSS (a grid-template-rows
 * 0fr→1fr transition in the Blade), so this layer only tracks `open`.
 *
 * Read state: `unread` holds the ids the server rendered as unread. When the
 * Blade is given `markReadUrl` / a POSTing `markAllUrl`, `markRead(id)` and
 * `markAll()` flip the rows optimistically, POST JSON to the host (CSRF token
 * from `<meta name="csrf-token">`), and revert on failure. `status` feeds the
 * polite live region. No timers, listeners or observers, hence nothing to
 * tear down.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiNotificationPanel', (config = {}) => ({
        open: Boolean(config.open ?? false),
        unread: Array.isArray(config.unread) ? config.unread.map(String) : [],
        pending: [],
        busy: false,
        status: '',
        markReadUrl: config.markReadUrl || null,
        markAllUrl: config.markAllUrl || null,
        labels: {
            read: 'Marked as read.',
            all: 'All notifications marked as read.',
            failed: 'Could not update the notification. Try again.',
            ...(config.labels || {}),
        },

        toggle() {
            this.open = !this.open;
        },

        isUnread(id) {
            return this.unread.includes(String(id));
        },

        csrfToken() {
            return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
        },

        /** POST a JSON body to the host; resolves on 2xx, throws otherwise. */
        async post(url, body) {
            const response = await fetch(url, {
                method: 'POST',
                credentials: 'same-origin',
                headers: {
                    'Content-Type': 'application/json',
                    Accept: 'application/json',
                    'X-Requested-With': 'XMLHttpRequest',
                    'X-CSRF-TOKEN': this.csrfToken(),
                },
                body: JSON.stringify(body),
            });
            if (!response.ok) {
                throw new Error(`Request failed with ${response.status}`);
            }
        },

        /** Optimistically mark one notification read; revert if the host refuses. */
        async markRead(id) {
            const key = String(id);
            if (!this.isUnread(key) || this.pending.includes(key)) return;
            const before = this.unread;
            this.unread = before.filter((entry) => entry !== key);
            this.status = this.labels.read;
            if (!this.markReadUrl) return;
            this.pending = [...this.pending, key];
            try {
                await this.post(this.markReadUrl, { id: key });
            } catch {
                this.unread = before;
                this.status = this.labels.failed;
            } finally {
                this.pending = this.pending.filter((entry) => entry !== key);
            }
        },

        /** Optimistically clear every unread row; revert if the host refuses. */
        async markAll() {
            if (this.unread.length === 0 || this.busy) return;
            const before = this.unread;
            this.unread = [];
            this.status = this.labels.all;
            if (!this.markAllUrl) return;
            this.busy = true;
            try {
                await this.post(this.markAllUrl, { ids: before });
            } catch {
                this.unread = before;
                this.status = this.labels.failed;
            } finally {
                this.busy = false;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0