CollaborationNotifications
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callNotifications
An inbox where agent updates and mentions arrive live. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add notificationsReact
npx designme add notifications --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Notifications is an inbox panel where updates from the agent and mentions from people arrive in one list. New items drop in at the top and bump the unread count, and tabs split the list into All, Agent and Mentions. A person clicks an item to mark it read or unread, or clears everything at once with Mark all read.
- When background agent runs finish, pause or need review while the person is elsewhere
- When agent updates and teammates' mentions should share one inbox but stay easy to separate
- When a header bell or side panel needs a list with a live unread count
- When an agent stops at a limit and the person has to hear about it
- Agent items show an
iconon a tile tinted with itscolor; people's items show anavatarwith initials. - A new id at the top of
itemsdrops in on its own and makes the unread count bump. - The filter switch shows all items or only those whose
kindmatches, and you can supply your ownfilters. - Clicking an item toggles
read, and the change comes back throughonItemsChange. - Mark all read marks every item read, and the unread dots in view clear one row after another.
- An empty filter shows
emptyLabel, which defaults to "Nothing here yet".
- The unread count has an
aria-labellike "3 unread". - Each item is a button whose
aria-pressedreports whether it has been read. - The filter is a
grouplabelled Filter, and each option reportsaria-pressed.
Usage
Import it and drive it from your own state.
Import
import { Notifications } from '@/components/designme/notifications/Notifications';Example
The demo above as a React component, 34 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { Notifications, type NotificationsItem } from './Notifications';
import { useStory } from '../lib/useScript';
/* ---------- Notifications: an inbox where two new items arrive while you watch ---------- */
const START: NotificationsItem[] = [
{ id: 'audit', kind: 'agent', icon: 'ok', color: 'var(--dm-ok)', text: <><b>Pricing audit</b> finished. 2 fixes ready to review</>, time: '4 min ago' },
{ id: 'maya', kind: 'mention', avatar: { initials: 'MK', color: 'var(--dm-av-l)' }, text: <><b>Maya</b> mentioned you in Checkout flow</>, time: '20 min ago' },
{ id: 'export', kind: 'agent', icon: 'alert', color: 'var(--dm-warn)', text: <><b>Token export</b> paused at the $5 cap</>, time: '1 hour ago' },
{ id: 'jon', kind: 'mention', avatar: { initials: 'JO', color: 'var(--dm-av-m)' }, text: <><b>Jon</b> asked for your review on PR #284</>, time: '2 hours ago', read: true }
];
const INCOMING: NotificationsItem[] = [
{ id: 'qa', kind: 'agent', icon: 'spark', color: 'var(--dm-accent)', text: <><b>Weekly design QA</b> started on 12 frames</>, time: 'Just now' },
{ id: 'ari', kind: 'mention', avatar: { initials: 'AR', color: 'var(--dm-av-p)' }, text: <><b>Ari</b> replied: “Looks great, ship it”</>, time: 'Just now' }
];
export default function NotificationsDemo() {
const ref = useRef<HTMLDivElement>(null);
const [items, setItems] = useState(START);
// two items arrive while you watch; your first click or key in the inbox stops them, so a filter you picked or a
// Mark all read stays as you left it
useStory(ref, async ({ wait }) => {
await wait(2200);
for (const x of INCOMING) { setItems(xs => [x, ...xs]); await wait(2600); }
});
return (
<div ref={ref} style={{ width: '100%' }}>
<Notifications items={items} onItemsChange={setItems} />
</div>
);
}
Props
Everything Notifications takes.
| Prop | Type | What it does |
|---|---|---|
items | NotificationsItem[] | Newest first. A new id at the top drops in and bumps the count |
defaultItems | NotificationsItem[] | |
onItemsChange | (items: NotificationsItem[]) => void | Called when items are read or unread, one at a time or all at once |
filters | NotificationsFilter[] | |
filter | string | The open filter. Leave it out to let the switch change it |
defaultFilter | string | |
onFilterChange | (filter: string) => void | |
title | string | |
markAllLabel | string | |
emptyLabel | string | |
className | string |
Code
The full source, as the CLI installs it.
Notifications.tsxReact · 107 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Avatar, enter, Seg } from '../lib/motion';
import '../designme.css';
import './notifications.css';
export interface NotificationsItem {
id: string;
/** Which filter it belongs to, like 'agent' or 'mention' */
kind: string;
/** What happened. Wrap the subject in <b>, like <><b>Pricing audit</b> finished</> */
text: ReactNode;
/** Like "4 min ago" */
time: string;
/** An icon on a tinted tile, for things the agent did */
icon?: IconName;
/** The icon's colour, like 'var(--dm-ok)' */
color?: string;
/** An avatar instead of an icon, for people */
avatar?: { initials: string; color: string };
read?: boolean;
}
export interface NotificationsFilter {
/** 'all' shows everything; anything else shows the items of that kind */
value: string;
label: string;
}
export interface NotificationsProps {
/** Newest first. A new id at the top drops in and bumps the count */
items?: NotificationsItem[];
defaultItems?: NotificationsItem[];
/** Called when items are read or unread, one at a time or all at once */
onItemsChange?: (items: NotificationsItem[]) => void;
filters?: NotificationsFilter[];
/** The open filter. Leave it out to let the switch change it */
filter?: string;
defaultFilter?: string;
onFilterChange?: (filter: string) => void;
title?: string;
markAllLabel?: string;
emptyLabel?: string;
className?: string;
}
const FILTERS: NotificationsFilter[] = [{ value: 'all', label: 'All' }, { value: 'agent', label: 'Agent' }, { value: 'mention', label: 'Mentions' }];
/** An inbox where agent updates and mentions arrive live. Click one to mark it read or unread. */
export function Notifications({ items, defaultItems = [], onItemsChange, filters = FILTERS, filter, defaultFilter = 'all', onFilterChange, title = 'Inbox', markAllLabel = 'Mark all read', emptyLabel = 'Nothing here yet', className }: NotificationsProps) {
const [list, setList] = useControllable(items, defaultItems, onItemsChange);
const [cur, setCur] = useControllable(filter, defaultFilter, onFilterChange);
const shown = list.filter(x => cur === 'all' || x.kind === cur);
const unread = list.filter(x => !x.read).length;
// the rows painted together (on first show and on each filter) stagger in; ones that arrive later drop in on their own
const [paint, setPaint] = useState(() => ({ filter: cur, ids: shown.map(x => x.id) }));
if (paint.filter !== cur) setPaint({ filter: cur, ids: shown.map(x => x.id) });
// a new item bumps the count
const [known, setKnown] = useState(() => new Set(list.map(x => x.id)));
const [bump, setBump] = useState(0);
const fresh = list.filter(x => !known.has(x.id));
if (fresh.length) { setKnown(new Set([...known, ...fresh.map(x => x.id)])); setBump(b => b + 1); }
// Mark all read clears the dots one row after another
const [pending, setPending] = useState<string[]>([]);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const markAll = () => {
const rows = shown.filter(x => !x.read).map(x => x.id);
setPending(rows);
rows.forEach((id, i) => timers.current.push(setTimeout(() => setPending(p => p.filter(x => x !== id)), i * 50)));
setList(list.map(x => (x.read ? x : { ...x, read: true })));
};
const toggle = (id: string) => setList(list.map(x => (x.id === id ? { ...x, read: !x.read } : x)));
return (
<div data-dm="" className={cx('dm-ntx dm-panel', className)}>
<div className="dm-ntx-h">
<b>{title}</b>
<span key={bump} className={cx('dm-ntx-ct dm-num', !unread && 'dm-z', bump > 0 && 'dm-bump')} aria-label={`${unread} unread`}>{unread}</span>
<span className="dm-ntx-sp" />
<button type="button" className="dm-pill dm-ghost dm-sm dm-ntx-all" onClick={markAll}>{markAllLabel}</button>
</div>
<div className="dm-ntx-tabs"><Seg label="Filter" value={cur} onChange={setCur} options={filters} /></div>
<div key={paint.filter} className="dm-ntx-list">
{shown.length ? shown.map(x => {
const at = paint.ids.indexOf(x.id), e = at >= 0 ? enter(at * 40) : null;
return (
<button type="button" key={x.id} className={cx('dm-ntx-n', x.read && !pending.includes(x.id) && 'dm-read', e ? e.className : 'dm-new')} style={e?.style}
aria-pressed={!!x.read} onClick={() => toggle(x.id)}>
<span className="dm-ic" style={x.avatar ? undefined : { color: x.color, background: `color-mix(in srgb,${x.color ?? 'currentColor'} 13%,transparent)` }}>
{x.avatar ? <Avatar initials={x.avatar.initials} color={x.avatar.color} /> : <Icon name={x.icon ?? 'bell'} size="xs" />}
</span>
<span className="dm-t">{x.text}<span>{x.time}</span></span>
<span className="dm-u" />
</button>
);
}) : <div className="dm-ntx-e">{emptyLabel}</div>}
</div>
</div>
);
}
notifications.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Notifications ---------- */
.dm-ntx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2);min-height:360px}
/* on a card the inbox keeps the room its fullest list takes, so it stays centred while items arrive, after a filter and after Mark all read */
.dm-stage-in>.dm-ntx{min-height:inherit}
.dm-ntx-h{display:flex;align-items:center;gap:var(--dm-s-2);margin:0 -12px 2px;padding:2px var(--dm-s-3) 10px var(--dm-s-4);font-size:14px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ntx-h b{font-weight:500}
.dm-ntx-ct{min-width:20px;height:20px;padding:0 6px;border-radius:var(--dm-r-pill);background:var(--dm-accent);color:var(--dm-on-accent);font-size:11px;font-weight:500;display:grid;place-items:center;transition:transform .35s var(--dm-ease),opacity .3s}
.dm-ntx-ct.dm-z{opacity:0;transform:scale(.5)}
.dm-ntx-ct.dm-bump{animation:dm-ntb .45s var(--dm-ease)}
@keyframes dm-ntb{40%{transform:scale(1.3)}}
.dm-ntx-sp{flex:1}
.dm-ntx-tabs .dm-seg2 button{font-size:12px;height:26px;padding:0 9px}
.dm-ntx-list{display:flex;flex-direction:column}
.dm-ntx-n{display:flex;gap:10px;padding:9px var(--dm-s-2);border-radius:var(--dm-r-in);text-align:left;width:100%;position:relative;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-ntx-n:hover{background:var(--dm-row)}}
.dm-ntx-n .dm-ic{width:30px;height:30px;border-radius:10px;display:grid;place-items:center;flex:none}
.dm-ntx-n .dm-ic .dm-av{width:30px;height:30px;font-size:11px}
.dm-ntx-n .dm-t{flex:1;min-width:0;font-size:13px;line-height:1.45;color:var(--dm-fg2)}
.dm-ntx-n .dm-t b{font-weight:500;color:var(--dm-fg)}
.dm-ntx-n .dm-t span{display:block;font-size:11.5px;color:var(--dm-muted);margin-top:2px}
.dm-ntx-n .dm-u{width:7px;height:7px;border-radius:50%;background:var(--dm-accent-mark,var(--dm-accent));margin-top:6px;flex:none;transition:transform .3s var(--dm-ease),opacity .3s}
.dm-ntx-n.dm-read .dm-u{transform:scale(0);opacity:0}
.dm-ntx-n.dm-read .dm-t{color:var(--dm-muted)}
.dm-ntx-n.dm-new{animation:dm-ntn .6s var(--dm-ease) both}
@keyframes dm-ntn{from{opacity:0;transform:translateY(-10px);filter:blur(4px)}}
.dm-ntx-e{padding:40px 10px;text-align:center;font-size:13px;color:var(--dm-muted)}
notifications.htmlHTML · 10 lines
<div class="card" data-c="notifications">
<div class="stage-in">
<div class="ntx panel">
<div class="ntx-h"><b>Inbox</b><span class="ntx-ct num">0</span><span class="ntx-sp"></span><button class="pill ghost sm ntx-all">Mark all read</button></div>
<div class="ntx-tabs"><div class="seg2" role="group" aria-label="Filter"><button aria-pressed="true" data-v="all">All</button><button aria-pressed="false" data-v="agent">Agent</button><button aria-pressed="false" data-v="men">Mentions</button></div></div>
<div class="ntx-list"></div>
</div>
</div>
</div>
notifications.cssHTML · 27 lines
/* ---------- Notifications ---------- */
.ntx{padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2);min-height:360px}
/* on a card the inbox keeps the room its fullest list takes, so it stays centred while items arrive, after a filter and after Mark all read */
.stage-in>.ntx{min-height:inherit}
.ntx-h{display:flex;align-items:center;gap:var(--s-2);margin:0 -12px 2px;padding:2px var(--s-3) 10px var(--s-4);font-size:14px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ntx-h b{font-weight:500}
.ntx-ct{min-width:20px;height:20px;padding:0 6px;border-radius:var(--r-pill);background:var(--accent);color:var(--on-accent);font-size:11px;font-weight:500;display:grid;place-items:center;transition:transform .35s var(--ease),opacity .3s}
.ntx-ct.z{opacity:0;transform:scale(.5)}
.ntx-ct.bump{animation:ntb .45s var(--ease)}
@keyframes ntb{40%{transform:scale(1.3)}}
.ntx-sp{flex:1}
.ntx-tabs .seg2 button{font-size:12px;height:26px;padding:0 9px}
.ntx-list{display:flex;flex-direction:column}
.ntx-n{display:flex;gap:10px;padding:9px var(--s-2);border-radius:var(--r-in);text-align:left;width:100%;position:relative;transition:background .2s}
@media (hover:hover) and (pointer:fine){.ntx-n:hover{background:var(--row)}}
.ntx-n .ic{width:30px;height:30px;border-radius:10px;display:grid;place-items:center;flex:none}
.ntx-n .ic .av{width:30px;height:30px;font-size:11px}
.ntx-n .t{flex:1;min-width:0;font-size:13px;line-height:1.45;color:var(--fg2)}
.ntx-n .t b{font-weight:500;color:var(--fg)}
.ntx-n .t span{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}
.ntx-n .u{width:7px;height:7px;border-radius:50%;background:var(--accent-mark,var(--accent));margin-top:6px;flex:none;transition:transform .3s var(--ease),opacity .3s}
.ntx-n.read .u{transform:scale(0);opacity:0}
.ntx-n.read .t{color:var(--muted)}
.ntx-n.new{animation:ntn .6s var(--ease) both}
@keyframes ntn{from{opacity:0;transform:translateY(-10px);filter:blur(4px)}}
.ntx-e{padding:40px 10px;text-align:center;font-size:13px;color:var(--muted)}
notifications.jsHTML · 22 lines
DM.def('notifications', (r, c) => {
let N = [
{ k:'agent', ic:'ok', col:'var(--ok)', t:'<b>Pricing audit</b> finished. 2 fixes ready to review', w:'4 min ago' },
{ k:'men', av:['MK','var(--av-l)'], t:'<b>Maya</b> mentioned you in Checkout flow', w:'20 min ago' },
{ k:'agent', ic:'alert', col:'var(--warn)', t:'<b>Token export</b> paused at the $5 cap', w:'1 hour ago' },
{ k:'men', av:['JO','var(--av-m)'], t:'<b>Jon</b> asked for your review on PR #284', w:'2 hours ago', read:true }
];
const list = $('.ntx-list', r), ct = $('.ntx-ct', r); let f = 'all';
const count = (bump) => { const n = N.filter(x => !x.read).length; ct.textContent = n; ct.classList.toggle('z', !n); if (bump){ ct.classList.remove('bump'); void ct.offsetWidth; ct.classList.add('bump'); } };
const row = x => `<button class="ntx-n${x.read ? ' read' : ''}"><span class="ic" ${x.ic ? `style="color:${x.fg || x.col};background:color-mix(in srgb,${x.col} 13%,transparent)"` : ''}>${x.ic ? I(x.ic,'xs') : AV(x.av[0], x.av[1])}</span><span class="t">${x.t}<span>${x.w}</span></span><span class="u"></span></button>`;
function paint(anim){ const L = N.filter(x => f === 'all' || x.k === f); list.innerHTML = L.length ? L.map(row).join('') : '<div class="ntx-e">Nothing here yet</div>';
$$('.ntx-n', list).forEach((b, i) => b.onclick = () => { L[i].read = !L[i].read; b.classList.toggle('read', L[i].read); count(); });
if (anim) stagger($$('.ntx-n', list), 40); count(); }
seg($('.ntx-tabs .seg2', r), v => { f = v; paint(true); });
$('.ntx-all', r).onclick = () => { N.forEach(x => x.read = true); $$('.ntx-n', list).forEach((b, i) => c.later(i * 50, () => b.classList.add('read'))); count(); };
paint(true);
const incoming = [{ k:'agent', ic:'spark', col:'var(--accent-mark,var(--accent))', fg:'var(--accent-fg,var(--accent))', t:'<b>Weekly design QA</b> started on 12 frames', w:'Just now' }, { k:'men', av:['AR','var(--av-p)'], t:'<b>Ari</b> replied: “Looks great, ship it”', w:'Just now' }];
// two items arrive while you watch; once you click or type in the inbox the preview leaves it to you, so a tab you
// picked or a Mark all read stays as you left it
incoming.forEach((x, i) => c.later(2200 + i * 2600, () => { if (c.mine) return; N.unshift(x); if (f === 'all' || f === x.k){ const el = html(row(x)); el.classList.add('new'); list.prepend(el); $('.ntx-e', list)?.remove(); el.onclick = () => { x.read = !x.read; el.classList.toggle('read', x.read); count(); }; } count(true); }));
});
Questions
What people ask about it.
- How do I push a new notification in while the inbox is open?
- Control it with
itemsandonItemsChange, and add the new item to the front of the array with an id it has not seen. It drops in at the top and the count bumps. - Can I add my own filter, like Billing?
- Yes. Pass
filterswith avalueandlabelfor each tab, and give items the matchingkind. The value 'all' always shows everything. - How do I bold the subject of a notification?
texttakes React nodes, so wrap the subject in<b>, like<><b>Pricing audit</b> finished</>.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.