Notifications
In-app notification surfaces — a stacked feed panel, a single content card, a dense inbox row, and a corner count badge.
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
- Ada Lovelace mentioned you ENG-142 Sign-in fails when the session cookie has expired Unread
- Grace Hopper assigned you ENG-139 Move invoice export to a queued job Unread
- Deploy to production finished Release 3.2.0 is live
{{-- 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
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.
-
resources/views/components/ui/notification-row.blade.php -
resources/views/components/ui/notification-row/action.blade.php
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.
# 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: 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>
{{-- 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
{{-- 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
{{-- 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>
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
{{--
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 }}>
{{--
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