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
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.

Nothing opened yet.
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.

Code
import { NotificationCenter } from "@/components/equip/notification-center";

export default function Example() {
  return <NotificationCenter items={[]} placement="bottom-start" />;
}

Props

PropTypeDefaultWhat it does
items{ id: string; title: string; text?: ReactNode; time: string; dateTime?: string; group?: string; unread?: boolean; href?: string }[]noneThe notifications, newest first. Items with the same group are listed under one heading in order.
onMarkRead(id: string) => voidnoneCalled when an unread item is marked read.
onMarkUnread(id: string) => voidnoneCalled when a read item is marked unread. Without it read items have no toggle.
onMarkAllRead() => voidnoneShows Mark all as read in the header.
onDismiss(id: string) => voidnoneShows a dismiss button on each item.
onOpenItem(id: string) => voidnoneCalled when an item's title is activated. Without href, the title becomes a button.
titlestring"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.
emptyReactNodenoneReplaces the default empty state.
...restComponentProps<"div">noneEvery native div attribute, applied to the root.

Anatomy

root.eq-notification-center
bell.eq-notification-center-bellA ghost Button with popovertarget, named Notifications, 3 unread.
count.eq-notification-center-countThe solid unread badge. Hidden from assistive tech; the bell's name says the number.
status.eq-notification-center-statusVisually hidden polite live region beside the bell.
hidden.eq-notification-center-hiddenVisually hidden Unread prefix on unread titles.
panel.eq-notification-center-panelThe Popover, role="dialog", named by its heading.
header.eq-notification-center-header
title.eq-notification-center-titleAn h2.
mark all.eq-notification-center-all
body.eq-notification-center-bodyScrolls when the list is long.
empty.eq-notification-center-emptyHolds an EmptyState when there are no items.
group.eq-notification-center-group
group label.eq-notification-center-group-labelVisible label; the list carries it as aria-label.
list.eq-notification-center-list
item.eq-notification-center-itemdata-unread when unread.
dot.eq-notification-center-dotThe unread marker, a solid dot that grows in.
content.eq-notification-center-content
open.eq-notification-center-openThe 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-actionsAbove the row link, so they stay clickable.
action.eq-notification-center-actionMark as read or unread, and Dismiss.

Keyboard

EnterSpaceOn the bell, opens or closes the panel.
TabMoves through Mark all as read, then each item's title, read toggle and dismiss button.
Arrow downArrow upInside the list, moves focus to the next or previous item's title.
HomeEndInside the list, moves focus to the first or last item.
EscapeCloses 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index