Feedback
Notification center
A bell with an unread count that opens a panel of grouped notifications to read, mark and dismiss.
- Built on
Popover APIpopovertargetrole=status<time>- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.5 kB
- Built from
- ButtonIconPopoverEmpty state
Notifications
- Invoice 2041 was paid2 days ago
Code
"use client";
import { useState } from "react";
import { NotificationCenter, type NotificationItem } from "@/components/equip/notification-center";
const start: NotificationItem[] = [
{ id: "1", group: "Today", unread: true, title: "Priya Raman mentioned you", text: "Can you check the refund copy?", time: "5 min ago", href: "#threads/refunds" },
{ id: "2", group: "Today", unread: true, title: "Build 4821 failed", text: "atlas-web on eu-west-1", time: "40 min ago", href: "#builds/4821" },
{ id: "3", group: "Earlier", unread: true, title: "Sam Lee requested your review", text: "Pull request 482", time: "Yesterday", href: "#pulls/482" },
{ id: "4", group: "Earlier", title: "Invoice 2041 was paid", time: "2 days ago", href: "#invoices/2041" },
];
export default function Demo() {
const [items, setItems] = useState(start);
const set = (id: string, unread: boolean) =>
setItems((list) => list.map((item) => (item.id === id ? { ...item, unread } : item)));
return (
<NotificationCenter
items={items}
onMarkRead={(id) => set(id, false)}
onMarkUnread={(id) => set(id, true)}
onMarkAllRead={() => setItems((list) => list.map((item) => ({ ...item, unread: false })))}
onDismiss={(id) => setItems((list) => list.filter((item) => item.id !== id))}
/>
);
}Examples
Opening an item marks it read
Without href, titles are buttons that call onOpenItem; Arrow up and down move between items while Tab still reaches every action.
Alerts
- Free space runs out in about 3 days at this rate.
Code
"use client";
import { useState } from "react";
import { NotificationCenter, type NotificationItem } from "@/components/equip/notification-center";
const start: NotificationItem[] = [
{ id: "a", group: "Today", unread: true, title: "Ingrid Holm approved the fraud checks spec", time: "09:41", dateTime: "2026-03-12T09:41" },
{ id: "b", group: "Today", unread: true, title: "Disk usage on db-eu-2 is above 85%", text: "Free space runs out in about 3 days at this rate.", time: "08:12", dateTime: "2026-03-12T08:12" },
{ id: "c", group: "This week", title: "Kwame Mensah added you to Billing", time: "Monday", dateTime: "2026-03-09T10:00" },
];
export default function Example() {
const [items, setItems] = useState(start);
const [opened, setOpened] = useState("Nothing opened yet.");
const read = (id: string) => setItems((list) => list.map((item) => (item.id === id ? { ...item, unread: false } : item)));
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<NotificationCenter
title="Alerts"
placement="bottom-start"
items={items}
onOpenItem={(id) => {
read(id);
setOpened(`Opened: ${items.find((item) => item.id === id)?.title ?? id}`);
}}
onMarkRead={read}
onMarkAllRead={() => setItems((list) => list.map((item) => ({ ...item, unread: false })))}
onDismiss={(id) => setItems((list) => list.filter((item) => item.id !== id))}
/>
<span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)", minWidth: 0, overflowWrap: "anywhere" }}>{opened}</span>
</div>
);
}Nothing new
With no items the bell has no badge, its label is just Notifications, and the panel shows an empty state.
Notifications
You are all caught up
Mentions, reviews and alerts will show up here.
Code
import { NotificationCenter } from "@/components/equip/notification-center";
export default function Example() {
return <NotificationCenter items={[]} placement="bottom-start" />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
items | { id: string; title: string; text?: ReactNode; time: string; dateTime?: string; group?: string; unread?: boolean; href?: string }[] | none | The notifications, newest first. Items with the same group are listed under one heading in order. |
onMarkRead | (id: string) => void | none | Called when an unread item is marked read. |
onMarkUnread | (id: string) => void | none | Called when a read item is marked unread. Without it read items have no toggle. |
onMarkAllRead | () => void | none | Shows Mark all as read in the header. |
onDismiss | (id: string) => void | none | Shows a dismiss button on each item. |
onOpenItem | (id: string) => void | none | Called when an item's title is activated. Without href, the title becomes a button. |
title | string | "Notifications" | Heading of the panel and name of the bell. |
placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | "bottom-end" | Where the panel opens against the bell. |
empty | ReactNode | none | Replaces the default empty state. |
...rest | ComponentProps<"div"> | none | Every native div attribute, applied to the root. |
Anatomy
| root | .eq-notification-center | |
|---|---|---|
| bell | .eq-notification-center-bell | A ghost Button with popovertarget, named Notifications, 3 unread. |
| count | .eq-notification-center-count | The solid unread badge. Hidden from assistive tech; the bell's name says the number. |
| status | .eq-notification-center-status | Visually hidden polite live region beside the bell. |
| hidden | .eq-notification-center-hidden | Visually hidden Unread prefix on unread titles. |
| panel | .eq-notification-center-panel | The Popover, role="dialog", named by its heading. |
| header | .eq-notification-center-header | |
| title | .eq-notification-center-title | An h2. |
| mark all | .eq-notification-center-all | |
| body | .eq-notification-center-body | Scrolls when the list is long. |
| empty | .eq-notification-center-empty | Holds an EmptyState when there are no items. |
| group | .eq-notification-center-group | |
| group label | .eq-notification-center-group-label | Visible label; the list carries it as aria-label. |
| list | .eq-notification-center-list | |
| item | .eq-notification-center-item | data-unread when unread. |
| dot | .eq-notification-center-dot | The unread marker, a solid dot that grows in. |
| content | .eq-notification-center-content | |
| open | .eq-notification-center-open | The title link or button; its ::after makes the whole row clickable. |
| name | .eq-notification-center-name | |
| text | .eq-notification-center-text | |
| time | .eq-notification-center-time | |
| actions | .eq-notification-center-actions | Above the row link, so they stay clickable. |
| action | .eq-notification-center-action | Mark as read or unread, and Dismiss. |
Keyboard
| EnterSpace | On the bell, opens or closes the panel. |
|---|---|
| Tab | Moves through Mark all as read, then each item's title, read toggle and dismiss button. |
| Arrow downArrow up | Inside the list, moves focus to the next or previous item's title. |
| HomeEnd | Inside the list, moves focus to the first or last item. |
| Escape | Closes the panel and returns focus to the bell. |
Notes
- Changes to the unread count are announced politely by a visually hidden status beside the bell, outside the panel, so they are heard even while it is closed; the count the page loads with is not announced.
- The bell is named Notifications, 3 unread, so the number never depends on the badge.
- The unread badge and dot use the solid fill, never the accent, and unread titles carry a hidden Unread prefix.
- After a dismiss, focus moves to the item that took its place, or to the panel when none is left, so it never falls to the page.
- The unread badge is 16px with the small radius rather than the 20px badge size, so it fits the corner of a 34px bell; past 99 it reads 99+.
- Mark all as read stays mounted and becomes aria-disabled at zero unread, and when a toggle or dismiss removes the focused button, focus moves to the row now in its place.
- Arrow keys only stop propagation while focus is on an item, so the site's shortcuts still work elsewhere.
- It is fully controlled: the part never changes items itself, it calls your handlers.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/notification-center.tsx287 lines
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Button } from "./button";
import { EmptyInbox, EmptyState } from "./empty-state";
import { Icon } from "./icon";
import { Popover, type PopoverPlacement } from "./popover";
import "./notification-center.css";
export type NotificationItem = {
id: string;
/** Who or what, and what happened, e.g. "Priya Raman mentioned you". */
title: string;
/** A line of detail under the title. */
text?: ReactNode;
/** When, already formatted, e.g. "5 min ago". */
time: string;
/** ISO date for a time element. */
dateTime?: string;
/** The heading it is listed under, e.g. "Today" or "Earlier". Items keep their order within it. */
group?: string;
unread?: boolean;
/** Makes the title a link to the thing it is about. */
href?: string;
};
export type NotificationCenterProps = Omit<ComponentProps<"div">, "children" | "title"> & {
items: NotificationItem[];
/** Called with an id when an item is marked read. */
onMarkRead?: (id: string) => void;
/** Called with an id when a read item is marked unread. Without it, read items have no toggle. */
onMarkUnread?: (id: string) => void;
/** Shows "Mark all as read" in the header. */
onMarkAllRead?: () => void;
/** Shows a dismiss button on each item. */
onDismiss?: (id: string) => void;
/** Called when an item's title is activated. Without href, the title becomes a button. */
onOpenItem?: (id: string) => void;
/** Heading of the panel and name of the bell. */
title?: string;
/** Where the panel opens against the bell. */
placement?: PopoverPlacement;
/** Replaces the default empty state. */
empty?: ReactNode;
};
const ITEM = ".eq-notification-center-item";
/** Focuses an item's title link or button, or the item itself when it has neither. */
function focusItem(row: Element | undefined) {
if (!(row instanceof HTMLElement)) return false;
const open = row.querySelector<HTMLElement>(".eq-notification-center-open");
(open ?? row).focus();
return true;
}
export function NotificationCenter({
items,
onMarkRead,
onMarkUnread,
onMarkAllRead,
onDismiss,
onOpenItem,
title = "Notifications",
placement = "bottom-end",
empty,
className,
...rest
}: NotificationCenterProps) {
const base = useId();
const panelId = `${base}-panel`;
const headingId = `${base}-heading`;
const panel = useRef<HTMLDivElement>(null);
const refocus = useRef<number | null>(null);
const unread = items.filter((item) => item.unread).length;
const [message, setMessage] = useState("");
const announced = useRef(unread);
// Announce changes to the unread count, not the count the page loaded with.
useEffect(() => {
if (announced.current === unread) return;
announced.current = unread;
setMessage(unread === 0 ? "No unread notifications" : `${unread} unread notification${unread === 1 ? "" : "s"}`);
}, [unread]);
// After a dismiss or a toggle removed the focused button, focus the item now in that
// place, or the panel when none is left. Nothing moves while focus is still in the panel.
useEffect(() => {
const index = refocus.current;
refocus.current = null;
const surface = panel.current;
if (index === null || !surface || surface.contains(document.activeElement)) return;
const rows = surface.querySelectorAll(ITEM);
if (!focusItem(rows[Math.min(index, rows.length - 1)])) surface.focus();
}, [items]);
const groups: { label: string; items: { item: NotificationItem; index: number }[] }[] = [];
items.forEach((item, index) => {
const label = item.group ?? "";
const found = groups.find((group) => group.label === label);
if (found) found.items.push({ item, index });
else groups.push({ label, items: [{ item, index }] });
});
function rowIndex(element: Element) {
const row = element.closest(ITEM);
const rows = Array.from(panel.current?.querySelectorAll(ITEM) ?? []);
return row ? Math.max(0, rows.indexOf(row)) : 0;
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
if (!(event.target instanceof Element)) return;
const row = event.target.closest(ITEM);
if (!row) return;
const rows = Array.from(event.currentTarget.querySelectorAll(ITEM));
const at = rows.indexOf(row);
const last = rows.length - 1;
const next =
event.key === "Home" ? 0 : event.key === "End" ? last : event.key === "ArrowDown" ? Math.min(at + 1, last) : Math.max(at - 1, 0);
event.preventDefault();
event.stopPropagation();
focusItem(rows[next]);
}
return (
<div {...rest} className={["eq-notification-center", className].filter(Boolean).join(" ")}>
<Button
className="eq-notification-center-bell"
variant="ghost"
square
popoverTarget={panelId}
aria-label={unread > 0 ? `${title}, ${unread} unread` : title}
lead={<Icon name="bell" size="m" />}
trail={
unread > 0 ? (
<span key={unread} className="eq-notification-center-count" aria-hidden="true">
{unread > 99 ? "99+" : unread}
</span>
) : undefined
}
/>
<span className="eq-notification-center-status" role="status">
{message}
</span>
<Popover
ref={panel}
id={panelId}
className="eq-notification-center-panel"
placement={placement}
role="dialog"
aria-labelledby={headingId}
tabIndex={-1}
>
<div className="eq-notification-center-header">
<h2 className="eq-notification-center-title" id={headingId}>
{title}
</h2>
{onMarkAllRead && items.length > 0 && (
<Button
className="eq-notification-center-all"
size="s"
variant="ghost"
// Stays focusable at zero unread, so focus is never dropped by the click that emptied it.
aria-disabled={unread === 0 || undefined}
onClick={() => {
if (unread > 0) onMarkAllRead();
}}
>
Mark all as read
</Button>
)}
</div>
{items.length === 0 ? (
<div className="eq-notification-center-empty">
{empty ?? (
<EmptyState size="s" icon={<EmptyInbox />} title="You are all caught up">
Mentions, reviews and alerts will show up here.
</EmptyState>
)}
</div>
) : (
<div className="eq-notification-center-body" onKeyDown={handleKeyDown}>
{groups.map((group) => (
<div className="eq-notification-center-group" key={group.label}>
{group.label && (
<div className="eq-notification-center-group-label" aria-hidden="true">
{group.label}
</div>
)}
<ul className="eq-notification-center-list" aria-label={group.label || title}>
{group.items.map(({ item, index }) => {
const nameId = `${base}-name-${index}`;
const name = (
<>
{item.unread && <span className="eq-notification-center-hidden">Unread: </span>}
<span className="eq-notification-center-name" id={nameId}>
{item.title}
</span>
</>
);
const toggle = item.unread ? onMarkRead : onMarkUnread;
return (
<li
key={item.id}
className="eq-notification-center-item"
data-unread={item.unread || undefined}
data-open={item.href || onOpenItem ? "" : undefined}
tabIndex={-1}
>
<span className="eq-notification-center-dot" aria-hidden="true" />
<div className="eq-notification-center-content">
{item.href ? (
<a className="eq-notification-center-open" href={item.href} onClick={() => onOpenItem?.(item.id)}>
{name}
</a>
) : onOpenItem ? (
<button type="button" className="eq-notification-center-open" onClick={() => onOpenItem(item.id)}>
{name}
</button>
) : (
<span>{name}</span>
)}
{item.text != null && <span className="eq-notification-center-text">{item.text}</span>}
{item.dateTime ? (
<time className="eq-notification-center-time" dateTime={item.dateTime}>
{item.time}
</time>
) : (
<span className="eq-notification-center-time">{item.time}</span>
)}
</div>
{(toggle || onDismiss) && (
<div className="eq-notification-center-actions">
{toggle && (
<Button
className="eq-notification-center-action"
size="s"
square
variant="quiet"
aria-label={item.unread ? "Mark as read" : "Mark as unread"}
aria-describedby={nameId}
lead={<Icon name={item.unread ? "check" : "mail"} size="s" />}
onClick={(event) => {
// Without the opposite handler this button goes away; focus then moves to the row.
refocus.current = rowIndex(event.currentTarget);
toggle(item.id);
}}
/>
)}
{onDismiss && (
<Button
className="eq-notification-center-action"
size="s"
square
variant="quiet"
aria-label="Dismiss"
aria-describedby={nameId}
lead={<Icon name="close" size="s" />}
onClick={(event) => {
refocus.current = rowIndex(event.currentTarget);
onDismiss(item.id);
}}
/>
)}
</div>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</Popover>
</div>
);
}components/equip/notification-center.css225 lines
@layer equip {
.eq-notification-center {
display: inline-block;
position: relative;
}
/* Holds the unread count over its corner. */
.eq-notification-center-bell {
position: relative;
}
/* The unread count: a small solid badge over the bell's corner. The bell's label says the number. */
.eq-notification-center-count {
box-sizing: border-box;
position: absolute;
inset-block-start: 1px;
inset-inline-end: 1px;
min-width: 16px;
height: 16px;
padding-inline: 4px;
border-radius: var(--eq-radius-s);
box-shadow: 0 0 0 2px var(--eq-surface);
background: var(--eq-solid);
color: var(--eq-solid-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 16px var(--eq-font);
font-variant-numeric: tabular-nums;
text-align: center;
pointer-events: none;
/* A new count lands: the badge is re-mounted with each number and springs in. */
transition: scale var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-notification-center-count {
scale: 0.6;
}
}
.eq-notification-center-status,
.eq-notification-center-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-popover.eq-notification-center-panel {
width: min(360px, calc(100vw - 2 * var(--eq-space-4)));
max-width: min(360px, calc(100vw - 2 * var(--eq-space-4)));
padding: 0;
overflow: hidden;
font-size: var(--eq-text-s);
line-height: 1.4;
}
.eq-popover.eq-notification-center-panel:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-notification-center-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
min-height: var(--eq-h-l);
padding: var(--eq-space-2) var(--eq-space-2) var(--eq-space-2) var(--eq-space-4);
border-bottom: 1px solid var(--eq-line);
}
.eq-notification-center-title {
margin: 0;
font-size: var(--eq-text-m);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
}
.eq-notification-center-body {
max-height: min(420px, 60dvh);
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--eq-space-2);
}
.eq-notification-center-empty {
padding: var(--eq-space-5) var(--eq-space-4);
}
.eq-notification-center-group + .eq-notification-center-group {
margin-top: var(--eq-space-2);
}
.eq-notification-center-group-label {
padding: var(--eq-space-1) var(--eq-space-2);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.01em;
}
.eq-notification-center-list {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-notification-center-item {
position: relative;
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
column-gap: var(--eq-space-2);
align-items: start;
padding: var(--eq-space-2);
border-radius: var(--eq-radius-m);
transition:
background-color var(--eq-dur) var(--eq-ease),
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-notification-center-item {
opacity: 0;
translate: 0 4px;
}
}
@media (hover: hover) {
.eq-notification-center-item[data-open]:hover {
background: var(--eq-tint);
}
}
.eq-notification-center-item:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Unread is a solid dot, never the accent. Assistive tech hears "Unread" with the title. */
.eq-notification-center-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
scale: 0;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-notification-center-item[data-unread] .eq-notification-center-dot {
scale: 1;
}
.eq-notification-center-content {
display: grid;
gap: 2px;
min-width: 0;
}
.eq-notification-center-open {
justify-self: start;
max-width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 2px;
background: none;
color: inherit;
font: inherit;
text-align: start;
text-decoration: none;
cursor: pointer;
}
/* The whole row opens the item; the action buttons sit above this layer. */
.eq-notification-center-open::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--eq-radius-m);
}
.eq-notification-center-open:focus-visible {
outline: none;
}
.eq-notification-center-open:focus-visible::after {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-notification-center-name {
color: var(--eq-ink);
font-weight: var(--eq-weight);
overflow-wrap: anywhere;
}
.eq-notification-center-item[data-unread] .eq-notification-center-name {
font-weight: var(--eq-weight-strong);
}
.eq-notification-center-text {
color: var(--eq-ink-2);
overflow-wrap: anywhere;
}
.eq-notification-center-time {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
}
.eq-notification-center-actions {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
margin-block: -4px;
color: var(--eq-ink-2);
}
}