Skip to content
Star on GitHub
CollaborationNotifications
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Notifications

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.

$npx designme add notifications
Inbox0
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add notifications

React

$npx designme add notifications --react

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

02

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
03

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>
  );
}
04

Props

Everything Notifications takes.

PropTypeWhat it does
itemsNotificationsItem[]Newest first. A new id at the top drops in and bumps the count
defaultItemsNotificationsItem[]
onItemsChange(items: NotificationsItem[]) => voidCalled when items are read or unread, one at a time or all at once
filtersNotificationsFilter[]
filterstringThe open filter. Leave it out to let the switch change it
defaultFilterstring
onFilterChange(filter: string) => void
titlestring
markAllLabelstring
emptyLabelstring
classNamestring
05

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); }));
});
06

Questions

What people ask about it.

How do I push a new notification in while the inbox is open?
Control it with items and onItemsChange, 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 filters with a value and label for each tab, and give items the matching kind. The value 'all' always shows everything.
How do I bold the subject of a notification?
text takes React nodes, so wrap the subject in <b>, like <><b>Pricing audit</b> finished</>.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
Ticket listSupport tickets the agent has triaged, with suggested replies.
All 10 in Collaboration →