NbNotificationCenter is the bell in the shell topbar: a trigger carrying an unread count, and an anchored panel listing what happened while you were working.
It is a container, not a feed. It holds no store, fetches nothing, polls nothing, marks nothing as read and knows nothing about your domain. Three Nubisco consoles each wrote this bell independently, and the parts that differed were the parts that should differ: what a notification is, where it came from, and what tapping it does. Everything the three agreed on, the trigger, the count, the dropdown, the dismissal rules, the focus order, the read/unread distinction, is here. One of those three files carries a comment saying it copied another console's placement so the two products would not disagree; that is the coordination this component makes unnecessary.
Where the evidence on this page comes from
Claims about "the audit" refer to a review of the twelve applications that consume this library (cms, keystone, verba, openbridge, platform, analytics, tally, openbridge-marketplace, stagewright, planetcraft, stratos, and one white-label client product). That review is a report about those repositories and is not checked into this one, so do not go looking for it here. What it found in this repository is checkable here, and is linked wherever it is used.
<template>
<NbNotificationCenter
:items="items"
:loading="pending"
@select="go($event)"
@mark-all-read="markAll()"
/>
</template>Two ways to supply rows
The items array is the short path. A row is a title, an optional body, an optional time and a read flag, which covers the majority of what the three consoles were rendering.
The default slot is the escape hatch, for rows that carry an avatar, a diff, a pair of buttons or anything else the array cannot describe. Compose them from NbNotificationCenterItem, which is the same row the array path renders, so a product that outgrows items keeps its geometry, its read/unread treatment and its place in the keyboard order.
<template>
<NbNotificationCenter :unread-count="unread" @mark-all-read="markAll()">
<NbNotificationCenterItem
v-for="event in events"
:key="event.id"
:read="event.seen"
:time="event.createdAt"
:href="`/deploys/${event.deployId}`"
variant="warning"
>
<template #media>
<img class="avatar" :src="event.actor.avatarUrl" alt="" />
</template>
<template #title>
<strong>{{ event.actor.name }}</strong> rolled back
{{ event.service }}
</template>
Rollback finished in {{ event.duration }}s.
<template #actions>
<NbButton size="sm" variant="ghost" @click="mute(event)">Mute</NbButton>
</template>
</NbNotificationCenterItem>
</NbNotificationCenter>
</template>Two numbers move to you when you use the slot, and both for the same reason: the centre renders your slot, it does not read it, so it has no array to count.
unreadCount, or the badge and the trigger's accessible name both say zero.itemCount, or the centre cannot tell an empty feed from a slot you chose not to fill.
itemCount is the one that decides which of the four answers the panel gives, so leaving it out has consequences worth stating plainly rather than burying:
With itemCount | Without it |
|---|---|
itemCount: 0 renders #empty. | #empty never renders. The centre has no way to know the feed is empty. |
error with rows shows the stale strip over them. | error always resolves to the full role="alert" error region. |
The second row is a deliberate choice and not an accident: with the count unknown, assuming the slot has rows would let a failed fetch render as content, which is the exact defect this component was built to end. So a failure wins an unknown count. A dev-mode warning is printed the first time a panel opens with a filled default slot and no itemCount.
<NbNotificationCenter
:unread-count="feed.unread"
:item-count="feed.rows.length"
:loading="feed.pending"
:error="feed.failed"
>
<NbNotificationCenterItem v-for="row in feed.rows" :key="row.id" ... />
</NbNotificationCenter>loading and error are props and stay props: they are never inferred from your markup.
Slotted rows still take part in the keyboard model and still emit select. A row that carries the data-nb-notification-item attribute and is a control is picked up by the roving focus and, when activated, emits select with a null item and the original event. Null because the centre never saw an item object for that row: you already have the object in your own v-for, so bind your handler there when you want it typed, and use the centre's event when you only need "a row was activated" (closing the panel, logging, marking seen). NbNotificationCenterItem sets the attribute for you, on the rows that are controls. What counts as one is the next section, and it is a short list.
Rows you press, and rows you only read
A row is a control or it is text. There is no third thing, and the difference is not a style.
NbNotificationCenterItem renders a <button> by default, an <a> when you give it href, and a plain element when you say :interactive="false". That plain element is inert: no tabindex, no place in the arrow-key order, no select, no pointer cursor. Set it when the row is an announcement rather than a destination, and the reader is told nothing that turns out to be untrue.
<template>
<!-- Pressable rows, each going somewhere. -->
<NbNotificationCenter :items="items" @select="go($event)" />
<!-- A feed with nowhere to go: rows are read, not pressed. -->
<NbNotificationCenter :items="events" :interactive="false" />
<!-- Mixed, per row. The item wins over the centre. -->
<NbNotificationCenter
:items="[
{ id: 1, title: 'Deploy finished', href: '/deploys/91' },
{ id: 2, title: 'Quota reset for September', interactive: false },
]"
/>
</template>This component shipped once with the opposite behaviour, and it is worth being explicit about why it was wrong. interactive: false used to render a <div> that still carried a tabindex, still took focus from the roving index, and still emitted select on click, Enter and Space. That is a focusable, activatable widget with no role, no name and no value: WCAG 4.1.2 Name, Role, Value fails outright, a screen reader announces a blank stop in the middle of a list, and the prop meant the opposite of what it said. An inert row is now inert in the accessibility tree, in the keyboard order and in the event contract, all three, or it is not inert at all.
An inert list is still a list you can reach
Take every control out of a scrolling box and you have made a region a keyboard cannot enter, which means content below the fold that a keyboard user cannot read. So when the list holds nothing focusable, the list itself becomes the tab stop: Tab reaches it, the arrow keys and Page Down scroll it, and it takes its place in the panel's tab cycle. One activatable row anywhere in the list and the extra stop disappears again, because the arrows already reach the bottom through the rows.
What the centre will not do for a slotted row
In the default slot the markup is yours, so the attribute is an opt-in and the centre checks what it was put on. It accepts a <button> that is not disabled, an <a href>, and any element carrying role="button", "link", "menuitem" or "option". Anything else keeps the attribute and gets nothing: no tabindex, no select, and a console warning in development saying which element and what to do about it.
<!-- Picked up: a control by construction. -->
<li><button data-nb-notification-item>Deploy finished</button></li>
<!-- Picked up: you supplied the role, so you own the key handling too,
and the centre supplies Enter and Space on top of it. -->
<li>
<div role="button" tabindex="0" data-nb-notification-item>Deploy finished</div>
</li>
<!-- Ignored, and warned about: nothing here is a control. -->
<li><div data-nb-notification-item>Deploy finished</div></li>The last case is not pedantry. Handing that <div> a tabindex is the same defect as the one above, only authored one repository further out, where nobody in this library would ever see it.
The count
The badge is capped, the accessible name is not. A 15px circle has room for 99+; a screen reader has room for the truth, so the button announces "Notifications, 142 unread" while the badge reads 99+. The badge itself is aria-hidden, because otherwise the same number is announced twice, once truthfully and once capped.
<template>
<!-- Counted from the array: every item where read is not true. -->
<NbNotificationCenter :items="items" />
<!-- Or stated outright, for a paged feed whose server knows a total the
loaded page does not. -->
<NbNotificationCenter :items="page" :unread-count="meta.unread" />
</template>formatUnread builds the unread half of that name. The default is English and counts in the two forms English has. Replace it with your t() call in any product that ships another language, because Portuguese, Polish and Arabic do not pluralise on n === 1.
<NbNotificationCenter :format-unread="(n) => t('notifications.unread', n)" />Read and unread
Three cues, deliberately, because one is never enough:
- a dot at the leading edge, which is the scannable one,
- a heavier title and a tinted row, which survive being seen through a colour-vision deficiency or a bad monitor,
- the visually hidden word Unread or Read at the start of the row, which is the only one a screen reader gets.
The dot is one shape in every variant. variant colours the leading icon (info, success, warning, error), not the unread affordance, so unread stays a thing you learn once rather than five things that all mean the same.
Mark all as read
The centre emits mark-all-read and does nothing else. It never mutates items: your list is your data, and a component that quietly flipped flags on it would be lying to the store that owns them. Until you push a new array back, the rows stay exactly as they were, which is also what makes an optimistic update and a failed request distinguishable.
The control disables itself at zero unread rather than disappearing, so the panel does not change shape as you use it.
A mark-all that hits the network is not instant, so say so with markAllPending. While it is true the control shows a spinner and markAllPendingLabel, swallows further clicks, and announces itself once from a role="status" region. It is aria-disabled, not disabled, on purpose: the user pressed that button, so it has focus, and adding a disabled attribute to a focused button drops focus to <body> for the length of the request. Without this prop, the audit's pattern repeats: a slow server, no feedback, and four identical requests.
<template>
<NbNotificationCenter
:items="items"
:mark-all-pending="marking"
@mark-all-read="markAll()"
/>
</template>
<script setup lang="ts">
const marking = ref(false)
async function markAll() {
marking.value = true
try {
items.value = await api.markAllRead()
} catch (error) {
toast.error('Could not mark everything as read')
} finally {
marking.value = false
}
}
</script>An optimistic update is the other legitimate answer: push the read flags into items at once, leave markAllPending false, and put them back if the request fails. What is not an answer is doing neither, which is what "the control is only disabled at zero unread" used to mean.
The label is Mark all as read. Across the fleet the same commit intent is currently written as Save, Done, Apply and Create in different products; this one is fixed because a bell in two consoles that disagree on the wording is exactly the drift this component exists to end.
The four answers: loading, failed, empty, and a list
A panel that has been asked for notifications can be in exactly one of four states, and they must never share an element. One audited app rendered a single .empty-text for both loading and empty, so a failed fetch read as "no contacts yet". All four are props here, none is an override of another's slot, and the component picks between them in one place so no future edit can make two of them true at once.
| State | You set | The panel renders |
|---|---|---|
| Loading | loading | Three NbSkeleton rows, aria-busy, one announcement. |
| Failed | error, and no rows | NbEmptyState kind="error" in a role="alert" region, with a retry. |
| Empty | neither, and no rows | NbEmptyState with emptyTitle and emptyDescription. |
| List | rows | The scrolling list. |
loading wins over everything: a request that has not come back is not yet a failure, and it is certainly not a result. The empty state cannot appear while either loading or error is true, which is the whole point.
"Rows" means the length of items, or itemCount when you supply rows through the default slot. Fill the slot without itemCount and the count is unknown, which is a third answer and not a zero: the panel then treats error as the failure state (never as the stale strip) and cannot reach the empty state at all. Two ways to supply rows has the table.
Failure is a state, not a flavour of empty
Give the panel error and it stops guessing. It says the request failed, it offers retryLabel, and it emits retry when the user takes it up; the component fetches nothing, so retry is the whole of its part in the recovery.
<script setup lang="ts">
const { data, pending, error, refresh } = useNotifications()
</script>
<template>
<NbNotificationCenter
:items="data"
:loading="pending"
:error="Boolean(error)"
error-title="Could not load notifications"
error-description="The activity service did not answer."
@retry="refresh()"
/>
</template>Note what does not happen: no offer to create the first notification, no "you're all caught up". NbEmptyState's kind="error" exists precisely so a view that could not load never invites you to make something.
A refresh that fails over rows you already have
If the panel already has rows and a background refresh fails, throwing those rows away to show a full-panel error would lose the notifications the user was reading. So it keeps them and says so above them, in a role="status" strip rather than a role="alert" one: nothing was lost, nothing is urgent, the list is just older than it looks.
If your product's failure needs more than a title, a sentence and a retry, #error replaces the whole region and still receives the retry callback so your own control keeps the same event contract.
<template #error="{ retry }">
<NbEmptyState kind="error" size="sm" title="Activity is offline">
<template #actions>
<NbButton size="sm" @click="retry()">Try again</NbButton>
<NbButton size="sm" variant="ghost" href="/status">Status page</NbButton>
</template>
</NbEmptyState>
</template>Replacing the bell
The #trigger slot swaps the button for anything you like, and it hands you the four things you cannot work out for yourself:
| Slot prop | What it is for |
|---|---|
triggerProps | id, aria-haspopup="dialog", aria-expanded, aria-controls and a default aria-label carrying the uncapped count. Spread it. |
setTriggerRef | Template ref setter. This is how Escape knows where to send focus back to. |
panelId | The teleported panel's id, if you would rather wire aria-controls yourself. |
toggle, show, close, open, unreadCount, badge | Behaviour and state, so your trigger can look like anything. |
<template #trigger="{ triggerProps, setTriggerRef, toggle, unreadCount }">
<NbButton
:ref="setTriggerRef"
v-bind="triggerProps"
variant="ghost"
size="sm"
icon="bell"
@click="toggle"
>
Activity<template v-if="unreadCount"> ({{ unreadCount }})</template>
</NbButton>
</template>aria-label comes last in triggerProps, so bind your own after the spread to replace it. Everything else should stay: a trigger without aria-expanded does not tell a screen reader whether the panel is open, and one without setTriggerRef leaves the panel unable to give focus back, which is a WCAG 2.4.3 Focus Order failure, not a cosmetic one. If you skip setTriggerRef anyway, the panel falls back to the first focusable element inside its own root, which in practice is your trigger. That is a safety net, not the contract.
Opening, dismissing, and focus
The behaviour is the one NbMenu and NbUserMenu already ship, on purpose: the panel is teleported to <body> and positioned from the trigger's viewport rect, because every shell that hosts a bell clips its topbar; a press outside closes it; Escape closes it.
Being honest about the placement code, since the audit's whole complaint is duplication: this is a fourth implementation of anchored positioning in the library, after NbMenu, NbUserMenu and utils/anchorPosition.helper.ts. It does not call that helper because the helper centres a surface on its anchor's cross axis, and a 360px panel centred on a 32px bell hangs half off the right edge of every topbar in the fleet: this panel aligns to a trigger edge. What it does borrow is the helper's actual argument, that placement is easy to get subtly wrong and impossible to eyeball, so the arithmetic is an exported pure function (placeNotificationPanel) with tests over the edge alignment, the viewport clamp, the flip and the list-height floor. If placeAnchored grows an align option, this function should collapse into a call to it.
| Key | Result |
|---|---|
| Enter / Space | On the trigger, opens and moves focus to the first row. On a row, activates it. |
| ↓ / ↑ | Move between rows. The list does not wrap, so the ends stay findable. Inside a field in one of the slots the arrows belong to the field, not to the list. |
| Home / End | First and last row, when focus is already on a row. |
| Tab | Moves through the panel's controls and then out of it, closing it. Forward off the last control lands on whatever follows the trigger in the page; Shift+Tab off the first lands back on the trigger. |
| Escape | Closes, from anywhere in the panel including a field. Focus returns to the trigger when it was inside the panel. Not taken when a surface layered above the panel already answered the key: see below. |
The whole list is one tab stop, not one per notification: arrows move the roving focus inside it. Forty notifications between the bell and the next control in the topbar would be forty presses of Tab.
Tab leaves the panel rather than looping inside it, and this changed after review. The panel is a role="dialog" without aria-modal: there is no scrim, nothing behind it is inert, and a screen reader's virtual cursor can read the page underneath freely. Trapping Tab in a surface like that is a contradiction, and it left a keyboard user able to reach the bell and then leave only by Escape. The APG rule for a non-modal dialog is that Tab moves out of it and moving out dismisses it, so that is what happens. The one correction the panel makes is where out is: it is teleported to the end of <body>, so the browser's own next stop is nothing near the bell, and the two edges are handled explicitly instead.
An Escape a nested surface already answered
A row action or a #footer link can open something that is itself dismissible: an NbModal, an NbConfirm, a teleported NbSelect listbox. That surface is nearer the user, so its Escape is its own, and one keystroke must not cost two dialogs. The centre stands down on two tests: event.defaultPrevented, for anything that consumed the key outright, and containment in an [aria-modal="true"] element that is not the panel, which is how NbModal and NbConfirm mark themselves (Modal.vue's own topmost check reads the same attribute) and which they do not preventDefault on.
The arrows are only taken while focus is on a row or on a non-field control. An <input>, <textarea>, <select> or contenteditable inside any of the slots keeps its own arrow keys, because ↑ in a filter box moves the caret and jumping the user into the list instead is a bug they cannot work around. A filter field in the header is a supported composition, so this is a contract, not an accident. Put it in #header-start, which adds to the header, rather than #header-actions, which replaces it. See Adding to the header.
Focus returns to the trigger when the panel had focus, so Escape and the toggle both put you back where you started. Three cases deliberately leave focus alone:
- You dismissed by clicking somewhere else. You already said where you wanted to be, and dragging focus back to the bell would undo that.
- Escape arrived while focus was somewhere else on the page. The panel is not modal, so the page underneath is live and that keystroke is usually not about the bell at all: the realistic sender is a nested overlay opened from a row action or from
#footer(anNbModal, a teleportedNbSelectlistbox) dismissing itself in the same tick. The panel goes; the caret stays. The exception is the trigger already holding focus, where "leave it alone" and "put it back" are the same thing. - The trigger scrolled out of view, or a navigation happened. By then the user is somewhere else.
Adding to the header
#header-start sits beside the mark-all control and costs you nothing:
<template>
<NbNotificationCenter :items="items" @mark-all-read="markAll">
<template #header-start>
<NbSelect
v-model="filter"
variant="fluid"
label="Filter"
name="notification-filter"
:options="filters"
/>
</template>
</NbNotificationCenter>
</template>#header-actions is the other one, and it is a trade: it replaces the mark-all control entirely, so you own the pending rules described in Mark all as read. You do not have to re-derive them. The slot hands you markAllProps, which is everything the built-in button binds including onClick, and markAllText, which is the label that swaps while a request is in flight:
<template #header-actions="{ markAllProps, markAllText }">
<NbButton size="sm" variant="ghost" v-bind="markAllProps">
{{ markAllText }}
</NbButton>
</template>Reach for #header-actions when the mark-all control itself has to change. Reach for #header-start for everything else.
Two centres on one page
Two bells in one shell is a reasonable thing to build: a product centre beside an account one, or a tenant-scoped one beside a platform one. Two open panels is not. They overlap, each cycles Tab within itself, and the one underneath is unreachable, so opening one dismisses any other that is open. Focus is not moved when that happens: you are on the bell you just pressed, and pulling you to the other one would be the same theft the component refuses everywhere else.
The arbitration is per page, not per app, and it works through the same update:open every other close goes through. A host that fully controls open and ignores update:open can therefore still force two open at once. That is your decision to make and the centre will not fight you for it, but nothing below the top of the stack will be reachable by keyboard.
Placement
align picks which trigger edge the panel lines up with (end for a bell at the right of a topbar, start for one in a left rail), width and maxHeight size it. The panel clamps itself inside the viewport, shortens its list when there is not enough room, and flips above the trigger only when below is genuinely cramped, so the same bell does not open in a different direction on every scroll position. It sits at --nb-zindex-menu, the same layer as the other two panels.
Only the list scrolls. The header and the footer stay put, so Mark all as read and your "See all activity" link cannot be pushed off the bottom of a long feed.
<template>
<NbNotificationCenter
:items="items"
align="start"
:width="320"
:max-height="320"
>
<template #footer="{ close }">
<NbButton
size="sm"
variant="ghost"
@click="
close()
go('/activity')
"
>
See all activity
</NbButton>
</template>
</NbNotificationCenter>
</template>Leaving the page
A bell lives in a shell that outlives the view under it, so its panel can survive things it should not survive. Two of those are handled here:
- Navigation. The panel closes on a history navigation (back, forward, a hash change) and on anything the browser reports through the Navigation API, which is how a
pushStaterouter change is seen where the browser supports it. SetcloseOnNavigatetofalseif you want it to stay. In a router that predates that API, the reliable belt-and-braces is one line in your shell: watch the route and callclose({ restoreFocus: false })on the exposed instance. Pass the option: a bareclose()restores focus when the panel happened to have it, and on a route change that is a focus jump into the old page's shell that the user did not ask for. - A trigger that scrolls away. The panel is
position: fixed, so once the bell it hangs off has left the viewport the panel is floating over content it has nothing to do with. When that happens it closes rather than pinning itself to an edge. Focus is left where it is, because by then the user is somewhere else.
<template>
<NbNotificationCenter ref="bell" :items="items" />
</template>
<script setup lang="ts">
const bell = ref()
const route = useRoute()
watch(
() => route.fullPath,
() => bell.value?.close({ restoreFocus: false }),
)
</script>Announcing arrivals
The panel is not a live region and the count is not announced when it changes. A bell that speaks every time something lands interrupts the work the user is actually doing, and the arrival of a notification is rarely worth that.
When an event genuinely needs to interrupt, raise an NbToast for it and let it also land in the centre, so the toast is the interruption and the panel is the record. Reserve role="alert" for what would be worth a person tapping you on the shoulder.
When a notification centre is the wrong answer
The container is cheap; the feed behind it is not. Before you add one, check that the answer to all three is yes:
- Does anything arrive while the user is not looking? If every event in your product is the direct result of a button the user just pressed, an
NbToastor anNbInlineLoadingbeside that button says it better, and a bell that only ever mirrors your own clicks trains people to stop opening it. - Is there more than one source? One source with a natural home (a comment thread, a build page, a run log) is better read where it lives. A centre earns its place when four subsystems all need somewhere to speak and none of them owns the screen.
- Can the user act on it, or is it a log? A record of everything that happened is an audit trail, and an audit trail belongs on a page with filters, dates and paging, not in a 384px panel.
And what does not belong in the feed, whichever way you answered:
- Anything blocking. If the user cannot proceed until they respond, that is an
NbConfirmor anNbModal, in front of them, now. A bell badge is not consent. - Anything catastrophic and current. A production incident, an expired card, a locked account: those are an
NbBanneron the page they affect. A person who has not opened the panel has not been told. - Per-keystroke or per-row chatter. One notification per saved field, per uploaded file, per synced record. Batch to the unit the user thinks in ("14 files uploaded"), or do not send it.
Two rules for what you do send:
- Newest first, and group what belongs together. Twelve rows saying "Ana commented" are one row saying "Ana commented 12 times on Pricing page". Grouping is the host's job, done before the array reaches this component, because only the host knows what "the same thing" means.
- Let people turn categories off. A feed nobody can tune is a feed everybody ignores, and the first thing an ignored bell costs you is the one notification that mattered. WCAG 2.2.4 Interruptions (AAA) asks for exactly that, a way to postpone or suppress what interrupts. The preferences screen is not part of this component, but shipping the bell without one is how alert fatigue starts.
On timing: nothing here disappears on a timer, which is deliberate. A notification the user has to be quick to read fails WCAG 2.2.1 Timing Adjustable, and content that moves or updates under a reader's cursor fails 2.2.2 Pause, Stop, Hide. Rows stay until the data behind them changes, and the panel only closes when the user, the navigation or a vanished trigger says so.
Copy
labelnames the trigger and titles the panel. Keep it a noun: Notifications, Activity, Inbox.- Titles are one line, no trailing full stop. The body is a sentence and gets one.
- Never write the count into the label yourself. It is already in the accessible name, and "Notifications (3), 3 unread" is what doubling it sounds like.
unreadLabelandreadLabelare read aloud, not shown. They are props because they need translating, not because they need styling.
Theme
Every colour is a token: --nb-c-layer-3 and --nb-c-layer-border-3 for the panel, --nb-c-danger with --nb-c-danger-a11y for the badge, --nb-c-primary for the unread dot and the mark-all control, --nb-c-focus-ring for focus, --nb-c-text, --nb-c-text-muted and --nb-c-text-subtle for the three levels of type. The unread tint is a color-mix of --nb-c-primary into transparency, so it follows whatever a white-label deployment sets primary to and it works on both grounds. No colour in this component names a brand ramp, so nothing purple leaks into a white-label build.
The one value that is not a token is the panel's elevation, 0 12px 32px rgba(0, 0, 0, 0.18), which is what NbUserMenu uses byte for byte. There is no shadow token in src/styles to point at yet, so it is behind an override instead: set --nb-notification-center-shadow anywhere that reaches <body> and the panel takes it. The library's own .dark theme does exactly that, because the same black elevation over a dark ground reads as nothing at all. When a real shadow token arrives, that default becomes a var() and nothing else about this changes.
Windows high contrast
Forced colours throw away every background, every fill and every shadow, and repaint text, borders and outlines from the user's own palette. Three things in this component were carried by colour alone and would have gone with it, so each is redrawn under @media (forced-colors: active) in the one currency the mode keeps, a border:
| What | Normally | Under forced colours |
|---|---|---|
| The panel's edge | A shadow over --nb-c-layer-3 | A 1px CanvasText border, no shadow. |
| The unread badge | A --nb-c-danger fill | Canvas with a CanvasText border and CanvasText digits. |
| An unread row | A 6% --nb-c-primary tint and a dot | A Highlight leading edge and a bordered Highlight dot. |
The unread row is the one that mattered. Its three cues are a tint, a dot and a heavier title, and forced colours removes the first two: that would have left font weight, which is not a reliable difference, and the visually hidden unreadLabel, which only a screen reader hears. A sighted high-contrast user would have seen read and unread as identical rows.
Server rendering
NbNotificationCenterItem formats time relative to the clock, and a server and a browser do not read the same clock: a row rendered as "just now" is "2 minutes ago" by the time the client hydrates, and Vue reports the mismatch. So the relative form waits for onMounted, and until then the row shows the absolute date and time in the same locale, which is true at both ends. Pass timeLabel instead and the centre renders your string untouched at both ends, which is the escape hatch if the one-tick swap is not acceptable to you. The panel itself is teleported and shut on the server, so the server renders the trigger and nothing else.
Accessibility
- The trigger is a
<button>witharia-haspopup="dialog",aria-expanded, andaria-controlspointing at the panel while it is open. Its accessible name carries the uncapped unread count. - The badge is
aria-hidden, because its number is the capped one. - A trigger you supply through
#triggergets the same guarantees, but only if you take them:triggerPropscarries the ARIA relationship andsetTriggerRefcarries the focus return. This is the one place where the escape hatch can cost you something the component otherwise owns, so it is spelled out in Replacing the bell rather than left to be discovered. - The failure state is a
role="alert"region, so a request that did not come back is announced rather than sitting there waiting to be found. A refresh that failed over rows already on screen isrole="status"instead: it is a correction, not an emergency. - The panel is
role="dialog"labelled by its heading. It is deliberately notaria-modal: there is no scrim, the page behind it stays live, and it closes when you click there. Because of that it does not hold Tab: tabbing off either end leaves the panel and closes it, forward onto whatever follows the trigger and backward onto the trigger itself. A focus trap belongs to a surface that has made the rest of the page inert, and this one has not. - Escape is not taken when a surface layered above the panel already answered it, so an
NbConfirmopened from a row action closes without taking the centre with it. - The list carries an explicit
role="list"and each row an explicitrole="listitem". Both are needed: WebKit strips list semantics from a list styled withlist-style: none, which every list in this library is, and without them VoiceOver never says "list, 12 items".NbReorderListcarries the same explicitrole="list", though not the item role, so this is the library's convention taken one step further rather than a local invention. - Focus moves to the first row on open, or to the panel itself when there is nothing to read, so the dialog title and the empty state are both announced.
- Arrow keys move the roving focus only when focus is on a row or on a non-field control. A field in any slot keeps its own arrows; only Escape is taken from it.
- Read state is a word for assistive technology, not only a dot and a tint.
- A row that leads nowhere is not a button.
interactive: falserenders it as a plain element with no tabindex, outside the roving focus, emitting nothing: inert in the accessibility tree, the keyboard order and the event contract at once. A row that is focusable and activatable but has no role is a WCAG 4.1.2 failure, and it is the failure this component was fixed for. - When a list holds no control at all, the scrolling list takes the tab stop, so the region can still be entered and scrolled from the keyboard. With one control in it, the extra stop is dropped.
- A slotted row is only taken into the keyboard order if it is genuinely a control: a
<button>, an<a href>, or an explicitroleof button, link, menuitem or option. Anything else is ignored and warned about in development rather than dressed up as a control the browser never agreed to. - Per-row controls live outside the row's own button or link. A button inside a button is invalid, and some browsers make the inner one unreachable.
- Times render as
<time>with a machine-readabledatetimebeside the human wording, and the wording comes fromIntl.RelativeTimeFormat, so "yesterday" arrives in the reader's language. - The mark-all control is
aria-disabledwhile its request is in flight rather thandisabled, so it keeps the focus the user gave it, and the pending label is announced from arole="status"region rather than relying on a silent label swap. - Under
prefers-reduced-motion: reducethe open and close transition is dropped. The loading placeholders follow whateverNbSkeletondoes under that preference, because they areNbSkeleton. - Under
forced-colors: activethe unread row keeps a marker: see Theme.
What this component will never do
Fetch, poll, subscribe, cache, mark read, deduplicate or decide what deserves a notification. Those are product decisions and they were correctly different in all three consoles. If a future version grows a store, it will be a separate composable that this component still knows nothing about.