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.0.0
Stability
stable
License
MIT
Related
Notification Badge
Notification Panel
Notification Card
Issue List
Issue Row
Infinite Scroll
Relative Time
Avatar

Notification Row — One dense inbox row like Linear's: the actor's avatar or an icon with an unread dot, the actor and action as the row's link, the target on a second line, a relative or snooze time that hover and focus swap for quiet action buttons, and an optional selection checkbox. Unread, read, snoozed, current and disabled states; it joins issue-list's keyboard cursor.

Preview

Disabled
previews.components.notification-row.default.blade.php Blade
{{-- Rows are list items: put them in a <ul role="list">. Hover or focus a
     row to swap its time for the quiet actions; they never open the row. --}}
<ul role="list" aria-label="{{ __('Inbox') }}" class="w-full max-w-2xl divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
    <x-ui.notification-row id="n-1" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#n-1" :time="now()->subMinutes(5)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
            <x-ui.notification-row.action icon="snooze" />
        </x-slot:actions>
    </x-ui.notification-row>
    <x-ui.notification-row id="n-2" :actor="['name' => 'Grace Hopper']" :action="__('assigned you')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#n-2" :time="now()->subHours(3)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
            <x-ui.notification-row.action icon="snooze" />
        </x-slot:actions>
    </x-ui.notification-row>
    <x-ui.notification-row id="n-3" :action="__('Deploy to production finished')" :target="__('Release 3.2.0 is live')" href="#n-3" :time="now()->subDay()->toIso8601String()">
        <x-slot:icon>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7" stroke-linecap="round" stroke-linejoin="round" /></svg>
        </x-slot:icon>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
        </x-slot:actions>
    </x-ui.notification-row>
</ul>

Installation

terminal
php artisan ui:add notification-row

Registry contract

php artisan ui:add notification-row 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-row.blade.php
  • blade resources/views/components/ui/notification-row/action.blade.php
Registry dependencies
notification-badge avatar checkbox button relative-time
Packages
composer: jml/brok:^0.2

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-row.md
# Brok UI: Notification Row (`notification-row`)

One dense inbox row like Linear's: the actor's avatar or an icon with an unread dot, the actor and action as the row's link, the target on a second line, a relative or snooze time that hover and focus swap for quiet action buttons, and an optional selection checkbox. Unread, read, snoozed, current and disabled states; it joins issue-list's keyboard cursor.

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

## Install

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

## Usage

```blade
{{-- Rows are list items: put them in a <ul role="list">. Hover or focus a
     row to swap its time for the quiet actions; they never open the row. --}}
<ul role="list" aria-label="{{ __('Inbox') }}" class="w-full max-w-2xl divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
    <x-ui.notification-row id="n-1" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#n-1" :time="now()->subMinutes(5)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
            <x-ui.notification-row.action icon="snooze" />
        </x-slot:actions>
    </x-ui.notification-row>
    <x-ui.notification-row id="n-2" :actor="['name' => 'Grace Hopper']" :action="__('assigned you')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#n-2" :time="now()->subHours(3)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
            <x-ui.notification-row.action icon="snooze" />
        </x-slot:actions>
    </x-ui.notification-row>
    <x-ui.notification-row id="n-3" :action="__('Deploy to production finished')" :target="__('Release 3.2.0 is live')" href="#n-3" :time="now()->subDay()->toIso8601String()">
        <x-slot:icon>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7" stroke-linecap="round" stroke-linejoin="round" /></svg>
        </x-slot:icon>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
        </x-slot:actions>
    </x-ui.notification-row>
</ul>
```

## Props

