NotificationBell
Preview
Basic
Loading…
Preview
Code
ts
import NotificationBell from '@/components/overlay/NotificationBell';src/components/overlay/NotificationBell.tsx
AI prompt
text
Build a header notification bell component (unread badge plus dropdown of notification cards) in React + TypeScript + Tailwind CSS.
## Look
- Root `relative`. Trigger: `relative p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors` around a 16px Bell. While open it keeps `bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200`.
- Badge (only when unread > 0): `absolute -top-1 -right-1 min-w-4 h-4 px-1 flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] font-bold leading-none tabular-nums`, showing "99+" above 99.
- Dropdown: `absolute right-0 mt-2 w-80 z-50 overflow-hidden p-0` on the opaque floating surface.
- Header: `p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between`. Left: text-sm font-bold slate-800 / dark slate-100, `flex items-center gap-2`, with a 16px Bell in indigo-600 / dark indigo-400, "Notifications", and "{n} unread" in 10px font-normal slate-400 / dark slate-500. Right, when unread > 0: a "Mark all read" button with a 12px CheckCheck icon, `text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50`.
- List: `<ol class="max-h-96 overflow-y-auto p-4 space-y-3">` of cards. Empty: `p-6 text-xs text-center text-slate-400 dark:text-slate-500` "Nothing yet. Releases, submissions and approvals land here."
- Footer, ALWAYS present (the dropdown only holds the latest few, so the way to the rest is a fixed part of it), outside the scroller: `border-t p-2` with a full-width link "See all notifications" plus a 12px ArrowRight, `flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800`. Clicking it closes the dropdown.
## The card (build it in the same file)
- `block w-full text-left p-3 border rounded-lg text-xs space-y-1 shadow-sm transition-colors` plus a tint.
- Line 1 (`flex justify-between gap-2 font-bold slate-800 / dark slate-100`): a 14px Bell in the tint's icon colour, the event label (truncating), and on the right the relative time (10px font-normal slate-400 / dark slate-500, with `title` set to "YYYY-MM-DD HH:MM").
- Line 2 (11px leading-relaxed slate-600 / dark slate-400): the reference in `font-mono font-semibold text-indigo-600 dark:text-indigo-400`, then "by {actor}" or "by the system".
- Tints for UNREAD cards, by type: approved → `bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900` (icon emerald-600 / dark emerald-400); rejected or failed → the same in rose; needs a human → amber; `request.*` → indigo; other workflow events → violet. READ cards, and unknown types, are neutral: `bg-slate-50 border-slate-200 dark:bg-slate-800/40 dark:border-slate-700`, icon slate-400 / dark slate-500. Colour means "still wants attention".
- The label comes from a type → text map ("request.approved" → "Request approved"), falling back to the raw type. Relative time: "Just now", "12m ago", "3h ago", "2d ago", then the ISO date after 7 days.
- A card with somewhere to go is a link (`hover:brightness-95 dark:hover:brightness-110`). One without is a button whose only job is marking itself read, disabled once read or while an update is pending.
## Behaviour
- Toggle on click. Clicking a card closes the dropdown if the card links somewhere, and if the card is unread calls `onMarkRead([eventId])`. "Mark all read" calls `onMarkAllRead()`.
- Run both inside `useTransition`: while pending, the buttons are disabled. If the promise resolves `{ error }`, show an error toast, then refresh the data (e.g. `router.refresh()`). The `unread` count comes from the server; without the callbacks the bell is display-only.
## API
- `items: HeaderNotification[]`, where `HeaderNotification = { eventId: number; type: string; createdAt: string; actor: string | null; ref: string | null; requestId: number | null; read: boolean }`. The card links to the request list when `requestId` is set.
- `unread: number`
- `onMarkRead?(ids: number[]): Promise<{ error?: string } | void>`, `onMarkAllRead?(): Promise<{ error?: string } | void>`
## Accessibility
- Trigger: `aria-label` "Notifications, 2 unread" (or just "Notifications"), `aria-expanded`, `title="Notifications"`.
## Demo
A right-aligned bell with `unread={2}` and four items:
- REQ-4471, request.approved, by Grace Hopper, unread
- REQ-4470, request.submitted, by Alan Turing, unread
- REQ-4468, request.rejected, by Ada Lovelace, read
- BATCH-88, batch.released, by the system, read, with no request
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: bonus-adjustment (96S2), verbatim. */
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react';
import { ArrowRight, Bell, CheckCheck } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import NotificationCard from './NotificationCard';
import type { HeaderNotification } from '@/components/overlay/NotificationCard';
/**
* The header bell, rendering the mockup's notification cards
* (docs/dashboard.html → `renderNotifications()`): a tinted, bordered card per
* row with a bold title, the relative time on the right, and the detail beneath
* — not a flat list.
*
* Rows are `events`, the audit trail that replaced Lark, and "unread" is this
* user's own read state, so two people clearing their bells don't affect each
* other. A row links to its request where it has one; there is no request
* detail page yet (DASHBOARD-SPEC §4), so that link goes to the list.
*/
export default function NotificationBell({
items,
unread,
onMarkRead,
onMarkAllRead,
}: {
items: HeaderNotification[];
unread: number;
/** Persist a read. Omit for a purely optimistic bell. */
onMarkRead?: (eventIds: number[]) => Promise<{ error?: string } | void>;
onMarkAllRead?: () => Promise<{ error?: string } | void>;
}) {
const [open, setOpen] = useState(false);
const [pending, startTransition] = useTransition();
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
useDismiss(ref, open, () => setOpen(false));
const readOne = (eventId: number) => {
startTransition(async () => {
const result = (await onMarkRead?.([eventId])) ?? {};
if ('error' in result) toast.error(result.error);
router.refresh();
});
};
const readAll = () => {
startTransition(async () => {
const result = (await onMarkAllRead?.()) ?? {};
if ('error' in result) toast.error(result.error);
router.refresh();
});
};
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
title="Notifications"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
aria-expanded={open}
className={cn(
'relative p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors',
open && 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-200',
)}
>
<Bell className="w-4 h-4" />
{unread > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 flex items-center justify-center rounded-full bg-rose-500 text-white text-[10px] font-bold leading-none tabular-nums">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{/* `panel-solid` is `.panel` without the translucency: this floats over
page content rather than sitting on the gradient ground, and at 60%
white the rows behind it read straight through the list. Shared with
the appearance and account menus so the three cannot drift apart. */}
{open && (
<div className="absolute right-0 mt-2 w-80 panel panel-solid p-0 z-50 overflow-hidden">
{/* The mockup's drawer header, kept verbatim in shape: bell, bold
title, and the dismissive control on the right. */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<span className="font-bold text-slate-800 dark:text-slate-100 text-sm flex items-center gap-2">
<Bell className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
Notifications
{unread > 0 && (
<span className="text-[10px] font-normal text-slate-400 dark:text-slate-500">
{unread} unread
</span>
)}
</span>
{unread > 0 && (
<button
type="button"
onClick={readAll}
disabled={pending}
className="flex items-center gap-1 text-[11px] text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
>
<CheckCheck className="w-3 h-3" />
Mark all read
</button>
)}
</div>
{items.length === 0 ? (
<p className="p-6 text-xs text-center text-slate-400 dark:text-slate-500">
Nothing yet. Releases, submissions and approvals land here.
</p>
) : (
<ol className="max-h-96 overflow-y-auto custom-scrollbar p-4 space-y-3">
{items.map((n) => (
<li key={n.eventId}>
<NotificationCard
item={n}
// There is no request detail page yet (DASHBOARD-SPEC §4),
// so a card carrying a request goes to the list.
href={n.requestId ? '/requests' : null}
disabled={pending}
onActivate={() => {
if (n.requestId) setOpen(false);
if (!n.read) readOne(n.eventId);
}}
/>
</li>
))}
</ol>
)}
{/* Always present, including on an empty bell: the dropdown holds the
latest NOTIFICATION_LIMIT only, so the way to the rest has to be a
fixed part of the surface rather than something that appears once
there is enough history to need it. Outside the scrolling <ol> so
it stays pinned to the bottom edge. */}
<div className="border-t border-slate-200 dark:border-slate-700 p-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="flex items-center justify-center gap-1.5 w-full rounded-lg px-3 py-2 text-[11px] font-semibold text-indigo-600 dark:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
See all notifications
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | HeaderNotification[] | — | |
unread* | number | — | |
onMarkRead | (eventIds: number[]) => Promise<{ error?: string } | void> | — | Persist a read. Omit for a purely optimistic bell. |
onMarkAllRead | () => Promise<{ error?: string } | void> | — |