- `id` (string|int|null, default `null`) — The notification id (data-row-id), used by issue-list for the cursor, selection and events. Null uses key, then a hash of the actor, action and target.
- `actor` (string|array|null, default `null`) — Who acted: a name, or { name, src|avatar, initials }. It draws the avatar and starts the title line in a heavier weight. Null (a system notification) draws a bell, or the icon slot.
- `action` (string|null, default `null`) — What happened, after the actor's name on the title line: mentioned you, assigned you, changed the status to Done. Translate it; it truncates to one line.
- `key` (string|null, default `null`) — The target's human key, in monospace at the start of the second line, for example ENG-142.
- `target` (string|null, default `null`) — The target's title on the second line, muted and truncated, for example the issue title.
- `href` (string|null, default `null`) — Where the row opens. With href the title line is an <a>; without it a <button type="button"> (issue-list still dispatches issue-open).
- `time` (string|DateTimeInterface|null, default `null`) — When it happened. An ISO date time or a DateTimeInterface renders as relative-time (5m ago, refreshed in the browser, the full date in its title); any other text prints as given.
- `unread` (bool, default `false`) — Unread: the notification-badge dot on the avatar, a heavier foreground title and Unread in the link's description, so colour is never the only cue. Read rows use the normal weight and the muted colour.
- `snoozedUntil` (string|DateTimeInterface|null, default `null`) — Snoozed: an ISO date time. The time cell shows a clock and the snooze time (weekday and time within six days, else the date); the title and the link's description say "Snoozed until" with the full date. data-snoozed marks the row.
- `current` (bool|page, default `false`) — The opened row of a list-and-detail view: an accent ground, data-current and aria-current on the link ("true", or "page" when the detail has its own URL).
- `disabled` (bool, default `false`) — The row cannot be opened or acted on: the link keeps aria-disabled="true" with no href, the actions slot is not rendered, the checkbox is disabled and the row is dimmed. Inside issue-list the cursor still reaches it, but it opens nothing.
- `selectable` (bool, default `false`) — Renders the neutral selection checkbox at the start (a 24 px target), for bulk actions. Inside a selectable issue-list the row inherits it through @aware.
- `selected` (bool, default `false`) — The first paint of the selection: data-selected and a checked box. Inside issue-list the list owns it after that.
- `name` (string|null, default `null`) — The checkbox posts as name[] with the row id as its value. Inside issue-list the row inherits the list's name through @aware.
- `as` (li|div, default `li`) — The row element. Rows are list items: put them in a <ul role="list">. Use div for a single row outside a list.
- `icon` (string, default `done`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — Declared by @props in the registry Blade source.

## Use when

- Use to show state, status, notification, or lightweight social feedback near the relevant object.
- An inbox page lists notifications or mentions as dense rows that open a record, with an unread mark, a relative time and quick actions such as Mark as done and Snooze.
- A list-and-detail inbox: the opened notification's row carries current (aria-current) while the detail shows beside the list.
- A keyboard-first inbox: put the rows in an issue-list for the roving cursor, j/k, arrows into the actions and x selection, and add infinite-scroll after the list for older notifications.

## Avoid when

- Do not overload small status elements with explanations that should remain visible in text.
- A dropdown or panel of recent notifications with its own header and Mark all as read: use notification-panel.
- One notification shown on its own as a raised card with a message and buttons: use notification-card.
- A transient message that appears and goes: use toaster.
- A work item with state, priority, labels and assignee: use issue-row.

## Anti-patterns

- Using color or icon alone to communicate state

## Rules

- Use the `<brok:notification-row>` tag (or `<x-ui.notification-row>`) 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-row
- Registry JSON (files, props, contract): https://brokui.dev/r/open/notification-row.json

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

Examples

disabled.blade.php Blade
{{-- Disabled: the link keeps aria-disabled with no href, the actions are
     not rendered and the checkbox is disabled. --}}
<ul role="list" aria-label="{{ __('Inbox') }}" class="w-full max-w-2xl divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
    <x-ui.notification-row id="x-1" disabled selectable :actor="['name' => 'Former member']" :action="__('mentioned you')" key="ENG-101" :target="__('This issue was deleted')" href="#x-1" :time="now()->subWeeks(2)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
        </x-slot:actions>
    </x-ui.notification-row>
</ul>
inbox.blade.php Blade
{{-- A keyboard-first inbox: the rows sit in an issue-list, which gives them
     one tab stop, j/k and arrows to move, ArrowRight into the actions, x to
     select and Escape to clear. The list's hint names notifications. --}}
<div class="w-full max-w-2xl">
    <x-ui.issue-list selectable name="notifications" :label="__('Inbox')" :hint="__('Use J and K or the arrow keys to move between notifications, Enter to open, the right arrow to reach the actions, X to select and Escape to clear the selection.')">
        <ul role="list" class="divide-y divide-border">
            <x-ui.notification-row id="i-1" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#i-1" :time="now()->subMinutes(5)->toIso8601String()" unread>
                <x-slot:actions>
                    <x-ui.notification-row.action icon="done" />
                    <x-ui.notification-row.action icon="snooze" />
                </x-slot:actions>
            </x-ui.notification-row>
            <x-ui.notification-row id="i-2" :actor="['name' => 'Grace Hopper']" :action="__('assigned you')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#i-2" :time="now()->subHours(3)->toIso8601String()" unread>
                <x-slot:actions>
                    <x-ui.notification-row.action icon="done" />
                    <x-ui.notification-row.action icon="snooze" />
                </x-slot:actions>
            </x-ui.notification-row>
            <x-ui.notification-row id="i-3" :actor="['name' => 'Alan Turing']" :action="__('commented')" key="ENG-128" :target="__('Show the billing address on credit notes')" href="#i-3" :time="now()->subDays(2)->toIso8601String()">
                <x-slot:actions>
                    <x-ui.notification-row.action icon="done" />
                    <x-ui.notification-row.action icon="unread" />
                </x-slot:actions>
            </x-ui.notification-row>
        </ul>
    </x-ui.issue-list>
</div>
list-and-detail.blade.php Blade
{{-- List and detail: the opened notification's row carries current, which
     sets aria-current on its link and the accent ground. --}}
<div class="grid w-full max-w-4xl gap-4 md:grid-cols-2">
    <ul role="list" aria-label="{{ __('Inbox') }}" class="divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
        <x-ui.notification-row id="d-1" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#d-1" :time="now()->subMinutes(5)->toIso8601String()" current="page" />
        <x-ui.notification-row id="d-2" :actor="['name' => 'Grace Hopper']" :action="__('assigned you')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#d-2" :time="now()->subHours(3)->toIso8601String()" unread />
    </ul>
    <section aria-labelledby="notification-detail-title" class="rounded-lg border border-border bg-background p-6">
        <h2 id="notification-detail-title" class="font-medium">{{ __('Sign-in fails when the session cookie has expired') }}</h2>
        <p class="mt-2 text-sm text-muted-foreground">{{ __('Ada Lovelace mentioned you: can you check the cookie lifetime on staging?') }}</p>
    </section>
</div>
long-content.blade.php Blade
{{-- Long names, actions and targets truncate to one line each; the time
     and the actions keep their width. A title in another script truncates
     at its own end (dir="auto"). --}}
<ul role="list" aria-label="{{ __('Inbox') }}" class="w-full max-w-md divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
    <x-ui.notification-row id="l-1" :actor="['name' => 'Maximiliana Wilhelmina van Oldenbarnevelt-Rijckevorsel']" :action="__('changed the status of the issue from In review to Done and left a comment')" key="PLATFORM-10442" :target="__('Investigate why the nightly export of every time entry in the archive times out after thirty minutes')" href="#l-1" :time="now()->subMinutes(42)->toIso8601String()" unread>
        <x-slot:actions>
            <x-ui.notification-row.action icon="done" />
            <x-ui.notification-row.action icon="snooze" />
        </x-slot:actions>
    </x-ui.notification-row>
    <x-ui.notification-row id="l-2" :actor="['name' => 'فاطمة الزهراء']" action="أشارت إليك في تعليق طويل جدًا حول الفواتير" key="ENG-7" target="مراجعة الفواتير الشهرية لجميع العملاء" href="#l-2" :time="now()->subDays(9)->toIso8601String()" />
</ul>
states.blade.php Blade
{{-- Every state: unread (dot, heavier weight, "Unread" in the description),
     read, snoozed (clock and time), selected, current (aria-current) and
     disabled. None of them uses colour alone. --}}
<ul role="list" aria-label="{{ __('Notification states') }}" class="w-full max-w-2xl divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
    <x-ui.notification-row id="s-unread" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#s-unread" :time="now()->subMinutes(5)->toIso8601String()" unread />
    <x-ui.notification-row id="s-read" :actor="['name' => 'Grace Hopper']" :action="__('commented')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#s-read" :time="now()->subHours(2)->toIso8601String()" />
    <x-ui.notification-row id="s-snoozed" :actor="['name' => 'Alan Turing']" :action="__('asked for a review')" key="ENG-128" :target="__('Show the billing address on credit notes')" href="#s-snoozed" :snoozed-until="now()->addDay()->setTime(9, 0)->toIso8601String()" />
    <x-ui.notification-row id="s-selected" selectable selected :actor="['name' => 'Katherine Johnson']" :action="__('closed')" key="ENG-125" :target="__('Round VAT per rate instead of per line')" href="#s-selected" :time="now()->subDays(2)->toIso8601String()" />
    <x-ui.notification-row id="s-current" current="page" :actor="['name' => 'Margaret Hamilton']" :action="__('changed the due date')" key="ENG-150" :target="__('Write the upgrade guide for version 3')" href="#s-current" :time="now()->subDays(3)->toIso8601String()" />
    <x-ui.notification-row id="s-disabled" disabled :actor="['name' => 'Former member']" :action="__('mentioned you')" key="ENG-101" :target="__('This issue was deleted')" href="#s-disabled" :time="now()->subWeeks(2)->toIso8601String()" />
</ul>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
id string | int | null null The notification id (data-row-id), used by issue-list for the cursor, selection and events. Null uses key, then a hash of the actor, action and target.
actor string | array | null null Who acted: a name, or { name, src|avatar, initials }. It draws the avatar and starts the title line in a heavier weight. Null (a system notification) draws a bell, or the icon slot.
action string | null null What happened, after the actor's name on the title line: mentioned you, assigned you, changed the status to Done. Translate it; it truncates to one line.
key string | null null The target's human key, in monospace at the start of the second line, for example ENG-142.
target string | null null The target's title on the second line, muted and truncated, for example the issue title.
href string | null null Where the row opens. With href the title line is an <a>; without it a <button type="button"> (issue-list still dispatches issue-open).
time string | DateTimeInterface | null null When it happened. An ISO date time or a DateTimeInterface renders as relative-time (5m ago, refreshed in the browser, the full date in its title); any other text prints as given.
unread bool false Unread: the notification-badge dot on the avatar, a heavier foreground title and Unread in the link's description, so colour is never the only cue. Read rows use the normal weight and the muted colour.
snoozedUntil string | DateTimeInterface | null null Snoozed: an ISO date time. The time cell shows a clock and the snooze time (weekday and time within six days, else the date); the title and the link's description say "Snoozed until" with the full date. data-snoozed marks the row.
current bool | page false The opened row of a list-and-detail view: an accent ground, data-current and aria-current on the link ("true", or "page" when the detail has its own URL).
disabled bool false The row cannot be opened or acted on: the link keeps aria-disabled="true" with no href, the actions slot is not rendered, the checkbox is disabled and the row is dimmed. Inside issue-list the cursor still reaches it, but it opens nothing.
selectable bool false Renders the neutral selection checkbox at the start (a 24 px target), for bulk actions. Inside a selectable issue-list the row inherits it through @aware.
selected bool false The first paint of the selection: data-selected and a checked box. Inside issue-list the list owns it after that.
name string | null null The checkbox posts as name[] with the row id as its value. Inside issue-list the row inherits the list's name through @aware.
as li | div li The row element. Rows are list items: put them in a <ul role="list">. Use div for a single row outside a list.
icon string done Declared by @props in the registry Blade source.
label mixed | null null Declared by @props in the registry Blade source.

Slots

  • icon — A source icon (an svg) in a muted circle instead of the actor's avatar, for system and integration notifications. It wins over the avatar; the unread dot still sits on it.
  • actions — Quiet row actions shown on hover and focus in place of the time, above the stretched link, so a click never opens the row. Use <x-ui.notification-row.action icon="done|snooze|read|unread|archive|unsubscribe" wire:click="…" />, or any button. Not rendered when disabled.
  • x-ui.notification-row.action — Installed subcomponent from the registry item.

Renders as li, div.

Data slots

Stable hooks for CSS overrides and browser tests.

notification-row notification-row-action notification-row-actions notification-row-actor notification-row-body notification-row-icon notification-row-key notification-row-link notification-row-media notification-row-meta notification-row-select notification-row-snoozed notification-row-target notification-row-time notification-row-title

Behavior

  • The title line is the only link. Its ::after covers the row, so a click anywhere opens the notification; the checkbox and the actions sit above it (z-10) and never nest inside the <a>.
  • The time and the actions share one cell at the end: hover on the row, or focus anywhere in it, fades the time out and the actions in (opacity only, so nothing moves). On a touch screen (pointer: coarse) both show side by side. Without actions the time always shows.
  • <x-ui.notification-row.action icon label>: a ghost icon button with a spoken name and a title (Mark as done, Snooze, Mark as read, Mark as unread, Archive, Unsubscribe by default). Its attributes reach the <button>, so wire:click and x-on:click work; it is never the primary colour.
  • Keyboard outside a list: the link and each action are tab stops in reading order. Inside an issue-list (slot mode with a <ul role="list">, or issue-list groups) the row follows the list row contract (data-list-row, data-row-id, data-row-link, data-row-select): the link keeps the only tab stop per list, j/k and the arrow keys move the cursor, ArrowRight and ArrowLeft reach the actions, Enter opens, x selects and issue-open { id, href } fires. Pass issue-list a hint about notifications.
  • States: unread (data-state=unread), read, snoozed (data-snoozed), selected (data-selected, the accent ground), current (data-current, the accent ground), the keyboard cursor (data-focused from issue-list, a lighter accent ground) and disabled (data-disabled, dimmed).
  • It is a list item, not a live notification: no card surface, no shadow and no role=status.
  • Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Status and lightweight feedback

Communicate concise object or system state.

Use when

  • Use to show state, status, notification, or lightweight social feedback near the relevant object.
  • An inbox page lists notifications or mentions as dense rows that open a record, with an unread mark, a relative time and quick actions such as Mark as done and Snooze.
  • A list-and-detail inbox: the opened notification's row carries current (aria-current) while the detail shows beside the list.
  • A keyboard-first inbox: put the rows in an issue-list for the roving cursor, j/k, arrows into the actions and x selection, and add infinite-scroll after the list for older notifications.

Avoid when

  • Do not overload small status elements with explanations that should remain visible in text.
  • A dropdown or panel of recent notifications with its own header and Mark all as read: use notification-panel.
  • One notification shown on its own as a raised card with a message and buttons: use notification-card.
  • A transient message that appears and goes: use toaster.
  • A work item with state, priority, labels and assignee: use issue-row.

Use instead

  • Persistent explanatory text

Anti-patterns

  • Using color or icon alone to communicate state
Anatomy
notification-row notification-row-select notification-row-media notification-row-icon notification-row-body notification-row-title notification-row-link notification-row-actor notification-row-target notification-row-key notification-row-meta notification-row-time notification-row-snoozed notification-row-actions
Theming hooks
notification-badge (the neutral dot) avatar checkbox (neutral tone) button (ghost actions) relative-time row grounds: hover:bg-accent/50, data-[focused]:bg-accent/60, data-[selected] and data-[current]:bg-accent

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
managed
  • The link's name is the actor and action; its aria-describedby adds Unread or Snoozed until (full date), the key and target, and the time, so a cursor move reads the whole row. The avatar and dot are aria-hidden.
  • Unread is shown by the dot, the heavier weight and the spoken Unread, never by colour alone.
  • The opened row exposes aria-current on its link. The selection checkbox is named "Select <actor action key>" in a 24 by 24 px target; each action has a spoken name and a title.
  • The time is a <time datetime> (relative-time) with the full date as its title. Logical properties mirror the row under dir="rtl"; the title and target carry dir="auto" and the key stays left to right.
  • 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-row-{{ $record->id }}">
    {{-- Rows are list items: put them in a <ul role="list">. Hover or focus a
         row to swap its time for the quiet actions; they never open the row. --}}
    <ul role="list" aria-label="{{ __('Inbox') }}" class="w-full max-w-2xl divide-y divide-border overflow-clip rounded-lg border border-border bg-background">
        <x-ui.notification-row id="n-1" :actor="['name' => 'Ada Lovelace']" :action="__('mentioned you')" key="ENG-142" :target="__('Sign-in fails when the session cookie has expired')" href="#n-1" :time="now()->subMinutes(5)->toIso8601String()" unread>
            <x-slot:actions>
                <x-ui.notification-row.action icon="done" />
                <x-ui.notification-row.action icon="snooze" />
            </x-slot:actions>
        </x-ui.notification-row>
        <x-ui.notification-row id="n-2" :actor="['name' => 'Grace Hopper']" :action="__('assigned you')" key="ENG-139" :target="__('Move invoice export to a queued job')" href="#n-2" :time="now()->subHours(3)->toIso8601String()" unread>
            <x-slot:actions>
                <x-ui.notification-row.action icon="done" />
                <x-ui.notification-row.action icon="snooze" />
            </x-slot:actions>
        </x-ui.notification-row>
        <x-ui.notification-row id="n-3" :action="__('Deploy to production finished')" :target="__('Release 3.2.0 is live')" href="#n-3" :time="now()->subDay()->toIso8601String()">
            <x-slot:icon>
                <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7" stroke-linecap="round" stroke-linejoin="round" /></svg>
            </x-slot:icon>
            <x-slot:actions>
                <x-ui.notification-row.action icon="done" />
            </x-slot:actions>
        </x-ui.notification-row>
    </ul>
</div>

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-row.blade.php Blade
{{--
    Notification Row: one dense row of an inbox list, like Linear's inbox.

    Parts from the logical start: an optional selection checkbox, the source
    (the actor's avatar, or the `icon` slot) with an unread dot from
    notification-badge, a title line (actor and action, the row's link), a
    secondary line (the target: a key and a title), and at the end the
    relative time, or the snooze time, which hover and focus swap for the
    `actions` slot.

    The title is the only link. Its ::after covers the whole row (the
    "stretched link"), so a click anywhere opens the notification, while the
    checkbox and the actions sit above it and stay separate controls: nothing
    interactive is ever nested inside the <a>.

    It is a list item, not a live notification: no card surface and no
    role="status". Put rows in a <ul role="list">. Inside an issue-list the
    row follows the list row contract (data-list-row, data-row-id,
    data-row-link, data-row-select), so the list gives it the roving tab
    stop, j/k, arrows into the actions and selection; `selectable` and `name`
    then come from the list through @aware.
--}}
@aware([
    'selectable' => false,
    'name' => null,
])

@props([
    // The notification id, used by issue-list for the cursor, selection and events (data-row-id).
    'id' => null,
    // The actor: a name, or { name, src|avatar, initials }. Null for a system notification.
    'actor' => null,
    // What happened, after the actor's name: "mentioned you", "assigned you".
    'action' => null,
    // The target's human key, in monospace: ENG-142.
    'key' => null,
    // The target's title on the secondary line.
    'target' => null,
    // Where the row opens. Null renders the title as a button.
    'href' => null,
    // When it happened: an ISO date time (a relative time) or ready text.
    'time' => null,
    'unread' => false,
    // An ISO date time: the row shows the snooze time instead of the time.
    'snoozedUntil' => null,
    // The opened row in a list-and-detail view: true or "page" (aria-current).
    'current' => false,
    'disabled' => false,
    'selectable' => false,
    'selected' => false,
    'name' => null,
    // li (in a <ul role="list">) | div (a single row outside a list)
    'as' => 'li',
])

@php
    $tag = in_array($as, ['li', 'div'], true) ? $as : 'li';
    $unread = filter_var($unread, FILTER_VALIDATE_BOOLEAN);
    $disabled = filter_var($disabled, FILTER_VALIDATE_BOOLEAN);
    $selectable = filter_var($selectable, FILTER_VALIDATE_BOOLEAN);
    $selected = filter_var($selected, FILTER_VALIDATE_BOOLEAN);
    $currentValue = $current === 'page' ? 'page' : (filter_var($current, FILTER_VALIDATE_BOOLEAN) ? 'true' : null);

    $actorName = is_array($actor) ? ($actor['name'] ?? null) : $actor;
    $actorName = is_scalar($actorName) && (string) $actorName !== '' ? (string) $actorName : null;
    $actorSrc = is_array($actor) ? ($actor['src'] ?? $actor['avatar'] ?? null) : null;
    $actorInitials = is_array($actor) && filled($actor['initials'] ?? null)
        ? (string) $actor['initials']
        : ($actorName !== null
            ? mb_strtoupper(collect(preg_split('/\s+/u', trim($actorName)) ?: [])->filter()->take(2)->map(fn ($part) => mb_substr($part, 0, 1))->implode(''))
            : '');
    $action = filled($action) ? (string) $action : null;
    $key = filled($key) ? (string) $key : null;
    $target = filled($target) ? (string) $target : null;

    $rowId = (string) ($id ?? $key ?? 'notification-'.substr(md5(($actorName ?? '').'|'.($action ?? '').'|'.($target ?? '')), 0, 8));
    $baseId = 'notification-row-'.substr(md5($rowId), 0, 10);

    // A parsable time renders as relative-time; any other text prints as given.
    $parse = function (mixed $value): ?\Illuminate\Support\Carbon {
        if ($value instanceof \DateTimeInterface) {
            return \Illuminate\Support\Carbon::instance($value);
        }
        if (! is_string($value) || trim($value) === '' || preg_match('/^\d{4}-\d{2}-\d{2}/', trim($value)) !== 1) {
            return null;
        }
        try {
            return \Illuminate\Support\Carbon::parse($value);
        } catch (\Throwable) {
            return null;
        }
    };
    $timeAt = $parse($time);
    $timeText = $timeAt === null && is_scalar($time) && (string) $time !== '' ? (string) $time : null;

    $snoozedAt = $parse($snoozedUntil)?->locale(app()->getLocale());
    $snoozedText = $snoozedAt === null ? null : ($snoozedAt->between(now(), now()->addDays(6)->endOfDay())
        ? $snoozedAt->isoFormat('ddd LT')
        : $snoozedAt->isoFormat('ll'));
    $snoozedFull = $snoozedAt?->isoFormat('LLLL');

    $status = implode(', ', array_filter([
        $unread ? __('Unread') : null,
        $snoozedAt !== null ? __('Snoozed until :time', ['time' => $snoozedFull]) : null,
    ]));
    $hasActions = isset($actions) && ! $actions->isEmpty() && ! $disabled;

    $describedBy = implode(' ', array_filter([
        $status !== '' ? $baseId.'-status' : null,
        $key !== null || $target !== null ? $baseId.'-target' : null,
        $snoozedAt === null && ($timeAt !== null || $timeText !== null) ? $baseId.'-time' : null,
    ]));
    $linkClasses = 'min-w-0 truncate text-start outline-none after:absolute after:inset-0 focus-visible:after:ring-[length:var(--ring-width)] focus-visible:after:ring-inset focus-visible:after:ring-ring'
        .($unread ? ' font-medium text-foreground' : ' text-muted-foreground')
        .($disabled ? ' cursor-not-allowed' : '');
    // Hover or focus in the row swaps the time for the actions; a touch
    // screen (no hover) shows both side by side.
    $swap = 'transition-opacity duration-fast ease-standard motion-reduce:transition-none';
@endphp

<{{ $tag }}
    data-slot="notification-row"
    data-list-row
    data-row-id="{{ $rowId }}"
    data-state="{{ $unread ? 'unread' : 'read' }}"
    @if ($snoozedAt !== null) data-snoozed="true" @endif
    @if ($currentValue !== null) data-current="true" @endif
    @if ($selected) data-selected="true" @endif
    @if ($disabled) data-disabled="true" @endif
    {{ $attributes->merge(['class' => 'group/row relative flex min-h-14 min-w-0 items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-accent/50 data-[focused]:bg-accent/60 data-[selected]:bg-accent data-[current]:bg-accent data-[disabled]:opacity-60 data-[disabled]:hover:bg-transparent motion-reduce:transition-none']) }}
>
    @if ($selectable)
        {{-- A 24px target around the 16px box (WCAG 2.5.8), above the link. --}}
        <label data-slot="notification-row-select" data-row-select class="relative z-10 -ms-1 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center">
            <x-ui.checkbox
                :id="$baseId.'-select'"
                :name="filled($name) ? $name.'[]' : null"
                :value="$rowId"
                :checked="$selected"
                :disabled="$disabled"
                :aria-label="__('Select :notification', ['notification' => trim(($actorName ?? '').' '.($action ?? '').' '.($key ?? $target ?? ''))])"
            />
        </label>
    @endif

    {{-- Decorative: the unread state is in the link's description. --}}
    <span data-slot="notification-row-media" aria-hidden="true" class="inline-flex shrink-0">
        <x-ui.notification-badge :dot="$unread" inset>
            @isset($icon)
                <span data-slot="notification-row-icon" class="inline-flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-4">{{ $icon }}</span>
            @elseif ($actorName !== null)
                <x-ui.avatar size="sm" :src="$actorSrc" alt="" :fallback="$actorInitials" />
            @else
                <span data-slot="notification-row-icon" class="inline-flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4">
                        <path d="M4 6.5a4 4 0 0 1 8 0c0 3.5 1.5 4.5 1.5 4.5h-11S4 10 4 6.5ZM6.8 13.5a1.4 1.4 0 0 0 2.4 0" stroke-linecap="round" stroke-linejoin="round" />
                    </svg>
                </span>
            @endif
        </x-ui.notification-badge>
    </span>

    <span data-slot="notification-row-body" class="flex min-w-0 flex-1 flex-col">
        <span data-slot="notification-row-title" class="flex min-w-0">
            @if ($disabled)
                <a role="link" aria-disabled="true" dir="auto" data-slot="notification-row-link" data-row-link @if ($describedBy !== '') aria-describedby="{{ $describedBy }}" @endif class="{{ $linkClasses }}">
            @elseif (filled($href))
                <a href="{{ $href }}" dir="auto" data-slot="notification-row-link" data-row-link @if ($currentValue !== null) aria-current="{{ $currentValue }}" @endif @if ($describedBy !== '') aria-describedby="{{ $describedBy }}" @endif class="{{ $linkClasses }}">
            @else
                <button type="button" dir="auto" data-slot="notification-row-link" data-row-link @if ($currentValue !== null) aria-current="{{ $currentValue }}" @endif @if ($describedBy !== '') aria-describedby="{{ $describedBy }}" @endif class="{{ $linkClasses }}">
            @endif
                @if ($actorName !== null)<span data-slot="notification-row-actor" @class(['font-semibold' => $unread, 'font-medium text-foreground' => ! $unread])>{{ $actorName }}</span>@endif
                @if ($action !== null)<span data-slot="notification-row-action">{{ $action }}</span>@endif
            @if ($disabled || filled($href))
                </a>
            @else
                </button>
            @endif
        </span>
        @if ($key !== null || $target !== null)
            <span id="{{ $baseId }}-target" data-slot="notification-row-target" class="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground">
                @if ($key !== null)
                    <span data-slot="notification-row-key" class="shrink-0 font-mono tabular-nums" dir="ltr">{{ $key }}</span>
                @endif
                @if ($target !== null)
                    <span class="min-w-0 truncate" dir="auto">{{ $target }}</span>
                @endif
            </span>
        @endif
    </span>

    @if ($snoozedAt !== null || $timeAt !== null || $timeText !== null || $hasActions)
        <span data-slot="notification-row-meta" class="grid shrink-0 items-center justify-items-end text-xs text-muted-foreground pointer-coarse:flex pointer-coarse:gap-2">
            @if ($snoozedAt !== null || $timeAt !== null || $timeText !== null)
                <span @class([
                    'col-start-1 row-start-1 inline-flex items-center whitespace-nowrap',
                    $swap => $hasActions,
                    'group-hover/row:opacity-0 group-focus-within/row:opacity-0 pointer-coarse:!opacity-100' => $hasActions,
                ])>
                    @if ($snoozedAt !== null)
                        <span data-slot="notification-row-snoozed" title="{{ __('Snoozed until :time', ['time' => $snoozedFull]) }}" class="inline-flex items-center gap-1">
                            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-3">
                                <circle cx="8" cy="8" r="5.5" />
                                <path d="M8 5v3l2 1.5" stroke-linecap="round" stroke-linejoin="round" />
                            </svg>
                            <span aria-hidden="true">{{ $snoozedText }}</span>
                        </span>
                    @elseif ($timeAt !== null)
                        <x-ui.relative-time id="{{ $baseId }}-time" :datetime="$timeAt->toIso8601String()" />
                    @else
                        <span id="{{ $baseId }}-time" data-slot="notification-row-time" class="tabular-nums">{{ $timeText }}</span>
                    @endif
                </span>
            @endif
            @if ($hasActions)
                <span data-slot="notification-row-actions" @class([
                    'relative z-10 col-start-1 row-start-1 inline-flex items-center gap-1 opacity-0 pointer-events-none',
                    'group-hover/row:pointer-events-auto group-hover/row:opacity-100 group-focus-within/row:pointer-events-auto group-focus-within/row:opacity-100',
                    'pointer-coarse:pointer-events-auto pointer-coarse:opacity-100',
                    $swap,
                ])>{{ $actions }}</span>
            @endif
        </span>
    @endif

    @if ($status !== '')
        <span id="{{ $baseId }}-status" class="sr-only">{{ $status }}</span>
    @endif
</{{ $tag }}>
resources/views/components/ui/notification-row/action.blade.php Blade
{{--
    Notification Row Action: one icon button for the row's `actions` slot
    (Mark as done, Snooze, Mark as read). A quiet ghost button with a
    spoken name and a tooltip title; attributes reach the <button>, so
    wire:click="done({{ $id }})" or x-on:click work. It sits above the row's
    stretched link, so a click never opens the notification.
--}}
@props([
    // done | snooze | read | unread | archive | unsubscribe
    'icon' => 'done',
    // The spoken name and tooltip. Null: the translated default of the icon.
    'label' => null,
])

@php
    $icons = [
        'done' => ['path' => 'm3.5 8.5 3 3 6-7', 'label' => __('Mark as done')],
        'snooze' => ['path' => 'M8 4.5V8l2.5 1.5M8 13.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Z', 'label' => __('Snooze')],
        'read' => ['path' => 'M2.5 8s2-4.5 5.5-4.5S13.5 8 13.5 8 11.5 12.5 8 12.5 2.5 8 2.5 8ZM8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z', 'label' => __('Mark as read')],
        'unread' => ['path' => 'M13.5 6.5v5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h6M10.5 4a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z', 'label' => __('Mark as unread')],
        'archive' => ['path' => 'M2.5 3.5h11v3h-11zM3.5 6.5v6h9v-6M6.5 9h3', 'label' => __('Archive')],
        'unsubscribe' => ['path' => 'M4 6.5a4 4 0 0 1 8 0c0 3.5 1.5 4.5 1.5 4.5h-11S4 10 4 6.5ZM2.5 2.5l11 11', 'label' => __('Unsubscribe')],
    ];
    $icon = is_string($icon) && isset($icons[$icon]) ? $icon : 'done';
    $label = filled($label) ? (string) $label : $icons[$icon]['label'];
@endphp

<x-ui.button
    variant="ghost"
    size="sm"
    :icon-label="$label"
    :title="$label"
    data-notification-action="{{ $icon }}"
    {{ $attributes }}
>
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" focusable="false" class="size-4">
        <path d="{{ $icons[$icon]['path'] }}" stroke-linecap="round" stroke-linejoin="round" />
    </svg>
</x-ui.button>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.0
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